From e1ce80a375598855621b50e2bf6730fb72bbaa2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 9 May 2022 13:53:38 +0300 Subject: [PATCH 0001/1592] Fix Libdoc tests on Windows. Strangely XMLSchema there doesn't work with Path objects. --- atest/robot/libdoc/LibDocLib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atest/robot/libdoc/LibDocLib.py b/atest/robot/libdoc/LibDocLib.py index 7fbc07bcb81..dc439e74862 100644 --- a/atest/robot/libdoc/LibDocLib.py +++ b/atest/robot/libdoc/LibDocLib.py @@ -20,7 +20,7 @@ class LibDocLib: def __init__(self, interpreter=None): self.interpreter = interpreter - self.xml_schema = XMLSchema(ROOT/'doc/schema/libdoc.xsd') + self.xml_schema = XMLSchema(str(ROOT/'doc/schema/libdoc.xsd')) with open(ROOT/'doc/schema/libdoc.json') as f: self.json_schema = Draft202012Validator(json.load(f)) From 264cec29cf79940630ef72d1410967704a288d7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 May 2022 21:18:28 +0300 Subject: [PATCH 0002/1592] Bump codecov/codecov-action from 3.0.0 to 3.1.0 (#4321) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.0.0 to 3.1.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/e3c560433a6cc60aec8812599b7844a7b4fa0d71...81cd2dc8148241f03f5839d295e000b8f761e378) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index ce04f907db5..8337f15178f 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -47,7 +47,7 @@ jobs: python -m coverage xml -i if: always() - - uses: codecov/codecov-action@e3c560433a6cc60aec8812599b7844a7b4fa0d71 + - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 with: name: ${{ matrix.python-version }}-${{ matrix.os }} if: always() From 3b40d5de279a498f019709fd487915f5fb182797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 9 May 2022 21:17:24 +0300 Subject: [PATCH 0003/1592] Release notes for 5.0.1rc1 --- doc/releasenotes/rf-5.0.1rc1.rst | 170 +++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 doc/releasenotes/rf-5.0.1rc1.rst diff --git a/doc/releasenotes/rf-5.0.1rc1.rst b/doc/releasenotes/rf-5.0.1rc1.rst new file mode 100644 index 00000000000..e85a9c656f1 --- /dev/null +++ b/doc/releasenotes/rf-5.0.1rc1.rst @@ -0,0 +1,170 @@ +========================================= +Robot Framework 5.0.1 release candidate 1 +========================================= + +.. default-role:: code + +`Robot Framework`_ 5.0.1 is the first and also the last planned bug fix +release in the Robot Framework 5.0.x series. This release candidate contains +all issues targeted to the final release. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==5.0.1rc1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 5.0.1 rc 1 was released on Monday May 9, 2022. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.0.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Robot Framework Slack: https://robotframework-slack-invite.herokuapp.com +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important fixes and enhancements +===================================== + +Fix crash when using `partialmethod` in library +----------------------------------------------- + +Using `partialmethod` in a library crashed the whole execution (`#4318`_). +As part of the fix, support to create keyword using both `partialmethod` and +`partial` as added. + +Libdoc JSON schema has been updated +----------------------------------- + +There were some changes to Libdoc's spec files in Robot Framework 5.0. +The schema for XML spec files was updated already then, but the JSON schema +was updated only now as part of Robot Framework 5.0.1 development (`#4281`_). +All schema files, including the output.xml schema, can be found at +https://github.com/robotframework/robotframework/tree/master/doc/schema/. + +Acknowledgements +================ + +Robot Framework 5.0.1 development has been sponsored by the `Robot Framework Foundation`_ +and its close to 50 member organizations. Big thanks for the foundation for its continued +support! + +Thanks also to all community members who have submitted bug reports, helped debugging +problems, or otherwise helped with the release. + +| `Pekka Klärck `__ +| Robot Framework Creator + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#4318`_ + - bug + - high + - Support creating functions using `partial` and `partialmethod` (earlier the latter caused a crash) + - rc 1 + * - `#4281`_ + - enhancement + - high + - Update Libdoc's JSON schema + - rc 1 + * - `#3862`_ + - bug + - medium + - `--runemptysuite` should work with `--rerunfailed` when there are no failed tests + - rc 1 + * - `#4175`_ + - bug + - medium + - `Get Variable Value` fails if variable name contains another variable and resolving it fails + - rc 1 + * - `#4204`_ + - bug + - medium + - Using `Set Test Variable` in suite setup or teardown should be a normal error, not a syntax error + - rc 1 + * - `#4305`_ + - bug + - medium + - Functions named `__init__` are exposed as keywords with name `__init__`, not `Init` + - rc 1 + * - `#4315`_ + - bug + - medium + - `BuiltIn.Log`: Using invalid log level should be a normal error, not a syntax error + - rc 1 + * - `#4324`_ + - bug + - medium + - Programmatic execution API doesn't support `pathlib.Path` objects + - rc 1 + * - `#4331`_ + - bug + - medium + - Example in API documentation uses an out-of-date API + - rc 1 + * - `#4336`_ + - bug + - medium + - `Get Variable Value` doesn't support accessing list/dict variable items like `${var}[0]` or `${var}[key]` + - rc 1 + * - `#4292`_ + - bug + - low + - Process library test related to sending signals fails on Guix + - rc 1 + * - `#4314`_ + - bug + - low + - Document that `EXCEPT` does not catch syntax errors + - rc 1 + * - `#4322`_ + - enhancement + - low + - DateTime: Enhance documentation related to the epoch time format + - rc 1 + +Altogether 13 issues. View on the `issue tracker `__. + +.. _#4318: https://github.com/robotframework/robotframework/issues/4318 +.. _#4281: https://github.com/robotframework/robotframework/issues/4281 +.. _#3862: https://github.com/robotframework/robotframework/issues/3862 +.. _#4175: https://github.com/robotframework/robotframework/issues/4175 +.. _#4204: https://github.com/robotframework/robotframework/issues/4204 +.. _#4305: https://github.com/robotframework/robotframework/issues/4305 +.. _#4315: https://github.com/robotframework/robotframework/issues/4315 +.. _#4324: https://github.com/robotframework/robotframework/issues/4324 +.. _#4331: https://github.com/robotframework/robotframework/issues/4331 +.. _#4336: https://github.com/robotframework/robotframework/issues/4336 +.. _#4292: https://github.com/robotframework/robotframework/issues/4292 +.. _#4314: https://github.com/robotframework/robotframework/issues/4314 +.. _#4322: https://github.com/robotframework/robotframework/issues/4322 From 30946eed584bf15a580adffa758b4070cd3bcfb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 9 May 2022 21:17:37 +0300 Subject: [PATCH 0004/1592] Updated version to 5.0.1rc1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 06e62fe8fc2..44ff772e41e 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 = '5.0.1.dev1' +VERSION = '5.0.1rc1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 0f4af86b878..3464e6ee25a 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 = '5.0.1.dev1' +VERSION = '5.0.1rc1' def get_version(naked=False): From bd71f2c1be72c8a631e618d27da2e0458c85b374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 9 May 2022 21:28:12 +0300 Subject: [PATCH 0005/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 44ff772e41e..adfa087b5cc 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 = '5.0.1rc1' +VERSION = '5.0.1rc2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 3464e6ee25a..bed86f541f7 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 = '5.0.1rc1' +VERSION = '5.0.1rc2.dev1' def get_version(naked=False): From d3f645a641177e01d5d461b77b1b8d31882401cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 9 May 2022 21:34:19 +0300 Subject: [PATCH 0006/1592] Mention planned final release date in 5.0.1rc1 notes --- doc/releasenotes/rf-5.0.1rc1.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/releasenotes/rf-5.0.1rc1.rst b/doc/releasenotes/rf-5.0.1rc1.rst index e85a9c656f1..081bd59005b 100644 --- a/doc/releasenotes/rf-5.0.1rc1.rst +++ b/doc/releasenotes/rf-5.0.1rc1.rst @@ -29,6 +29,8 @@ distribution from PyPI_ and install it manually. For more details and other installation approaches, see the `installation instructions`_. Robot Framework 5.0.1 rc 1 was released on Monday May 9, 2022. +The plan is to get the final release out on Monday May 16, just before the +`RoboCon 2022 `_ conference. .. _Robot Framework: http://robotframework.org .. _Robot Framework Foundation: http://robotframework.org/foundation From f17bcd3e346f291e87ee9757ca8ff5c361982815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 11 May 2022 22:23:02 +0300 Subject: [PATCH 0007/1592] Fine-tune CI runs. - Hopefully fix PyPy by using correct interpreter name. - Add Python 3.10. - Remove seemingly unnecessary config. --- .github/workflows/acceptance_tests_cpython.yml | 8 ++------ .github/workflows/acceptance_tests_cpython_pr.yml | 11 +---------- .github/workflows/unit_tests.yml | 4 ++-- .github/workflows/unit_tests_pr.yml | 3 --- 4 files changed, 5 insertions(+), 21 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 3f82b6594eb..6b72e719d45 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -18,19 +18,15 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', 'pypy3' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy3.8' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 - os: windows-latest set_codepage: chcp 850 - - os: windows-latest - python-version: '3.9' - set_codepage: chcp 850 - atest_args: --exclude require-lxml --exclude require-screenshot exclude: - os: windows-latest - python-version: 'pypy3' + python-version: 'pypy3.8' runs-on: ${{ matrix.os }} diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index f3f304832f5..cf911f47861 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -1,4 +1,4 @@ -name: Acceptance tests (CPython + PyPy) +name: Acceptance tests (CPython) on: pull_request: @@ -21,15 +21,6 @@ jobs: set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 - os: windows-latest set_codepage: chcp 850 - - os: windows-latest - python-version: '3.9' - set_codepage: chcp 850 - atest_args: --exclude require-lxml --exclude require-screenshot - exclude: - - os: windows-latest - python-version: 'pypy2' - - os: windows-latest - python-version: 'pypy3' runs-on: ${{ matrix.os }} diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 8337f15178f..6c6d984807c 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,10 +19,10 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', 'pypy3' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy3.8' ] exclude: - os: windows-latest - python-version: 'pypy3' + python-version: 'pypy3.8' runs-on: ${{ matrix.os }} diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml index 0730dab205f..bd35f81fb1a 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -16,9 +16,6 @@ jobs: matrix: os: [ 'ubuntu-latest', 'windows-latest' ] python-version: [ '3.6', '3.9' ] - exclude: - - os: windows-latest - python-version: 'pypy3' runs-on: ${{ matrix.os }} From 64a3615564c07f05c1a465dfb1fcb21189a610b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 11 May 2022 22:36:00 +0300 Subject: [PATCH 0008/1592] Correct PyPy interpreter. Also remove unnecessary PyPy config. --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 6 ------ .github/workflows/unit_tests.yml | 4 ++-- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 6b72e719d45..ebce7335e16 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy3.8' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.8' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 @@ -26,7 +26,7 @@ jobs: set_codepage: chcp 850 exclude: - os: windows-latest - python-version: 'pypy3.8' + python-version: 'pypy-3.8' runs-on: ${{ matrix.os }} diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index cf911f47861..8388268e170 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -62,12 +62,6 @@ jobs: choco install zip -y --no-progress if: runner.os == 'Windows' - - name: Install Ubuntu PyPy dependencies - run: | - sudo apt-get update - sudo apt-get -y -q install libxslt-dev libxml2-dev - if: contains(matrix.python-version, 'pypy') && contains(matrix.os, 'ubuntu') - - name: Install screen and report handling tools, and other required libraries to Linux run: | sudo apt-get update diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 6c6d984807c..1892634f184 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,10 +19,10 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy3.8' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.8' ] exclude: - os: windows-latest - python-version: 'pypy3.8' + python-version: 'pypy-3.8' runs-on: ${{ matrix.os }} From 02136a2ce1d080d9fcd286e2837a92778cd76012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 16 May 2022 10:24:23 +0300 Subject: [PATCH 0009/1592] Remove duplicate test that depends on shell semantics. Fixes #4292. --- .../standard_libraries/process/sending_signal.robot | 9 ++------- .../standard_libraries/process/sending_signal.robot | 12 +++--------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/atest/robot/standard_libraries/process/sending_signal.robot b/atest/robot/standard_libraries/process/sending_signal.robot index d9575e82c4b..f0342200fe8 100644 --- a/atest/robot/standard_libraries/process/sending_signal.robot +++ b/atest/robot/standard_libraries/process/sending_signal.robot @@ -23,18 +23,13 @@ Sending INT signal as a number Send other well-known signals Check Test Case ${TESTNAME} -By default signal is not sent to process running in shell - [Documentation] This depends on shell semantics and is tested only on Linux. - [Tags] require-linux - Check Test Case ${TESTNAME} - By default signal is sent only to parent process Check Test Case ${TESTNAME} -Signal can be sent to process running in shell +Signal can be sent to child processes Check Test Case ${TESTNAME} -Signal can be sent to child processes +Signal can be sent to process running in shell Check Test Case ${TESTNAME} Sending an unknown signal diff --git a/atest/testdata/standard_libraries/process/sending_signal.robot b/atest/testdata/standard_libraries/process/sending_signal.robot index 1b8563bfc3e..52f6347617b 100644 --- a/atest/testdata/standard_libraries/process/sending_signal.robot +++ b/atest/testdata/standard_libraries/process/sending_signal.robot @@ -21,23 +21,17 @@ Send other well-known signals Killer signal ${signal} END -By default signal is not sent to process running in shell - Check Precondition sys.platform == 'linux' - Start Countdown shell=yes - Send Signal To Process TERM - Countdown should not have stopped - By default signal is sent only to parent process Start Countdown children=3 Send Signal To Process SIGTERM Countdown should not have stopped -Signal can be sent to process running in shell - Killer signal TERM shell=True group=yes - Signal can be sent to child processes Killer signal TERM children=3 group=${True} +Signal can be sent to process running in shell + Killer signal TERM shell=True group=yes + Sending an unknown signal [Documentation] FAIL Unsupported signal 'unknown'. Start Python Process 1+1 From 6419d6fd855d872ee071f0d0d88888d4c9ca2c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 16 May 2022 10:53:49 +0300 Subject: [PATCH 0010/1592] Release notes for 5.0.1 --- doc/releasenotes/rf-5.0.1.rst | 155 ++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 doc/releasenotes/rf-5.0.1.rst diff --git a/doc/releasenotes/rf-5.0.1.rst b/doc/releasenotes/rf-5.0.1.rst new file mode 100644 index 00000000000..64a5255cc2a --- /dev/null +++ b/doc/releasenotes/rf-5.0.1.rst @@ -0,0 +1,155 @@ +===================== +Robot Framework 5.0.1 +===================== + +.. default-role:: code + +`Robot Framework`_ 5.0.1 is the first and also the last planned bug fix +release in the Robot Framework 5.0.x series. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +:: + + pip install --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==5.0.1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 5.0.1 was released on Monday May 16, 2022, +just before the `RoboCon 2022 `_ conference. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.0.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Robot Framework Slack: https://robotframework-slack-invite.herokuapp.com +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Fix crash when using `partialmethod` in library +----------------------------------------------- + +Using `partialmethod` in a library crashed the whole execution (`#4318`_). +As part of the fix, support to create keyword using both `partialmethod` and +`partial` was added. + +Libdoc JSON schema has been updated +----------------------------------- + +There were some changes to Libdoc's spec files in Robot Framework 5.0. +The schema for XML spec files was updated already then, but the JSON schema +was updated only now as part of Robot Framework 5.0.1 development (`#4281`_). +All schema files, including the output.xml schema, can be found here__. + +__ https://github.com/robotframework/robotframework/tree/master/doc/schema/ + +Acknowledgements +================ + +Robot Framework 5.0.1 development has been sponsored by the `Robot Framework Foundation`_ +and its close to 50 member organizations. Big thanks for the foundation for its continued +support! + +Thanks also to all community members who have submitted bug reports, helped debugging +problems, or otherwise helped with the release. + +| `Pekka Klärck `__ +| Robot Framework Creator + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#4318`_ + - bug + - high + - Support creating functions using `partial` and `partialmethod` (earlier the latter caused a crash) + * - `#4281`_ + - enhancement + - high + - Update Libdoc's JSON schema + * - `#3862`_ + - bug + - medium + - `--runemptysuite` should work with `--rerunfailed` when there are no failed tests + * - `#4175`_ + - bug + - medium + - `Get Variable Value` fails if variable name contains another variable and resolving it fails + * - `#4204`_ + - bug + - medium + - Using `Set Test Variable` in suite setup or teardown should be a normal error, not a syntax error + * - `#4305`_ + - bug + - medium + - Functions named `__init__` are exposed as keywords with name `__init__`, not `Init` + * - `#4315`_ + - bug + - medium + - `BuiltIn.Log`: Using invalid log level should be a normal error, not a syntax error + * - `#4324`_ + - bug + - medium + - Programmatic execution API doesn't support `pathlib.Path` objects + * - `#4331`_ + - bug + - medium + - Example in API documentation uses an out-of-date API + * - `#4336`_ + - bug + - medium + - `Get Variable Value` doesn't support accessing list/dict variable items like `${var}[0]` or `${var}[key]` + * - `#4292`_ + - bug + - low + - Process library test related to sending signals fails on Guix + * - `#4314`_ + - bug + - low + - Document that `EXCEPT` does not catch syntax errors + * - `#4322`_ + - enhancement + - low + - DateTime: Enhance documentation related to the epoch time format + +Altogether 13 issues. View on the `issue tracker `__. + +.. _#4318: https://github.com/robotframework/robotframework/issues/4318 +.. _#4281: https://github.com/robotframework/robotframework/issues/4281 +.. _#3862: https://github.com/robotframework/robotframework/issues/3862 +.. _#4175: https://github.com/robotframework/robotframework/issues/4175 +.. _#4204: https://github.com/robotframework/robotframework/issues/4204 +.. _#4305: https://github.com/robotframework/robotframework/issues/4305 +.. _#4315: https://github.com/robotframework/robotframework/issues/4315 +.. _#4324: https://github.com/robotframework/robotframework/issues/4324 +.. _#4331: https://github.com/robotframework/robotframework/issues/4331 +.. _#4336: https://github.com/robotframework/robotframework/issues/4336 +.. _#4292: https://github.com/robotframework/robotframework/issues/4292 +.. _#4314: https://github.com/robotframework/robotframework/issues/4314 +.. _#4322: https://github.com/robotframework/robotframework/issues/4322 From 41513d0ce336de91753a5381bb888b01bd82b8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 16 May 2022 10:54:01 +0300 Subject: [PATCH 0011/1592] Updated version to 5.0.1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index adfa087b5cc..87be145c308 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 = '5.0.1rc2.dev1' +VERSION = '5.0.1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index bed86f541f7..048fb52c553 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 = '5.0.1rc2.dev1' +VERSION = '5.0.1' def get_version(naked=False): From dfc51bcc2a6380c8a1db56c9d294250e59b53db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 16 May 2022 10:57:41 +0300 Subject: [PATCH 0012/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 87be145c308..0762dfa6a2c 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 = '5.0.1' +VERSION = '5.0.2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 048fb52c553..27faada8a39 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 = '5.0.1' +VERSION = '5.0.2.dev1' def get_version(naked=False): From 555980fc9e06acb0f38942772a86b0533916baba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 23 May 2022 15:26:12 +0300 Subject: [PATCH 0013/1592] RF 5.1 development can start! --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 0762dfa6a2c..61f0daaa6bd 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 = '5.0.2.dev1' +VERSION = '5.1.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 27faada8a39..fa9fd180d8c 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 = '5.0.2.dev1' +VERSION = '5.1.dev1' def get_version(naked=False): From 8de56dca3a5aeb8a092196c4ac794bc15c1a60b8 Mon Sep 17 00:00:00 2001 From: pianofab Date: Mon, 23 May 2022 05:27:57 -0700 Subject: [PATCH 0014/1592] Use proper main_thread API from Python 3.4 (#4342) https://docs.python.org/3.4/library/threading.html#threading.main_thread Co-authored-by: Fabrizio Oddone --- src/robot/running/signalhandler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robot/running/signalhandler.py b/src/robot/running/signalhandler.py index cdabcdaaf95..f09d5b182e5 100644 --- a/src/robot/running/signalhandler.py +++ b/src/robot/running/signalhandler.py @@ -14,7 +14,7 @@ # limitations under the License. import sys -from threading import current_thread +from threading import current_thread, main_thread import signal from robot.errors import ExecutionFailed @@ -57,7 +57,7 @@ def __exit__(self, *exc_info): @property def _can_register_signal(self): - return signal and current_thread().name == 'MainThread' + return signal and current_thread() is main_thread() def _register_signal_handler(self, signum): try: From 4bd7870e93dc0361dcee536168871f25b2a4cce5 Mon Sep 17 00:00:00 2001 From: UliSei <89480399+UliSei@users.noreply.github.com> Date: Wed, 1 Jun 2022 14:22:03 +0200 Subject: [PATCH 0015/1592] Update ResourceAndVariableFiles.rst (#4352) #4349 Resolved by adding missing key/value pair. --- doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst index 50856137ba5..0068148cffe 100644 --- a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst +++ b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst @@ -577,7 +577,7 @@ as this Variable section: ${STRING} Hello, world! ${INTEGER} ${42} @{LIST} one two - &{DICT} one=yksi two=kaksi + &{DICT} one=yksi two=kaksi with spaces=kolme YAML files used as variable files must always be mappings in the top level. As the above example demonstrates, keys and values in the mapping become From 6f4a30b11ad928015ccb77795986c8cb3cb69842 Mon Sep 17 00:00:00 2001 From: Fabio Zadrozny Date: Thu, 2 Jun 2022 09:17:33 -0300 Subject: [PATCH 0016/1592] Make parsing 17% faster. (#4350) --- .gitignore | 1 + src/robot/parsing/lexer/blocklexers.py | 120 ++++++++++++++------- src/robot/parsing/lexer/statementlexers.py | 48 ++++++--- src/robot/parsing/lexer/tokenizer.py | 51 ++++----- 4 files changed, 138 insertions(+), 82 deletions(-) diff --git a/.gitignore b/.gitignore index 0f618b1148a..079f36c3b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ __pycache__ .classpath .settings .jython_cache +/.mypy_cache/ diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index 458bc027137..d246fe788d9 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -52,14 +52,15 @@ def input(self, statement): return lexer def lexer_for(self, statement): - for cls in self.lexer_classes(): - lexer = cls(self.ctx) - if lexer.handles(statement): + for cls in self.RESOLVED_LEXER_CLASSES: + if cls.handles(self.ctx, statement): + lexer = cls(self.ctx) return lexer raise TypeError("%s did not find lexer for statement %s." % (type(self).__name__, statement)) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return () def lex(self): @@ -80,7 +81,8 @@ class FileLexer(BlockLexer): def lex(self): self._lex_with_priority(priority=SettingSectionLexer) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (SettingSectionLexer, VariableSectionLexer, TestCaseSectionLexer, KeywordSectionLexer, CommentSectionLexer, ErrorSectionLexer, @@ -95,64 +97,78 @@ def accepts_more(self, statement): class SettingSectionLexer(SectionLexer): - def handles(self, statement): - return self.ctx.setting_section(statement) + @classmethod + def handles(cls, ctx, statement): + return ctx.setting_section(statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (SettingSectionHeaderLexer, SettingLexer) class VariableSectionLexer(SectionLexer): - def handles(self, statement): - return self.ctx.variable_section(statement) + @classmethod + def handles(cls, ctx, statement): + return ctx.variable_section(statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (VariableSectionHeaderLexer, VariableLexer) class TestCaseSectionLexer(SectionLexer): - def handles(self, statement): - return self.ctx.test_case_section(statement) + @classmethod + def handles(cls, ctx, statement): + return ctx.test_case_section(statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (TestCaseSectionHeaderLexer, TestCaseLexer) class KeywordSectionLexer(SettingSectionLexer): - def handles(self, statement): - return self.ctx.keyword_section(statement) + @classmethod + def handles(cls, ctx, statement): + return ctx.keyword_section(statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (KeywordSectionHeaderLexer, KeywordLexer) class CommentSectionLexer(SectionLexer): - def handles(self, statement): - return self.ctx.comment_section(statement) + @classmethod + def handles(cls, ctx, statement): + return ctx.comment_section(statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (CommentSectionHeaderLexer, CommentLexer) class ImplicitCommentSectionLexer(SectionLexer): - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return True - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (CommentLexer,) class ErrorSectionLexer(SectionLexer): - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement and statement[0].value.startswith('*') - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (ErrorSectionHeaderLexer, CommentLexer) @@ -177,9 +193,10 @@ def _handle_name_or_indentation(self, statement): self._name_seen = True else: while statement and not statement[0].value: - statement.pop(0).type = None # These tokens will be ignored + statement.pop(0).type = None # These tokens will be ignored - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (TestOrKeywordSettingLexer, BreakLexer, ContinueLexer, ForLexer, InlineIfLexer, IfLexer, ReturnLexer, TryLexer, WhileLexer, KeywordCallLexer) @@ -223,30 +240,36 @@ def input(self, statement): class ForLexer(NestedBlockLexer): - def handles(self, statement): - return ForHeaderLexer(self.ctx).handles(statement) + @classmethod + def handles(cls, ctx, statement): + return ForHeaderLexer.handles(ctx, statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (ForHeaderLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) class WhileLexer(NestedBlockLexer): - def handles(self, statement): - return WhileHeaderLexer(self.ctx).handles(statement) + @classmethod + def handles(cls, ctx, statement): + return WhileHeaderLexer.handles(ctx, statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (WhileHeaderLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) class IfLexer(NestedBlockLexer): - def handles(self, statement): - return IfHeaderLexer(self.ctx).handles(statement) + @classmethod + def handles(cls, ctx, statement): + return IfHeaderLexer.handles(ctx, statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (InlineIfLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ForLexer, TryLexer, WhileLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) @@ -254,15 +277,17 @@ def lexer_classes(self): class InlineIfLexer(BlockLexer): - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): if len(statement) <= 2: return False - return InlineIfHeaderLexer(self.ctx).handles(statement) + return InlineIfHeaderLexer.handles(ctx, statement) def accepts_more(self, statement): return False - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (InlineIfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) @@ -305,10 +330,23 @@ def _split(self, statement): class TryLexer(NestedBlockLexer): - def handles(self, statement): - return TryHeaderLexer(self.ctx).handles(statement) + @classmethod + def handles(cls, ctx, statement): + return TryHeaderLexer(ctx).handles(ctx, statement) - def lexer_classes(self): + @classmethod + def lexer_classes(cls): return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, ForLexer, InlineIfLexer, IfLexer, WhileLexer, EndLexer, ReturnLexer, BreakLexer, ContinueLexer, KeywordCallLexer) + + +def _make_RESOLVED_LEXER_CLASSES_class_access(): + for _, cls in globals().items(): + if hasattr(cls, 'lexer_classes'): + cls.RESOLVED_LEXER_CLASSES = cls.lexer_classes() + + +# Optimization: set RESOLVED_LEXER_CLASSES directly in class to avoid function +# call on performance-sensitive areas. +_make_RESOLVED_LEXER_CLASSES_class_access() diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 4f81ef15525..d7462b1e1a2 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -25,7 +25,8 @@ class Lexer: def __init__(self, ctx): self.ctx = ctx - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return True def accepts_more(self, statement): @@ -72,7 +73,8 @@ def lex(self): class SectionHeaderLexer(SingleType): - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value.startswith('*') @@ -114,7 +116,8 @@ def lex(self): class TestOrKeywordSettingLexer(SettingLexer): - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): marker = statement[0].value return marker and marker[0] == '[' and marker[-1] == ']' @@ -150,7 +153,8 @@ def _lex_as_keyword_call(self): class ForHeaderLexer(StatementLexer): separators = ('IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP') - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'FOR' def lex(self): @@ -169,14 +173,16 @@ def lex(self): class IfHeaderLexer(TypeAndArguments): token_type = Token.IF - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'IF' and len(statement) <= 2 class InlineIfHeaderLexer(StatementLexer): token_type = Token.INLINE_IF - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): for token in statement: if token.value == 'IF': return True @@ -199,28 +205,32 @@ def lex(self): class ElseIfHeaderLexer(TypeAndArguments): token_type = Token.ELSE_IF - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return normalize_whitespace(statement[0].value) == 'ELSE IF' class ElseHeaderLexer(TypeAndArguments): token_type = Token.ELSE - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'ELSE' class TryHeaderLexer(TypeAndArguments): token_type = Token.TRY - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'TRY' class ExceptHeaderLexer(StatementLexer): token_type = Token.EXCEPT - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'EXCEPT' def lex(self): @@ -243,14 +253,16 @@ def lex(self): class FinallyHeaderLexer(TypeAndArguments): token_type = Token.FINALLY - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'FINALLY' class WhileHeaderLexer(StatementLexer): token_type = Token.WHILE - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'WHILE' def lex(self): @@ -264,26 +276,30 @@ def lex(self): class EndLexer(TypeAndArguments): token_type = Token.END - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'END' class ReturnLexer(TypeAndArguments): token_type = Token.RETURN_STATEMENT - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'RETURN' class ContinueLexer(TypeAndArguments): token_type = Token.CONTINUE - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'CONTINUE' class BreakLexer(TypeAndArguments): token_type = Token.BREAK - def handles(self, statement): + @classmethod + def handles(cls, ctx, statement): return statement[0].value == 'BREAK' diff --git a/src/robot/parsing/lexer/tokenizer.py b/src/robot/parsing/lexer/tokenizer.py index 1784cbec0f7..5f0654ccfa6 100644 --- a/src/robot/parsing/lexer/tokenizer.py +++ b/src/robot/parsing/lexer/tokenizer.py @@ -72,49 +72,52 @@ def _split_from_pipes(self, line): yield rest, True def _cleanup_tokens(self, tokens, data_only): - has_data = self._handle_comments(tokens) - continues = self._handle_continuation(tokens) - self._remove_trailing_empty(tokens) - if continues: - self._remove_leading_empty(tokens) - self._ensure_data_after_continuation(tokens) - if data_only: - tokens = self._remove_non_data(tokens) - return tokens, has_data and not continues - - def _handle_comments(self, tokens): + # Handle comments has_data = False - commented = False - for token in tokens: + + iter_in_tokens = iter(tokens) + for token in iter_in_tokens: if token.type is None: # lstrip needed to strip possible leading space from first token. # Other leading/trailing spaces have been consumed as separators. value = token.value.lstrip() - if value and not commented: + if value: if value[0] == '#': - commented = True + # When we find a #, consume iterator and mark remaining + # tokens as comments. + token.type = Token.COMMENT + for token in iter_in_tokens: + if token.type is None: + token.type = Token.COMMENT else: has_data = True - if commented: - token.type = Token.COMMENT - return has_data - def _handle_continuation(self, tokens): + # Handle continuation + continues = False for token in tokens: if token.value == '...' and token.type is None: token.type = Token.CONTINUATION - return True + continues = True + break elif token.value and token.type != Token.SEPARATOR: - return False - return False + continues = False + break - def _remove_trailing_empty(self, tokens): + # Remove trailing empty for token in reversed(tokens): if not token.value and token.type != Token.EOL: tokens.remove(token) elif token.type is None: break + if continues: + self._remove_leading_empty(tokens) + self._ensure_data_after_continuation(tokens) + if data_only: + # Remove non data + tokens = [t for t in tokens if t.type is None] + return tokens, has_data and not continues + def _remove_leading_empty(self, tokens): data_or_continuation = (None, Token.CONTINUATION) for token in list(tokens): @@ -134,5 +137,3 @@ def _find_continuation(self, tokens): if token.type == Token.CONTINUATION: return token - def _remove_non_data(self, tokens): - return [t for t in tokens if t.type is None] From 1e2af544f0070fda931c596018f5d3fb7d93f7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Jun 2022 15:21:31 +0300 Subject: [PATCH 0017/1592] Consistent patterns --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 079f36c3b0d..4028dcaab8f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ __pycache__ .classpath .settings .jython_cache -/.mypy_cache/ +.mypy_cache/ From cd0a74a7e7ac90d5c139983163e88ca2194d911c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Jun 2022 15:23:30 +0300 Subject: [PATCH 0018/1592] Remove seemingly inefficient optimization. The optimization was part of PR #4350. This particular change basically avoided a function call when finding a lexer for a new statement. It was a bit ugly and on my machine brought no measurable performance benefits. @fabioz reported 2% enhancement with his tests but even that's pretty little. Python 3.11 should also make function calls faster in general: https://docs.python.org/3.11/whatsnew/3.11.html#faster-cpython --- src/robot/parsing/lexer/blocklexers.py | 58 +++++++------------------- 1 file changed, 16 insertions(+), 42 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index d246fe788d9..8b8da2748ea 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -52,15 +52,14 @@ def input(self, statement): return lexer def lexer_for(self, statement): - for cls in self.RESOLVED_LEXER_CLASSES: + for cls in self.lexer_classes(): if cls.handles(self.ctx, statement): lexer = cls(self.ctx) return lexer raise TypeError("%s did not find lexer for statement %s." % (type(self).__name__, statement)) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return () def lex(self): @@ -81,8 +80,7 @@ class FileLexer(BlockLexer): def lex(self): self._lex_with_priority(priority=SettingSectionLexer) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (SettingSectionLexer, VariableSectionLexer, TestCaseSectionLexer, KeywordSectionLexer, CommentSectionLexer, ErrorSectionLexer, @@ -101,8 +99,7 @@ class SettingSectionLexer(SectionLexer): def handles(cls, ctx, statement): return ctx.setting_section(statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (SettingSectionHeaderLexer, SettingLexer) @@ -112,8 +109,7 @@ class VariableSectionLexer(SectionLexer): def handles(cls, ctx, statement): return ctx.variable_section(statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (VariableSectionHeaderLexer, VariableLexer) @@ -123,8 +119,7 @@ class TestCaseSectionLexer(SectionLexer): def handles(cls, ctx, statement): return ctx.test_case_section(statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (TestCaseSectionHeaderLexer, TestCaseLexer) @@ -134,8 +129,7 @@ class KeywordSectionLexer(SettingSectionLexer): def handles(cls, ctx, statement): return ctx.keyword_section(statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (KeywordSectionHeaderLexer, KeywordLexer) @@ -145,8 +139,7 @@ class CommentSectionLexer(SectionLexer): def handles(cls, ctx, statement): return ctx.comment_section(statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (CommentSectionHeaderLexer, CommentLexer) @@ -156,8 +149,7 @@ class ImplicitCommentSectionLexer(SectionLexer): def handles(cls, ctx, statement): return True - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (CommentLexer,) @@ -167,8 +159,7 @@ class ErrorSectionLexer(SectionLexer): def handles(cls, ctx, statement): return statement and statement[0].value.startswith('*') - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (ErrorSectionHeaderLexer, CommentLexer) @@ -195,8 +186,7 @@ def _handle_name_or_indentation(self, statement): while statement and not statement[0].value: statement.pop(0).type = None # These tokens will be ignored - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (TestOrKeywordSettingLexer, BreakLexer, ContinueLexer, ForLexer, InlineIfLexer, IfLexer, ReturnLexer, TryLexer, WhileLexer, KeywordCallLexer) @@ -244,8 +234,7 @@ class ForLexer(NestedBlockLexer): def handles(cls, ctx, statement): return ForHeaderLexer.handles(ctx, statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (ForHeaderLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) @@ -256,8 +245,7 @@ class WhileLexer(NestedBlockLexer): def handles(cls, ctx, statement): return WhileHeaderLexer.handles(ctx, statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (WhileHeaderLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) @@ -268,8 +256,7 @@ class IfLexer(NestedBlockLexer): def handles(cls, ctx, statement): return IfHeaderLexer.handles(ctx, statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (InlineIfLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ForLexer, TryLexer, WhileLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) @@ -286,8 +273,7 @@ def handles(cls, ctx, statement): def accepts_more(self, statement): return False - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (InlineIfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) @@ -334,19 +320,7 @@ class TryLexer(NestedBlockLexer): def handles(cls, ctx, statement): return TryHeaderLexer(ctx).handles(ctx, statement) - @classmethod - def lexer_classes(cls): + def lexer_classes(self): return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, ForLexer, InlineIfLexer, IfLexer, WhileLexer, EndLexer, ReturnLexer, BreakLexer, ContinueLexer, KeywordCallLexer) - - -def _make_RESOLVED_LEXER_CLASSES_class_access(): - for _, cls in globals().items(): - if hasattr(cls, 'lexer_classes'): - cls.RESOLVED_LEXER_CLASSES = cls.lexer_classes() - - -# Optimization: set RESOLVED_LEXER_CLASSES directly in class to avoid function -# call on performance-sensitive areas. -_make_RESOLVED_LEXER_CLASSES_class_access() From 707dbce8d5c7d9f9531cbfef95072285bc990a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Jun 2022 15:37:14 +0300 Subject: [PATCH 0019/1592] Refactor tokenization. This commit reverts tokenization changes done in performance optimization PR #4350 but introduces new optimizations that ought to have same/similar performance benefits. The previous optimization mostly avoided some function calls which made the `_cleanup_tokens` function somewhat long. This commit instead combines handling comments and continuations so that there's no need to iterate over tokens twice. In my tests performance is same before and after these changes and I slightly prefer this implementation. Benefits from avoiding function calls ought to also be smaller in the future with Python 3.11: https://docs.python.org/3.11/whatsnew/3.11.html#faster-cpython Hopefully @fabioz can test these changes in his environment. If there are big enough benefits from avoiding function calls, or there are other enhancements, the code can still be optimized more. --- src/robot/parsing/lexer/tokenizer.py | 72 ++++++++++++++-------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/src/robot/parsing/lexer/tokenizer.py b/src/robot/parsing/lexer/tokenizer.py index 5f0654ccfa6..a32edc40a6f 100644 --- a/src/robot/parsing/lexer/tokenizer.py +++ b/src/robot/parsing/lexer/tokenizer.py @@ -72,52 +72,49 @@ def _split_from_pipes(self, line): yield rest, True def _cleanup_tokens(self, tokens, data_only): - # Handle comments - has_data = False + has_data, continues = self._handle_comments_and_continuation(tokens) + self._remove_trailing_empty(tokens) + if continues: + self._remove_leading_empty(tokens) + if not has_data: + self._ensure_data_after_continuation(tokens) + starts_new = False + else: + starts_new = has_data + if data_only: + tokens = self._remove_non_data(tokens) + return tokens, starts_new - iter_in_tokens = iter(tokens) - for token in iter_in_tokens: + def _handle_comments_and_continuation(self, tokens): + has_data = False + continues = False + commented = False + for token in tokens: if token.type is None: # lstrip needed to strip possible leading space from first token. # Other leading/trailing spaces have been consumed as separators. value = token.value.lstrip() - if value: + if commented: + token.type = Token.COMMENT + elif value: if value[0] == '#': - # When we find a #, consume iterator and mark remaining - # tokens as comments. token.type = Token.COMMENT - for token in iter_in_tokens: - if token.type is None: - token.type = Token.COMMENT - else: - has_data = True - - # Handle continuation - continues = False - for token in tokens: - if token.value == '...' and token.type is None: - token.type = Token.CONTINUATION - continues = True - break - elif token.value and token.type != Token.SEPARATOR: - continues = False - break - - # Remove trailing empty + commented = True + elif not has_data: + if value == '...' and not continues: + token.type = Token.CONTINUATION + continues = True + else: + has_data = True + return has_data, continues + + def _remove_trailing_empty(self, tokens): for token in reversed(tokens): if not token.value and token.type != Token.EOL: tokens.remove(token) elif token.type is None: break - if continues: - self._remove_leading_empty(tokens) - self._ensure_data_after_continuation(tokens) - if data_only: - # Remove non data - tokens = [t for t in tokens if t.type is None] - return tokens, has_data and not continues - def _remove_leading_empty(self, tokens): data_or_continuation = (None, Token.CONTINUATION) for token in list(tokens): @@ -127,13 +124,14 @@ def _remove_leading_empty(self, tokens): break def _ensure_data_after_continuation(self, tokens): - if not any(t.type is None for t in tokens): - cont = self._find_continuation(tokens) - token = Token(lineno=cont.lineno, col_offset=cont.end_col_offset) - tokens.insert(tokens.index(cont) + 1, token) + cont = self._find_continuation(tokens) + token = Token(lineno=cont.lineno, col_offset=cont.end_col_offset) + tokens.insert(tokens.index(cont) + 1, token) def _find_continuation(self, tokens): for token in tokens: if token.type == Token.CONTINUATION: return token + def _remove_non_data(self, tokens): + return [t for t in tokens if t.type is None] From 30d45122e56bf6c88987a857067bcf2e9b37db66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Jun 2022 17:12:41 +0300 Subject: [PATCH 0020/1592] More natural argument order --- src/robot/parsing/lexer/blocklexers.py | 36 +++++++++++----------- src/robot/parsing/lexer/statementlexers.py | 32 +++++++++---------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index 8b8da2748ea..ffbf68e4a2e 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -53,7 +53,7 @@ def input(self, statement): def lexer_for(self, statement): for cls in self.lexer_classes(): - if cls.handles(self.ctx, statement): + if cls.handles(statement, self.ctx): lexer = cls(self.ctx) return lexer raise TypeError("%s did not find lexer for statement %s." @@ -96,7 +96,7 @@ def accepts_more(self, statement): class SettingSectionLexer(SectionLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return ctx.setting_section(statement) def lexer_classes(self): @@ -106,7 +106,7 @@ def lexer_classes(self): class VariableSectionLexer(SectionLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return ctx.variable_section(statement) def lexer_classes(self): @@ -116,7 +116,7 @@ def lexer_classes(self): class TestCaseSectionLexer(SectionLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return ctx.test_case_section(statement) def lexer_classes(self): @@ -126,7 +126,7 @@ def lexer_classes(self): class KeywordSectionLexer(SettingSectionLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return ctx.keyword_section(statement) def lexer_classes(self): @@ -136,7 +136,7 @@ def lexer_classes(self): class CommentSectionLexer(SectionLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return ctx.comment_section(statement) def lexer_classes(self): @@ -146,7 +146,7 @@ def lexer_classes(self): class ImplicitCommentSectionLexer(SectionLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return True def lexer_classes(self): @@ -156,7 +156,7 @@ def lexer_classes(self): class ErrorSectionLexer(SectionLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement and statement[0].value.startswith('*') def lexer_classes(self): @@ -231,8 +231,8 @@ def input(self, statement): class ForLexer(NestedBlockLexer): @classmethod - def handles(cls, ctx, statement): - return ForHeaderLexer.handles(ctx, statement) + def handles(cls, statement, ctx): + return ForHeaderLexer.handles(statement, ctx) def lexer_classes(self): return (ForHeaderLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, EndLexer, @@ -242,8 +242,8 @@ def lexer_classes(self): class WhileLexer(NestedBlockLexer): @classmethod - def handles(cls, ctx, statement): - return WhileHeaderLexer.handles(ctx, statement) + def handles(cls, statement, ctx): + return WhileHeaderLexer.handles(statement, ctx) def lexer_classes(self): return (WhileHeaderLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, EndLexer, @@ -253,8 +253,8 @@ def lexer_classes(self): class IfLexer(NestedBlockLexer): @classmethod - def handles(cls, ctx, statement): - return IfHeaderLexer.handles(ctx, statement) + def handles(cls, statement, ctx): + return IfHeaderLexer.handles(statement, ctx) def lexer_classes(self): return (InlineIfLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, @@ -265,10 +265,10 @@ def lexer_classes(self): class InlineIfLexer(BlockLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): if len(statement) <= 2: return False - return InlineIfHeaderLexer.handles(ctx, statement) + return InlineIfHeaderLexer.handles(statement, ctx) def accepts_more(self, statement): return False @@ -317,8 +317,8 @@ def _split(self, statement): class TryLexer(NestedBlockLexer): @classmethod - def handles(cls, ctx, statement): - return TryHeaderLexer(ctx).handles(ctx, statement) + def handles(cls, statement, ctx): + return TryHeaderLexer(ctx).handles(statement, ctx) def lexer_classes(self): return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index d7462b1e1a2..e1fa3969b3f 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -26,7 +26,7 @@ def __init__(self, ctx): self.ctx = ctx @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return True def accepts_more(self, statement): @@ -74,7 +74,7 @@ def lex(self): class SectionHeaderLexer(SingleType): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value.startswith('*') @@ -117,7 +117,7 @@ def lex(self): class TestOrKeywordSettingLexer(SettingLexer): @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): marker = statement[0].value return marker and marker[0] == '[' and marker[-1] == ']' @@ -154,7 +154,7 @@ class ForHeaderLexer(StatementLexer): separators = ('IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP') @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'FOR' def lex(self): @@ -174,7 +174,7 @@ class IfHeaderLexer(TypeAndArguments): token_type = Token.IF @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'IF' and len(statement) <= 2 @@ -182,7 +182,7 @@ class InlineIfHeaderLexer(StatementLexer): token_type = Token.INLINE_IF @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): for token in statement: if token.value == 'IF': return True @@ -206,7 +206,7 @@ class ElseIfHeaderLexer(TypeAndArguments): token_type = Token.ELSE_IF @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return normalize_whitespace(statement[0].value) == 'ELSE IF' @@ -214,7 +214,7 @@ class ElseHeaderLexer(TypeAndArguments): token_type = Token.ELSE @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'ELSE' @@ -222,7 +222,7 @@ class TryHeaderLexer(TypeAndArguments): token_type = Token.TRY @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'TRY' @@ -230,7 +230,7 @@ class ExceptHeaderLexer(StatementLexer): token_type = Token.EXCEPT @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'EXCEPT' def lex(self): @@ -254,7 +254,7 @@ class FinallyHeaderLexer(TypeAndArguments): token_type = Token.FINALLY @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'FINALLY' @@ -262,7 +262,7 @@ class WhileHeaderLexer(StatementLexer): token_type = Token.WHILE @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'WHILE' def lex(self): @@ -277,7 +277,7 @@ class EndLexer(TypeAndArguments): token_type = Token.END @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'END' @@ -285,7 +285,7 @@ class ReturnLexer(TypeAndArguments): token_type = Token.RETURN_STATEMENT @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'RETURN' @@ -293,7 +293,7 @@ class ContinueLexer(TypeAndArguments): token_type = Token.CONTINUE @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'CONTINUE' @@ -301,5 +301,5 @@ class BreakLexer(TypeAndArguments): token_type = Token.BREAK @classmethod - def handles(cls, ctx, statement): + def handles(cls, statement, ctx): return statement[0].value == 'BREAK' From f8a9861d437c8fac93c4796278e022c75bf74501 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Thu, 2 Jun 2022 18:15:33 -0400 Subject: [PATCH 0021/1592] Honor SOURCE_DATE_EPOCH when generating documentation (#4286) - Generation time in Libdoc outputs takes SOURCE_DATE_EPOCH into account. - Generation from was simply removed from the User Guide. Version number is enough. Fixes #4262. Co-authored-by: Maxim Cournoyer --- BUILD.rst | 6 ++++++ atest/robot/libdoc/html_output.robot | 2 +- atest/robot/libdoc/json_output.robot | 2 +- atest/robot/libdoc/libdoc_resource.robot | 8 +++++++- atest/robot/libdoc/spec_library.robot | 8 ++++++++ .../operating_system/modified_time.robot | 2 +- .../standard_libraries/builtin/get_time.robot | 6 +++--- .../operating_system/modified_time.robot | 4 ++-- doc/userguide/ug2html.py | 3 +-- src/robot/libdocpkg/model.py | 6 +++--- src/robot/libdocpkg/output.py | 13 +++++++++++++ src/robot/libdocpkg/xmlwriter.py | 7 +++---- 12 files changed, 49 insertions(+), 18 deletions(-) diff --git a/BUILD.rst b/BUILD.rst index 8776b2e5dd2..7392a628942 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -204,6 +204,12 @@ Creating distributions 7. Documentation + - For a reproducible build, set the ``SOURCE_DATE_EPOCH`` + environment variable to a constant value, corresponding to the + date in seconds since the Epoch (also known as Epoch time). For + more information regarding this environment variable, see + https://reproducible-builds.org/docs/source-date-epoch/. + - Generate library documentation:: invoke library-docs all diff --git a/atest/robot/libdoc/html_output.robot b/atest/robot/libdoc/html_output.robot index f42a4b150b8..af428c9679a 100644 --- a/atest/robot/libdoc/html_output.robot +++ b/atest/robot/libdoc/html_output.robot @@ -15,7 +15,7 @@ Version Generated [Template] Should Match Regexp - ${MODEL}[generated] \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} + ${MODEL}[generated] \\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}[+-]\\d{2}:\\d{2} Scope ${MODEL}[scope] GLOBAL diff --git a/atest/robot/libdoc/json_output.robot b/atest/robot/libdoc/json_output.robot index 78305a45851..65460370400 100644 --- a/atest/robot/libdoc/json_output.robot +++ b/atest/robot/libdoc/json_output.robot @@ -15,7 +15,7 @@ Version Generated [Template] Should Match Regexp - ${MODEL}[generated] \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} + ${MODEL}[generated] \\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}[+-]\\d{2}:\\d{2} Scope ${MODEL}[scope] GLOBAL diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index 351b5e793d3..86f52285ab1 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -93,7 +93,13 @@ Lineno Should Be Element Attribute Should Be ${LIBDOC} lineno ${lineno} Generated Should Be Defined - Element Attribute Should Match ${LIBDOC} generated ????-??-??T??:??:??Z + # For example, '1970-01-01T00:00:01+00:00'. + Element Attribute Should Match ${LIBDOC} generated ????-??-??T??:??:?????:?? + +Generated Should Be + [Arguments] ${generated} + Generated Should Be Defined + Element Attribute Should Be ${LIBDOC} generated ${generated} Spec version should be correct Element Attribute Should Be ${LIBDOC} specversion 4 diff --git a/atest/robot/libdoc/spec_library.robot b/atest/robot/libdoc/spec_library.robot index 1d74736c308..d64dc0137c6 100644 --- a/atest/robot/libdoc/spec_library.robot +++ b/atest/robot/libdoc/spec_library.robot @@ -1,4 +1,5 @@ *** Settings *** +Library OperatingSystem Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/ExampleSpec.xml Resource libdoc_resource.robot @@ -98,6 +99,13 @@ Keyword Source Info Run Libdoc And Parse Output %{TEMPDIR}/Example.libspec Test Everything +SOURCE_DATE_EPOCH is honored in Libdoc output + [Setup] Set Environment Variable SOURCE_DATE_EPOCH 0 + Copy File ${TESTDATADIR}/ExampleSpec.xml %{TEMPDIR}/Example.libspec + Run Libdoc And Parse Output %{TEMPDIR}/Example.libspec + Generated Should Be 1970-01-01T00:00:00+00:00 + [Teardown] Remove Environment Variable SOURCE_DATE_EPOCH + *** Keywords *** Test Everything Name Should Be Example diff --git a/atest/robot/standard_libraries/operating_system/modified_time.robot b/atest/robot/standard_libraries/operating_system/modified_time.robot index d144ffd3406..c7681e61db0 100644 --- a/atest/robot/standard_libraries/operating_system/modified_time.robot +++ b/atest/robot/standard_libraries/operating_system/modified_time.robot @@ -8,7 +8,7 @@ ${TESTFILE} %{TEMPDIR}${/}robot-os-tests${/}f1.txt *** Test Cases *** Get Modified Time As Timestamp ${tc} = Check Test Case ${TESTNAME} - Should Match Regexp ${tc.kws[0].msgs[0].message} Last modified time of '' is 20\\d\\d-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d + Should Match Regexp ${tc.kws[0].msgs[0].message} Last modified time of '' is \\d\\d\\d\\d-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d Get Modified Time As Seconds After Epoch ${tc} = Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/builtin/get_time.robot b/atest/testdata/standard_libraries/builtin/get_time.robot index 9847d8c425f..24ce732ca41 100644 --- a/atest/testdata/standard_libraries/builtin/get_time.robot +++ b/atest/testdata/standard_libraries/builtin/get_time.robot @@ -11,18 +11,18 @@ Get Time As Timestamp Get Time As Seconds After Epoch ${time} = Get Time epoch - Should Be True 1000000000 < ${time} < 2000000000 + Should Be True 0 < ${time} Get Time As Parts @{time} = Get Time year, month, day, hour, min, sec - Should Be True 2000 < ${time}[0] < 2100 + Should Match Regexp ${time}[0] \\d{4} Should Be True 1 <= int('${time}[1]') <= 12 Should Be True 1 <= int('${time}[2]') <= 31 Should Be True 0 <= int('${time}[3]') <= 23 Should Be True 0 <= int('${time}[4]') <= 59 Should Be True 0 <= int('${time}[5]') <= 59 ${year} ${min} ${sec} = Get Time seconds and minutes and year and whatnot - Should Be True 2000 < ${year} < 2100 + Should Match Regexp ${year} \\d{4} Should Be True 0 <= int('${min}') <= 59 Should Be True 0 <= int('${sec}') <= 59 diff --git a/atest/testdata/standard_libraries/operating_system/modified_time.robot b/atest/testdata/standard_libraries/operating_system/modified_time.robot index 9489b3c9c0c..c712ebaedc7 100644 --- a/atest/testdata/standard_libraries/operating_system/modified_time.robot +++ b/atest/testdata/standard_libraries/operating_system/modified_time.robot @@ -14,13 +14,13 @@ Get Modified Time As Timestamp Get Modified Time As Seconds After Epoch ${dirtime} = Get Modified Time ${CURDIR} epoch - Should Be True 1000000000 < ${dirtime} < 2000000000 + Should Be True ${dirtime} > 0 ${current} = Get Time epoch Should Be True ${current} >= ${dirtime} Get Modified Time As Parts ${year} = Get Modified Time ${CURDIR} year - Should Be True 2000 < ${year} < 2100 + Should Match Regexp ${year} \\d{4} ${yyyy} ${mm} ${dd} = Get Modified Time ${CURDIR} year, month, day Should Be Equal ${yyyy} ${year} # Must use `int('x')` because otherwise 08 and 09 are considered octal diff --git a/doc/userguide/ug2html.py b/doc/userguide/ug2html.py index 03320355206..b278c71c891 100755 --- a/doc/userguide/ug2html.py +++ b/doc/userguide/ug2html.py @@ -150,8 +150,7 @@ def create_userguide(): install_file = _copy_installation_instructions() description = 'HTML generator for Robot Framework User Guide.' - arguments = ['--time', - '--stylesheet-path', ['src/userguide.css'], + arguments = ['--stylesheet-path', ['src/userguide.css'], 'src/RobotFrameworkUserGuide.rst', 'RobotFrameworkUserGuide.html'] os.chdir(CURDIR) diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 90d023c656a..31cd9ef3a89 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -19,11 +19,11 @@ from robot.model import Tags from robot.running import ArgumentSpec -from robot.utils import getshortdoc, get_timestamp, Sortable, setter +from robot.utils import getshortdoc, Sortable, setter from .htmlutils import DocFormatter, DocToHtml, HtmlToText from .writer import LibdocWriter -from .output import LibdocOutput +from .output import LibdocOutput, get_generation_time class LibraryDoc: @@ -114,7 +114,7 @@ def to_dictionary(self): 'name': self.name, 'doc': self.doc, 'version': self.version, - 'generated': get_timestamp(daysep='-', millissep=None), + 'generated': get_generation_time(), 'type': self.type, 'scope': self.scope, 'docFormat': self.doc_format, diff --git a/src/robot/libdocpkg/output.py b/src/robot/libdocpkg/output.py index 533e6ffa0da..b173009261c 100644 --- a/src/robot/libdocpkg/output.py +++ b/src/robot/libdocpkg/output.py @@ -13,7 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import os +import time from robot.utils import file_writer @@ -40,3 +42,14 @@ def __exit__(self, *exc_info): os.remove(self._output_path) except OSError: pass + + +def get_generation_time(): + """Return a timestamp that honors `SOURCE_DATE_EPOCH`. + + This timestamp is to be used for embedding in output files, so + that builds can be made reproducible. + """ + ts = float(os.getenv('SOURCE_DATE_EPOCH', time.time())) + dt = datetime.datetime.fromtimestamp(round(ts), datetime.timezone.utc) + return dt.isoformat() diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index a765ebb2b50..1242059fceb 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -13,10 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from datetime import datetime - from robot.utils import XmlWriter +from .output import get_generation_time + class LibdocXmlWriter: @@ -32,12 +32,11 @@ def write(self, libdoc, outfile): self._write_end(writer) def _write_start(self, libdoc, writer): - generated = datetime.utcnow().replace(microsecond=0).isoformat() + 'Z' attrs = {'name': libdoc.name, 'type': libdoc.type, 'format': libdoc.doc_format, 'scope': libdoc.scope, - 'generated': generated, + 'generated': get_generation_time(), 'specversion': '4'} self._add_source_info(attrs, libdoc) writer.start('keywordspec', attrs) From 832f9111780d440d066cdbd6ba9f927b3f3822ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 6 Jun 2022 16:34:35 +0300 Subject: [PATCH 0022/1592] Remore strange spaces before commas --- .../src/ExtendingRobotFramework/CreatingTestLibraries.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 8aa9f631915..69dcdca3eec 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1186,7 +1186,7 @@ Other types cause conversion failures. | Type | ABC | Aliases | Accepts | Explanation | Examples | +=============+===============+============+==============+================================================================+======================================+ | bool_ | | boolean | str_, | Strings `TRUE`, `YES`, `ON` and `1` are converted to `True`, | | `TRUE` (converted to `True`) | - | | | | int_ , | the empty string as well as `FALSE`, `NO`, `OFF` and `0` | | `off` (converted to `False`) | + | | | | int_, | the empty string as well as `FALSE`, `NO`, `OFF` and `0` | | `off` (converted to `False`) | | | | | float_, | are converted to `False`, and the string `NONE` is converted | | `example` (used as-is) | | | | | None_ | to `None`. Other strings and other accepted values are | | | | | | | passed as-is, allowing keywords to handle them specially if | | @@ -1212,7 +1212,7 @@ Other types cause conversion failures. | | | | | visual separators for digit grouping purposes. | | `10_000.000_01` | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | Decimal_ | | | str_, | Conversion is done using the Decimal_ class. Decimal_ is | | `3.14` | - | | | | int_ , | recommended over float_ when decimal numbers need to be | | `10 000.000 01` | + | | | | int_, | recommended over float_ when decimal numbers need to be | | `10 000.000 01` | | | | | float_ | represented exactly. | | `10_000.000_01` | | | | | | | | | | | | | Starting from RF 4.1, spaces and underscores can be used as | | @@ -1230,7 +1230,7 @@ Other types cause conversion failures. | | | | bytes_ | | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | `datetime | | | str_, | Strings are expected to be timestamps in `ISO 8601`_ like | | `2022-02-09T16:39:43.632269` | - | `__| | | int_ , | format `YYYY-MM-DD hh:mm:ss.mmmmmm`, where any non-digit | | `2022-02-09 16:39` | + | `__| | | int_, | format `YYYY-MM-DD hh:mm:ss.mmmmmm`, where any non-digit | | `2022-02-09 16:39` | | | | | float_ | character can be used as a separator or separators can be | | `2022-02-09` | | | | | | omitted altogether. Additionally, only the date part is | | `${1644417583.632269}` (Epoch time)| | | | | | mandatory, all possibly missing time components are considered | | @@ -1243,7 +1243,7 @@ Other types cause conversion failures. | | | | | time components are expected to be omitted or to be zeros. | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | timedelta_ | | | str_, | Strings are expected to represent a time interval in one of | | `42` (42 seconds) | - | | | | int_ , | the time formats Robot Framework supports: `time as number`_, | | `1 minute 2 seconds` | + | | | | int_, | the time formats Robot Framework supports: `time as number`_, | | `1 minute 2 seconds` | | | | | float_ | `time as time string`_ or `time as "timer" string`_. Integers | | `01:02` (same as above) | | | | | | and floats are considered to be seconds. | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ From e5bcad1ca22a9eeceffc44cfc397c083a59d4d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 6 Jun 2022 17:57:35 +0300 Subject: [PATCH 0023/1592] Fix continuable failures with WHILE. Fixes #4355. --- atest/robot/running/while/while.robot | 6 +++++ atest/testdata/running/while/while.robot | 28 ++++++++++++++++++++++++ src/robot/running/bodyrunner.py | 7 ++++++ 3 files changed, 41 insertions(+) diff --git a/atest/robot/running/while/while.robot b/atest/robot/running/while/while.robot index 91ff24732f1..8df1dc7715e 100644 --- a/atest/robot/running/while/while.robot +++ b/atest/robot/running/while/while.robot @@ -23,6 +23,12 @@ Execution fails on the first loop Execution fails after some loops Check While Loop FAIL 3 +Continuable failure in loop + Check While Loop FAIL 3 + +Normal failure after continuable failure in loop + Check While Loop FAIL 2 + Loop in loop Check While Loop PASS 5 Check While Loop PASS 3 path=body[0].body[0].body[2] diff --git a/atest/testdata/running/while/while.robot b/atest/testdata/running/while/while.robot index 728cce1d6f0..49e2ca96075 100644 --- a/atest/testdata/running/while/while.robot +++ b/atest/testdata/running/while/while.robot @@ -36,6 +36,34 @@ Execution fails after some loops Log ${variable} END +Continuable failure in loop + [Documentation] FAIL + ... Several failures occurred: + ... + ... 1) Oh no 1! + ... + ... 2) Oh no 2! + ... + ... 3) Oh no 3! + [Tags] robot:continue-on-failure + WHILE $variable < 4 + Fail Oh no ${variable}! + ${variable}= Evaluate $variable + 1 + END + +Normal failure after continuable failure in loop + [Documentation] FAIL + ... Several failures occurred: + ... + ... 1) Oh no! + ... + ... 2) Oh no for real! + WHILE True + IF $variable > 1 Fail Oh no for real! + Run Keyword And Continue On Failure Fail Oh no! + ${variable}= Evaluate $variable + 1 + END + Loop in loop WHILE $variable < 6 Log Outer ${variable} diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 13dea76b650..9a0d077df78 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -349,6 +349,7 @@ def run(self, data): if data.error: raise DataError(data.error) limit = WhileLimit.create(data.limit, self._context.variables) + errors = [] while self._should_run(data.condition, self._context.variables) \ and limit.is_valid: executed_once = True @@ -359,9 +360,15 @@ def run(self, data): break except ContinueLoop: continue + except ExecutionFailed as err: + errors.extend(err.get_errors()) + if not err.can_continue(self._context, self._templated): + break if not executed_once: status.pass_status = result.NOT_RUN self._run_iteration(data, result, run=False) + if errors: + raise ExecutionFailures(errors) if not limit.is_valid: raise DataError(limit.reason) From 1dd59a7e4f86bd4726f6eb5355ed8a56eb2e59a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 7 Jun 2022 17:22:24 +0300 Subject: [PATCH 0024/1592] UG: Fix examples Examples using EXPECT with patterns and catching variables use the old pattern type syntax. Fixes #4358. --- doc/userguide/src/CreatingTestData/ControlStructures.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index 7ffb99ba797..11ab288335e 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -1091,9 +1091,9 @@ end of the `EXCEPT` statement: Capture error TRY Some Keyword - EXCEPT GLOB: ValueError: * AS ${error} + EXCEPT ValueError: * type=GLOB AS ${error} Error Handler 1 ${error} - EXCEPT REGEXP: [Ee]rror \\d+ GLOB: ${pattern} AS ${error} + EXCEPT [Ee]rror \\d+ (Invalid|Bad) usage type=REGEXP AS ${error} Error Handler 2 ${error} EXCEPT AS ${error} Error Handler 3 ${error} From f82d1b7632e7eab95721888bf6dd7022b5d5fd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 7 Jun 2022 17:24:18 +0300 Subject: [PATCH 0025/1592] Fix creating TRY and WHILE structures using `from_params`. Also add default values to the Token class for releated tokens. Fixes #4357. --- src/robot/parsing/lexer/tokens.py | 10 +- src/robot/parsing/model/statements.py | 19 ++- utest/parsing/test_statements.py | 197 +++++++++++++++++++------- 3 files changed, 167 insertions(+), 59 deletions(-) diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 33af52091c8..256ea2f849b 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -153,10 +153,12 @@ def __init__(self, type=None, value=None, lineno=-1, col_offset=-1, error=None): self.type = type if value is None: value = { - Token.IF: 'IF', Token.ELSE_IF: 'ELSE IF', Token.ELSE: 'ELSE', - Token.INLINE_IF: 'IF', Token.FOR: 'FOR', Token.END: 'END', - Token.RETURN_STATEMENT: 'RETURN', Token.CONTINUE: 'CONTINUE', Token.BREAK: 'BREAK', - Token.CONTINUATION: '...', Token.EOL: '\n', Token.WITH_NAME: 'WITH NAME' + Token.IF: 'IF', Token.INLINE_IF: 'IF', Token.ELSE_IF: 'ELSE IF', + Token.ELSE: 'ELSE', Token.FOR: 'FOR', Token.WHILE: 'WHILE', + Token.TRY: 'TRY', Token.EXCEPT: 'EXCEPT', Token.FINALLY: 'FINALLY', + Token.END: 'END', Token.CONTINUE: 'CONTINUE', Token.BREAK: 'BREAK', + Token.RETURN_STATEMENT: 'RETURN', Token.CONTINUATION: '...', + Token.EOL: '\n', Token.WITH_NAME: 'WITH NAME', Token.AS: 'AS' }.get(type, '') self.value = value self.lineno = lineno diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 3e5774acac5..2aad3925a2e 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -919,7 +919,7 @@ class ExceptHeader(Statement): type = Token.EXCEPT @classmethod - def from_params(cls, patterns=None, variable=None, indent=FOUR_SPACES, + def from_params(cls, patterns=(), type=None, variable=None, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): tokens = [ Token(Token.SEPARATOR, indent), @@ -927,12 +927,17 @@ def from_params(cls, patterns=None, variable=None, indent=FOUR_SPACES, Token(Token.SEPARATOR, separator) ] for pattern in patterns: - tokens.append(pattern) - tokens.append(Token(Token.SEPARATOR, separator)) + tokens.extend([Token(Token.ARGUMENT, pattern), + Token(Token.SEPARATOR, separator)]) + if type: + tokens.extend([Token(Token.OPTION, f'type={type}'), + Token(Token.SEPARATOR, separator)]) if variable: - tokens.append(Token(Token.AS)) - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.VARIABLE, variable)) + tokens.extend([Token(Token.AS), + Token(Token.SEPARATOR, separator), + Token(Token.VARIABLE, variable)]) + if tokens[-1].type == Token.SEPARATOR: + tokens.pop() tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -984,7 +989,7 @@ def from_params(cls, condition, limit=None, indent=FOUR_SPACES, Token(Token.ARGUMENT, condition)] if limit: tokens.extend([Token(Token.SEPARATOR, indent), - Token(Token.OPTION, limit)]) + Token(Token.OPTION, f'limit={limit}')]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 9c4e48e66d9..b830a2de2aa 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -1,46 +1,7 @@ import unittest +from robot.parsing.model.statements import * from robot.parsing import Token -from robot.parsing.model.statements import ( - Statement, - SectionHeader, - LibraryImport, - ResourceImport, - VariablesImport, - Documentation, - Metadata, - Tags, - TestCaseName, - KeywordName, - ForceTags, - DefaultTags, - SuiteSetup, - SuiteTeardown, - TestSetup, - TestTeardown, - TestTemplate, - TestTimeout, - Variable, - Setup, - Teardown, - Template, - Timeout, - Arguments, - Return, - ReturnStatement, - KeywordCall, - TemplateArguments, - ForHeader, - IfHeader, - InlineIfHeader, - Continue, - Break, - ElseHeader, - ElseIfHeader, - End, - Comment, - EmptyLine -) from robot.utils.asserts import assert_equal, assert_true from robot.utils import type_name @@ -68,14 +29,16 @@ def compare_statements(first, second): def assert_statements(st1, st2): + assert_equal(len(st1), len(st2), + f'Statement lengths are not equal:\n' + f'{len(st1)} for {st1}\n' + f'{len(st2)} for {st2}') for t1, t2 in zip(st1, st2): assert_equal(t1, t2, formatter=repr) - assert_true( - compare_statements(st1, st2), - 'Statements are not equal. %s (%s) != %s (%s)' % (st1, type_name(st1), - st2, type_name(st2)) - ) - assert_equal(len(st1), len(st2)) + assert_true(compare_statements(st1, st2), + f'Statements are not equal:\n' + f'{st1} {type_name(st1)}\n' + f'{st2} {type_name(st2)}') class TestCreateStatementsFromParams(unittest.TestCase): @@ -586,7 +549,7 @@ def test_Arguments(self): args=['${arg1}', '${arg2}=4'] ) - def test_Return(self): + def test_ReturnSetting(self): # Keyword # [Return] ${arg1} ${arg2}=4 tokens = [ @@ -732,6 +695,144 @@ def test_ElseHeader(self): ElseHeader ) + def test_TryHeader(self): + # TRY + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.TRY), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + TryHeader + ) + + def test_ExceptHeader(self): + # EXCEPT + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.EXCEPT), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + ExceptHeader + ) + # EXCEPT one + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.EXCEPT), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'one'), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + ExceptHeader, + patterns=['one'] + ) + # EXCEPT one two AS ${var} + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.EXCEPT), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'one'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'two'), + Token(Token.SEPARATOR, ' '), + Token(Token.AS, 'AS'), + Token(Token.SEPARATOR, ' '), + Token(Token.VARIABLE, '${var}'), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + ExceptHeader, + patterns=['one', 'two'], + variable='${var}' + ) + # EXCEPT Example: * type=glob + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.EXCEPT), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'Example: *'), + Token(Token.SEPARATOR, ' '), + Token(Token.OPTION, 'type=glob'), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + ExceptHeader, + patterns=['Example: *'], + type='glob' + ) + # EXCEPT Error \\d (x|y) type=regexp AS ${var} + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.EXCEPT), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'Error \\d'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, '(x|y)'), + Token(Token.SEPARATOR, ' '), + Token(Token.OPTION, 'type=regexp'), + Token(Token.SEPARATOR, ' '), + Token(Token.AS, 'AS'), + Token(Token.SEPARATOR, ' '), + Token(Token.VARIABLE, '${var}'), + Token(Token.EOL, '\n')] + assert_created_statement( + tokens, + ExceptHeader, + patterns=['Error \\d', '(x|y)'], + type='regexp', + variable='${var}' + ) + + def test_FinallyHeader(self): + # FINALLY + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.FINALLY), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + FinallyHeader + ) + + def test_WhileHeader(self): + # WHILE $cond + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.WHILE), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, '$cond'), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + WhileHeader, + condition='$cond' + ) + # WHILE $cond limit=100s + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.WHILE), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, '$cond'), + Token(Token.SEPARATOR, ' '), + Token(Token.OPTION, 'limit=100s'), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + WhileHeader, + condition='$cond', + limit='100s' + ) + def test_End(self): tokens = [ Token(Token.SEPARATOR, ' '), @@ -743,7 +844,7 @@ def test_End(self): End ) - def test_Return(self): + def test_ReturnStatement(self): tokens = [ Token(Token.SEPARATOR, ' '), Token(Token.RETURN_STATEMENT), From 9edd5bdf1286acba707a17b6de9579d7aec9a1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 7 Jun 2022 22:05:06 +0300 Subject: [PATCH 0026/1592] Parsing model: Fix Variable.from_params with multiple values. Fixes #4359. --- src/robot/parsing/model/statements.py | 18 ++++++++++-------- utest/parsing/test_statements.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 2aad3925a2e..41c67dca5bb 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -17,7 +17,7 @@ import re from robot.running.arguments import UserKeywordArgumentParser -from robot.utils import normalize_whitespace, seq2str, split_from_equals +from robot.utils import is_list_like, normalize_whitespace, seq2str, split_from_equals from robot.variables import is_scalar_assign, is_dict_variable, search_variable from ..lexer import Token @@ -83,7 +83,7 @@ def from_params(cls, *args, **kwargs): settings header or test/keyword. Most implementations support following general properties: - - `separator` whitespace inserted between each token. Default is four spaces. + - ``separator`` whitespace inserted between each token. Default is four spaces. - ``indent`` whitespace inserted before first token. Default is four spaces. - ``eol`` end of line sign. Default is ``'\\n'``. """ @@ -521,12 +521,14 @@ class Variable(Statement): @classmethod def from_params(cls, name, value, separator=FOUR_SPACES, eol=EOL): - return cls([ - Token(Token.VARIABLE, name), - Token(Token.SEPARATOR, separator), - Token(Token.ARGUMENT, value), - Token(Token.EOL, eol) - ]) + """``value`` can be given either as a string or as a list of strings.""" + values = value if is_list_like(value) else [value] + tokens = [Token(Token.VARIABLE, name)] + for value in values: + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value)]) + tokens.append(Token(Token.EOL, eol)) + return cls(tokens) @property def name(self): diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index b830a2de2aa..602f25a08bf 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -197,6 +197,26 @@ def test_Variable(self): name='${variable_name}', value="{'a': 4, 'b': 'abc'}" ) + # ${var} first second third + # @{var} first second third + # &{var} first second third + for name in ['${var}', '@{var}', '&{var}']: + tokens = [ + Token(Token.VARIABLE, name), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'first'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'second'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'third'), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + Variable, + name=name, + value=['first', 'second', 'third'] + ) def test_TestCaseName(self): tokens = [Token(Token.TESTCASE_NAME, 'Example test case name'), Token(Token.EOL, '\n')] From 4eb30c883cb1aae18ca2ecb1b7a7b1346874de56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 7 Jun 2022 22:31:25 +0300 Subject: [PATCH 0027/1592] Cleanup - Consistent coding style. - `extend` instead of `append` when possible. - Don't reuse tokens. That causes problems if they are modified. --- src/robot/parsing/model/statements.py | 251 +++++++++++--------------- utest/parsing/test_statements.py | 5 + 2 files changed, 115 insertions(+), 141 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 41c67dca5bb..1978357d319 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -242,16 +242,17 @@ class LibraryImport(Statement): @classmethod def from_params(cls, name, args=(), alias=None, separator=FOUR_SPACES, eol=EOL): - sep = Token(Token.SEPARATOR, separator) - tokens = [Token(Token.LIBRARY, 'Library'), sep, Token(Token.NAME, name)] + tokens = [Token(Token.LIBRARY, 'Library'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] for arg in args: - tokens.append(sep) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) if alias is not None: - tokens.append(sep) - tokens.append(Token(Token.WITH_NAME)) - tokens.append(sep) - tokens.append(Token(Token.NAME, alias)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.WITH_NAME), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, alias)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -293,15 +294,12 @@ class VariablesImport(Statement): @classmethod def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): - sep = Token(Token.SEPARATOR, separator) - tokens = [ - Token(Token.VARIABLES, 'Variables'), - sep, - Token(Token.NAME, name) - ] + tokens = [Token(Token.VARIABLES, 'Variables'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] for arg in args: - tokens.append(sep) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -322,28 +320,24 @@ class Documentation(DocumentationOrMetadata): def from_params(cls, value, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL, settings_section=True): if settings_section: - tokens = [ - Token(Token.DOCUMENTATION, 'Documentation'), - Token(Token.SEPARATOR, separator) - ] + tokens = [Token(Token.DOCUMENTATION, 'Documentation'), + Token(Token.SEPARATOR, separator)] else: - tokens = [ - Token(Token.SEPARATOR, indent), - Token(Token.DOCUMENTATION, '[Documentation]'), - Token(Token.SEPARATOR, separator) - ] + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.DOCUMENTATION, '[Documentation]'), + Token(Token.SEPARATOR, separator)] multiline_separator = ' ' * (len(tokens[-2].value) + len(separator) - 3) doc_lines = value.splitlines() if doc_lines: - tokens.append(Token(Token.ARGUMENT, doc_lines[0])) - tokens.append(Token(Token.EOL, eol)) + tokens.extend([Token(Token.ARGUMENT, doc_lines[0]), + Token(Token.EOL, eol)]) for line in doc_lines[1:]: if not settings_section: tokens.append(Token(Token.SEPARATOR, indent)) tokens.append(Token(Token.CONTINUATION)) if line: - tokens.append(Token(Token.SEPARATOR, multiline_separator)) - tokens.append(Token(Token.ARGUMENT, line)) + tokens.extend([Token(Token.SEPARATOR, multiline_separator), + Token(Token.ARGUMENT, line)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -359,22 +353,19 @@ class Metadata(DocumentationOrMetadata): @classmethod def from_params(cls, name, value, separator=FOUR_SPACES, eol=EOL): - sep = Token(Token.SEPARATOR, separator) - tokens = [ - Token(Token.METADATA, 'Metadata'), - sep, - Token(Token.NAME, name) - ] + tokens = [Token(Token.METADATA, 'Metadata'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] metadata_lines = value.splitlines() if metadata_lines: - tokens.append(sep) - tokens.append(Token(Token.ARGUMENT, metadata_lines[0])) - tokens.append(Token(Token.EOL, eol)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, metadata_lines[0]), + Token(Token.EOL, eol)]) for line in metadata_lines[1:]: - tokens.append(Token(Token.CONTINUATION)) - tokens.append(sep) - tokens.append(Token(Token.ARGUMENT, line)) - tokens.append(Token(Token.EOL, eol)) + tokens.extend([Token(Token.CONTINUATION), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, line), + Token(Token.EOL, eol)]) return cls(tokens) @property @@ -395,8 +386,8 @@ class ForceTags(MultiValue): def from_params(cls, values, separator=FOUR_SPACES, eol=EOL): tokens = [Token(Token.FORCE_TAGS, 'Force Tags')] for tag in values: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, tag)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, tag)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -409,8 +400,8 @@ class DefaultTags(MultiValue): def from_params(cls, values, separator=FOUR_SPACES, eol=EOL): tokens = [Token(Token.DEFAULT_TAGS, 'Default Tags')] for tag in values: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, tag)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, tag)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -421,14 +412,12 @@ class SuiteSetup(Fixture): @classmethod def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.SUITE_SETUP, 'Suite Setup'), - Token(Token.SEPARATOR, separator), - Token(Token.NAME, name) - ] + tokens = [Token(Token.SUITE_SETUP, 'Suite Setup'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] for arg in args: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -439,14 +428,12 @@ class SuiteTeardown(Fixture): @classmethod def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.SUITE_TEARDOWN, 'Suite Teardown'), - Token(Token.SEPARATOR, separator), - Token(Token.NAME, name) - ] + tokens = [Token(Token.SUITE_TEARDOWN, 'Suite Teardown'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] for arg in args: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -457,14 +444,12 @@ class TestSetup(Fixture): @classmethod def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.TEST_SETUP, 'Test Setup'), - Token(Token.SEPARATOR, separator), - Token(Token.NAME, name) - ] + tokens = [Token(Token.TEST_SETUP, 'Test Setup'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] for arg in args: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -475,14 +460,12 @@ class TestTeardown(Fixture): @classmethod def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.TEST_TEARDOWN, 'Test Teardown'), - Token(Token.SEPARATOR, separator), - Token(Token.NAME, name) - ] + tokens = [Token(Token.TEST_TEARDOWN, 'Test Teardown'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] for arg in args: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -600,17 +583,15 @@ class Setup(Fixture): type = Token.SETUP @classmethod - def from_params(cls, name, args=(), indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): - sep = Token(Token.SEPARATOR, separator) - tokens = [ - Token(Token.SEPARATOR, indent), - Token(Token.SETUP, '[Setup]'), - sep, - Token(Token.NAME, name) - ] + def from_params(cls, name, args=(), indent=FOUR_SPACES, separator=FOUR_SPACES, + eol=EOL): + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.SETUP, '[Setup]'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] for arg in args: - tokens.append(sep) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -620,17 +601,15 @@ class Teardown(Fixture): type = Token.TEARDOWN @classmethod - def from_params(cls, name, args=(), indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): - sep = Token(Token.SEPARATOR, separator) - tokens = [ - Token(Token.SEPARATOR, indent), - Token(Token.TEARDOWN, '[Teardown]'), - sep, - Token(Token.NAME, name) - ] + def from_params(cls, name, args=(), indent=FOUR_SPACES, separator=FOUR_SPACES, + eol=EOL): + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.TEARDOWN, '[Teardown]'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name)] for arg in args: - tokens.append(sep) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -641,13 +620,11 @@ class Tags(MultiValue): @classmethod def from_params(cls, values, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.SEPARATOR, indent), - Token(Token.TAGS, '[Tags]') - ] + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.TAGS, '[Tags]')] for tag in values: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, tag)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, tag)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -688,13 +665,11 @@ class Arguments(MultiValue): @classmethod def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.SEPARATOR, indent), - Token(Token.ARGUMENTS, '[Arguments]'), - ] + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.ARGUMENTS, '[Arguments]')] for arg in args: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -710,13 +685,11 @@ class Return(MultiValue): @classmethod def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.SEPARATOR, indent), - Token(Token.RETURN, '[Return]'), - ] + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.RETURN, '[Return]')] for arg in args: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -726,15 +699,16 @@ class KeywordCall(Statement): type = Token.KEYWORD @classmethod - def from_params(cls, name, assign=(), args=(), indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name, assign=(), args=(), indent=FOUR_SPACES, + separator=FOUR_SPACES, eol=EOL): tokens = [Token(Token.SEPARATOR, indent)] for assignment in assign: - tokens.append(Token(Token.ASSIGN, assignment)) - tokens.append(Token(Token.SEPARATOR, separator)) + tokens.extend([Token(Token.ASSIGN, assignment), + Token(Token.SEPARATOR, separator)]) tokens.append(Token(Token.KEYWORD, name)) for arg in args: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -759,8 +733,8 @@ class TemplateArguments(Statement): def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): tokens = [] for index, arg in enumerate(args): - tokens.append(Token(Token.SEPARATOR, separator if index else indent)) - tokens.append(Token(Token.ARGUMENT, arg)) + tokens.extend([Token(Token.SEPARATOR, separator if index else indent), + Token(Token.ARGUMENT, arg)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -774,19 +748,18 @@ class ForHeader(Statement): type = Token.FOR @classmethod - def from_params(cls, variables, values, flavor='IN', indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.SEPARATOR, indent), - Token(Token.FOR), - Token(Token.SEPARATOR, separator) - ] + def from_params(cls, variables, values, flavor='IN', indent=FOUR_SPACES, + separator=FOUR_SPACES, eol=EOL): + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.FOR), + Token(Token.SEPARATOR, separator)] for variable in variables: - tokens.append(Token(Token.VARIABLE, variable)) - tokens.append(Token(Token.SEPARATOR, separator)) + tokens.extend([Token(Token.VARIABLE, variable), + Token(Token.SEPARATOR, separator)]) tokens.append(Token(Token.FOR_SEPARATOR, flavor)) for value in values: - tokens.append(Token(Token.SEPARATOR, separator)) - tokens.append(Token(Token.ARGUMENT, value)) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -923,23 +896,19 @@ class ExceptHeader(Statement): @classmethod def from_params(cls, patterns=(), type=None, variable=None, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): - tokens = [ - Token(Token.SEPARATOR, indent), - Token(Token.EXCEPT), - Token(Token.SEPARATOR, separator) - ] + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.EXCEPT)] for pattern in patterns: - tokens.extend([Token(Token.ARGUMENT, pattern), - Token(Token.SEPARATOR, separator)]) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, pattern)]), if type: - tokens.extend([Token(Token.OPTION, f'type={type}'), - Token(Token.SEPARATOR, separator)]) + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f'type={type}')]) if variable: - tokens.extend([Token(Token.AS), + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.AS), Token(Token.SEPARATOR, separator), Token(Token.VARIABLE, variable)]) - if tokens[-1].type == Token.SEPARATOR: - tokens.pop() tokens.append(Token(Token.EOL, eol)) return cls(tokens) diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 602f25a08bf..2ce101887b8 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -20,6 +20,11 @@ def assert_created_statement(tokens, base_class, **params): new_statement, Statement.from_tokens(tokens) ) + if len(set(id(t) for t in new_statement.tokens)) != len(tokens): + lines = '\n'.join(f'{i:18}{t}' for i, t in + [('ID', 'TOKEN')] + + [(str(id(t)), repr(t)) for t in new_statement.tokens]) + raise AssertionError(f'Tokens should not be reused!\n\n{lines}') def compare_statements(first, second): From 898065d69dda3c40ad186d0ae00e9145eacf6951 Mon Sep 17 00:00:00 2001 From: yanne Date: Fri, 3 Jun 2022 21:07:21 +0300 Subject: [PATCH 0028/1592] Initial implementation for localization support in parsing Related issue #4096 --- src/robot/conf/__init__.py | 1 + src/robot/conf/languages.py | 120 +++++++++++++++++++++ src/robot/conf/settings.py | 7 +- src/robot/parsing/lexer/blocklexers.py | 17 ++- src/robot/parsing/lexer/context.py | 25 +++-- src/robot/parsing/lexer/lexer.py | 12 +-- src/robot/parsing/lexer/markers.py | 59 ++++++++++ src/robot/parsing/lexer/sections.py | 32 +++--- src/robot/parsing/lexer/settings.py | 38 ++++--- src/robot/parsing/lexer/statementlexers.py | 4 + src/robot/parsing/lexer/tokens.py | 2 + src/robot/parsing/model/blocks.py | 3 +- src/robot/parsing/model/statements.py | 7 +- src/robot/parsing/parser/fileparser.py | 1 + src/robot/parsing/parser/parser.py | 18 ++-- src/robot/run.py | 2 + src/robot/running/builder/builders.py | 23 ++-- src/robot/running/builder/parsers.py | 7 +- src/robot/running/importer.py | 4 +- src/robot/running/namespace.py | 5 +- src/robot/running/suiterunner.py | 2 +- utest/parsing/test_statements.py | 1 + 22 files changed, 309 insertions(+), 81 deletions(-) create mode 100644 src/robot/conf/languages.py create mode 100644 src/robot/parsing/lexer/markers.py diff --git a/src/robot/conf/__init__.py b/src/robot/conf/__init__.py index d0c7411dd76..fa6bde20530 100644 --- a/src/robot/conf/__init__.py +++ b/src/robot/conf/__init__.py @@ -24,4 +24,5 @@ Instantiating them is not likely to change, though. """ +from .languages import Language from .settings import RobotSettings, RebotSettings diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py new file mode 100644 index 00000000000..426124f1d6d --- /dev/null +++ b/src/robot/conf/languages.py @@ -0,0 +1,120 @@ +# 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 is_string + + +class Language: + setting_headers = {} + variable_headers = {} + test_case_headers = {} + task_headers = {} + keyword_headers = {} + comment_headers = {} + library = None + resource = None + variables = None + documentation = None + metadata = None + suite_setup = None + suite_teardown = None + test_setup = None + test_teardown = None + test_template = None + test_timeout = None + force_tags = None + default_tags = None + tags = None + setup = None + teardown = None + template = None + timeout = None + arguments = None + return_ = None + bdd_prefixes = set() # These are not used yet + + @classmethod + def get_languages(cls, languages): + languages = cls._resolve_languages(languages) + available = {c.__name__.lower(): c() for c in cls.__subclasses__()} + # FIXME: support for external lang files + return [available[name.lower()] for name in languages if name.lower() in available] + + @classmethod + def _resolve_languages(cls, languages): + if not languages: + languages = [] + if is_string(languages): + languages = [languages] + if 'en' not in languages: + languages.append('en') + return languages + + @property + def settings(self): + settings = { + self.library: En.library, + self.resource: En.resource, + self.variables: En.variables, + self.documentation: En.documentation, + self.metadata: En.metadata, + self.suite_setup: En.suite_setup, + self.suite_teardown: En.suite_teardown, + self.test_setup: En.test_setup, + self.test_teardown: En.test_teardown, + self.test_template: En.test_template, + self.test_timeout: En.test_timeout, + self.force_tags: En.force_tags, + self.default_tags: En.default_tags, + self.tags: En.tags, + self.setup: En.setup, + self.teardown: En.teardown, + self.template: En.template, + self.timeout: En.timeout, + self.arguments: En.arguments, + self.return_: En.return_ + } + settings.pop(None, None) + return settings + + +class En(Language): + setting_headers = {'Settings', 'Setting'} + variable_headers = {'Variables', 'Variable'} + test_case_headers = {'Test Cases', 'Test Case'} + task_headers = {'Tasks', 'Task'} + keyword_headers = {'Keywords', 'Keyword'} + comment_headers = {'Comments', 'Comment'} + library = 'Library' + resource = 'Resource' + variables = 'Variables' + documentation = 'Documentation' + metadata = 'Metadata' + suite_setup = 'Suite Setup' + suite_teardown = 'Suite Teardown' + test_setup = 'Test Setup' + test_teardown = 'Test Teardown' + test_template = 'Test Template' + test_timeout = 'Test Timeout' + force_tags = 'Force Tags' + default_tags = 'Default Tags' + tags = 'Tags' + setup = 'Setup' + teardown = 'Teardown' + template = 'Template' + timeout = 'Timeout' + arguments = 'Arguments' + return_ = 'Return' + bdd_prefixes = {'Given', 'When', 'Then', 'And', 'But'} diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index dd8b2027151..607b45edac8 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -473,7 +473,8 @@ class RobotSettings(_BaseSettings): 'ConsoleTypeQuiet' : ('quiet', False), 'ConsoleWidth' : ('consolewidth', 78), 'ConsoleMarkers' : ('consolemarkers', 'AUTO'), - 'DebugFile' : ('debugfile', None)} + 'DebugFile' : ('debugfile', None), + 'Language' : ('language', [])} def get_rebot_settings(self): settings = RebotSettings() @@ -500,6 +501,10 @@ def listeners(self): def debug_file(self): return self['DebugFile'] + @property + def languages(self): + return self['Language'] + @property def suite_config(self): return { diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index ffbf68e4a2e..ead837e0fd3 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -20,6 +20,7 @@ SettingSectionHeaderLexer, SettingLexer, VariableSectionHeaderLexer, VariableLexer, TestCaseSectionHeaderLexer, + TaskSectionHeaderLexer, KeywordSectionHeaderLexer, CommentSectionHeaderLexer, CommentLexer, ErrorSectionHeaderLexer, @@ -82,9 +83,9 @@ def lex(self): def lexer_classes(self): return (SettingSectionLexer, VariableSectionLexer, - TestCaseSectionLexer, KeywordSectionLexer, - CommentSectionLexer, ErrorSectionLexer, - ImplicitCommentSectionLexer) + TestCaseSectionLexer, TaskSectionLexer, + KeywordSectionLexer, CommentSectionLexer, + ErrorSectionLexer, ImplicitCommentSectionLexer) class SectionLexer(BlockLexer): @@ -123,6 +124,16 @@ def lexer_classes(self): return (TestCaseSectionHeaderLexer, TestCaseLexer) +class TaskSectionLexer(SectionLexer): + + @classmethod + def handles(cls, statement, ctx): + return ctx.task_section(statement) + + def lexer_classes(self): + return (TaskSectionHeaderLexer, TestCaseLexer) + + class KeywordSectionLexer(SettingSectionLexer): @classmethod diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index fc37a8c7c91..44da7636689 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .markers import Markers from .sections import (InitFileSections, TestCaseFileSections, ResourceFileSections) from .settings import (InitFileSettings, TestCaseFileSettings, @@ -22,8 +23,13 @@ class LexingContext: settings_class = None - def __init__(self, settings=None): - self.settings = settings or self.settings_class() + def __init__(self, settings=None, lang=None): + if not settings: + self.markers = Markers(lang) + self.settings = self.settings_class(self.markers) + else: + self.markers = settings.markers + self.settings = settings def lex_setting(self, statement): self.settings.lex(statement) @@ -32,9 +38,10 @@ def lex_setting(self, statement): class FileContext(LexingContext): sections_class = None - def __init__(self, settings=None): - LexingContext.__init__(self, settings) - self.sections = self.sections_class() + def __init__(self, settings=None, lang=None): + super().__init__(settings, lang) + # TODO: should .sections be removed as unnecessary indirection? + self.sections = self.sections_class(self.markers) def setting_section(self, statement): return self.sections.setting(statement) @@ -45,6 +52,9 @@ def variable_section(self, statement): def test_case_section(self, statement): return self.sections.test_case(statement) + def task_section(self, statement): + return self.sections.task(statement) + def keyword_section(self, statement): return self.sections.keyword(statement) @@ -52,7 +62,7 @@ def comment_section(self, statement): return self.sections.comment(statement) def keyword_context(self): - return KeywordContext(settings=KeywordSettings()) + return KeywordContext(settings=KeywordSettings(self.markers)) def lex_invalid_section(self, statement): self.sections.lex_invalid(statement) @@ -63,7 +73,8 @@ class TestCaseFileContext(FileContext): settings_class = TestCaseFileSettings def test_case_context(self): - return TestCaseContext(settings=TestCaseSettings(self.settings)) + return TestCaseContext(settings=TestCaseSettings(self.settings, + self.markers)) class ResourceFileContext(FileContext): diff --git a/src/robot/parsing/lexer/lexer.py b/src/robot/parsing/lexer/lexer.py index 5f3717cf3b3..785dd51bcda 100644 --- a/src/robot/parsing/lexer/lexer.py +++ b/src/robot/parsing/lexer/lexer.py @@ -24,7 +24,7 @@ from .tokens import EOS, END, Token -def get_tokens(source, data_only=False, tokenize_variables=False): +def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): """Parses the given source to tokens. :param source: The source where to read the data. Can be a path to @@ -42,30 +42,30 @@ def get_tokens(source, data_only=False, tokenize_variables=False): Returns a generator that yields :class:`~robot.parsing.lexer.tokens.Token` instances. """ - lexer = Lexer(TestCaseFileContext(), data_only, tokenize_variables) + lexer = Lexer(TestCaseFileContext(lang=lang), data_only, tokenize_variables) lexer.input(source) return lexer.get_tokens() -def get_resource_tokens(source, data_only=False, tokenize_variables=False): +def get_resource_tokens(source, data_only=False, tokenize_variables=False, lang=None): """Parses the given source to resource file tokens. Otherwise same as :func:`get_tokens` but the source is considered to be a resource file. This affects, for example, what settings are valid. """ - lexer = Lexer(ResourceFileContext(), data_only, tokenize_variables) + lexer = Lexer(ResourceFileContext(lang=lang), data_only, tokenize_variables) lexer.input(source) return lexer.get_tokens() -def get_init_tokens(source, data_only=False, tokenize_variables=False): +def get_init_tokens(source, data_only=False, tokenize_variables=False, lang=None): """Parses the given source to init file tokens. Otherwise same as :func:`get_tokens` but the source is considered to be a suite initialization file. This affects, for example, what settings are valid. """ - lexer = Lexer(InitFileContext(), data_only, tokenize_variables) + lexer = Lexer(InitFileContext(lang=lang), data_only, tokenize_variables) lexer.input(source) return lexer.get_tokens() diff --git a/src/robot/parsing/lexer/markers.py b/src/robot/parsing/lexer/markers.py new file mode 100644 index 00000000000..29b60b0a1f2 --- /dev/null +++ b/src/robot/parsing/lexer/markers.py @@ -0,0 +1,59 @@ +# 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.conf import Language + + +class Markers: + + def __init__(self, languages): + self.setting_headers = set() + self.variable_headers = set() + self.test_case_headers = set() + self.task_headers = set() + self.keyword_headers = set() + self.comment_headers = set() + self.settings = {} + for lang in Language.get_languages(languages): + self.setting_headers |= lang.setting_headers + self.variable_headers |= lang.variable_headers + self.test_case_headers |= lang.test_case_headers + self.task_headers |= lang.task_headers + self.keyword_headers |= lang.keyword_headers + self.comment_headers |= lang.comment_headers + self.settings.update(lang.settings) + + def setting_section(self, marker): + return marker in self.setting_headers + + def variable_section(self, marker): + return marker in self.variable_headers + + def test_case_section(self, marker): + return marker in self.test_case_headers + + def task_section(self, marker): + return marker in self.task_headers + + def keyword_section(self, marker): + return marker in self.keyword_headers + + def comment_section(self, marker): + return marker in self.comment_headers + + def translate(self, value): + if value in self.settings: + return self.settings[value] + return value diff --git a/src/robot/parsing/lexer/sections.py b/src/robot/parsing/lexer/sections.py index 6b946f04eec..da8fe5fdda4 100644 --- a/src/robot/parsing/lexer/sections.py +++ b/src/robot/parsing/lexer/sections.py @@ -19,30 +19,31 @@ class Sections: - setting_markers = ('Settings', 'Setting') - variable_markers = ('Variables', 'Variable') - test_case_markers = ('Test Cases', 'Test Case', 'Tasks', 'Task') - keyword_markers = ('Keywords', 'Keyword') - comment_markers = ('Comments', 'Comment') + + def __init__(self, markers): + self.markers = markers def setting(self, statement): - return self._handles(statement, self.setting_markers) + return self._handles(statement, self.markers.setting_section) def variable(self, statement): - return self._handles(statement, self.variable_markers) + return self._handles(statement, self.markers.variable_section) def test_case(self, statement): return False + def task(self, statement): + return False + def keyword(self, statement): - return self._handles(statement, self.keyword_markers) + return self._handles(statement, self.markers.keyword_section) def comment(self, statement): - return self._handles(statement, self.comment_markers) + return self._handles(statement, self.markers.comment_section) - def _handles(self, statement, markers): + def _handles(self, statement, validator): marker = statement[0].value - return marker.startswith('*') and self._normalize(marker) in markers + return marker.startswith('*') and validator(self._normalize(marker)) def _normalize(self, marker): return normalize_whitespace(marker).strip('* ').title() @@ -60,7 +61,10 @@ def _get_invalid_section_error(self, header): class TestCaseFileSections(Sections): def test_case(self, statement): - return self._handles(statement, self.test_case_markers) + return self._handles(statement, self.markers.test_case_section) + + def task(self, statement): + return self._handles(statement, self.markers.task_section) def _get_invalid_section_error(self, header): return ("Unrecognized section header '%s'. Valid sections: " @@ -72,7 +76,7 @@ class ResourceFileSections(Sections): def _get_invalid_section_error(self, header): name = self._normalize(header) - if name in self.test_case_markers: + if self.markers.test_case_section(name) or self.markers.task_section(name): message = "Resource file with '%s' section is invalid." % name fatal = True else: @@ -87,7 +91,7 @@ class InitFileSections(Sections): def _get_invalid_section_error(self, header): name = self._normalize(header) - if name in self.test_case_markers: + if self.markers.test_case_section(name) or self.markers.task_section(name): message = ("'%s' section is not allowed in suite initialization " "file." % name) else: diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 4008a535b88..9780f7d3007 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -51,39 +51,37 @@ class Settings: 'Library', ) - def __init__(self): + def __init__(self, markers): self.settings = {n: None for n in self.names} + self.markers = markers def lex(self, statement): setting = statement[0] - name = self._format_name(setting.value) - normalized = self._normalize_name(name) + orig = self._format_name(setting.value) + name = normalize_whitespace(orig).title() + name = self.markers.translate(name) + if name in self.aliases: + name = self.aliases[name] try: - self._validate(name, normalized, statement) + self._validate(orig, name, statement) except ValueError as err: self._lex_error(setting, statement[1:], err.args[0]) else: - self._lex_setting(setting, statement[1:], normalized) + self._lex_setting(setting, statement[1:], name) def _format_name(self, name): return name - def _normalize_name(self, name): - name = normalize_whitespace(name).title() - if name in self.aliases: - return self.aliases[name] - return name - - def _validate(self, name, normalized, statement): - if normalized not in self.settings: - message = self._get_non_existing_setting_message(name, normalized) + def _validate(self, orig, name, statement): + if name not in self.settings: + message = self._get_non_existing_setting_message(orig, name) raise ValueError(message) - if self.settings[normalized] is not None and normalized not in self.multi_use: + if self.settings[name] is not None and name not in self.multi_use: raise ValueError("Setting '%s' is allowed only once. " - "Only the first value is used." % name) - if normalized in self.single_value and len(statement) > 2: + "Only the first value is used." % orig) + if name in self.single_value and len(statement) > 2: raise ValueError("Setting '%s' accepts only one value, got %s." - % (name, len(statement) - 1)) + % (orig, len(statement) - 1)) def _get_non_existing_setting_message(self, name, normalized): if normalized in TestCaseFileSettings.names: @@ -188,8 +186,8 @@ class TestCaseSettings(Settings): 'Timeout' ) - def __init__(self, parent): - Settings.__init__(self) + def __init__(self, parent, markers): + super().__init__(markers) self.parent = parent def _format_name(self, name): diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index e1fa3969b3f..f4b210ccb9e 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -90,6 +90,10 @@ class TestCaseSectionHeaderLexer(SectionHeaderLexer): token_type = Token.TESTCASE_HEADER +class TaskSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.TASK_HEADER + + class KeywordSectionHeaderLexer(SectionHeaderLexer): token_type = Token.KEYWORD_HEADER diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 256ea2f849b..a499cf710f5 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -41,6 +41,7 @@ class Token: SETTING_HEADER = 'SETTING HEADER' VARIABLE_HEADER = 'VARIABLE HEADER' TESTCASE_HEADER = 'TESTCASE HEADER' + TASK_HEADER = 'TASK HEADER' KEYWORD_HEADER = 'KEYWORD HEADER' COMMENT_HEADER = 'COMMENT HEADER' @@ -136,6 +137,7 @@ class Token: SETTING_HEADER, VARIABLE_HEADER, TESTCASE_HEADER, + TASK_HEADER, KEYWORD_HEADER, COMMENT_HEADER )) diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index cd307ed714c..80d1508f00d 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -107,11 +107,12 @@ class VariableSection(Section): pass +# FIXME: should there be a separate TaskSection? class TestCaseSection(Section): @property def tasks(self): - return self.header.name.upper() in ('TASKS', 'TASK') + return self.header.type == Token.TASK_HEADER class KeywordSection(Section): diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 1978357d319..6d6c77095e2 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -210,13 +210,14 @@ def args(self): @Statement.register class SectionHeader(Statement): handles_types = (Token.SETTING_HEADER, Token.VARIABLE_HEADER, - Token.TESTCASE_HEADER, Token.KEYWORD_HEADER, - Token.COMMENT_HEADER) + Token.TESTCASE_HEADER, Token.TASK_HEADER, + Token.KEYWORD_HEADER, Token.COMMENT_HEADER) @classmethod def from_params(cls, type, name=None, eol=EOL): if not name: - names = ('Settings', 'Variables', 'Test Cases', 'Keywords', 'Comments') + names = ('Settings', 'Variables', 'Test Cases', 'Tasks', + 'Keywords', 'Comments') name = dict(zip(cls.handles_types, names))[type] if not name.startswith('*'): name = '*** %s ***' % name diff --git a/src/robot/parsing/parser/fileparser.py b/src/robot/parsing/parser/fileparser.py index 7ca2241c5cd..75ce3bc3ff9 100644 --- a/src/robot/parsing/parser/fileparser.py +++ b/src/robot/parsing/parser/fileparser.py @@ -46,6 +46,7 @@ def parse(self, statement): Token.SETTING_HEADER: SettingSectionParser, Token.VARIABLE_HEADER: VariableSectionParser, Token.TESTCASE_HEADER: TestCaseSectionParser, + Token.TASK_HEADER: TestCaseSectionParser, Token.KEYWORD_HEADER: KeywordSectionParser, Token.COMMENT_HEADER: CommentSectionParser, Token.COMMENT: ImplicitCommentSectionParser, diff --git a/src/robot/parsing/parser/parser.py b/src/robot/parsing/parser/parser.py index 3d82652d7d5..9bac6461ce5 100644 --- a/src/robot/parsing/parser/parser.py +++ b/src/robot/parsing/parser/parser.py @@ -19,7 +19,7 @@ from .fileparser import FileParser -def get_model(source, data_only=False, curdir=None): +def get_model(source, data_only=False, curdir=None, lang=None): """Parses the given source to a model represented as an AST. How to use the model is explained more thoroughly in the general @@ -38,34 +38,36 @@ def get_model(source, data_only=False, curdir=None): When not given, the variable is left as-is. Should only be given only if the model will be executed afterwards. If the model is saved back to disk, resolving ``${CURDIR}`` is typically not a good idea. + # FIXME: docs + :param lang: Additional languages to be supported during parsing Use :func:`get_resource_model` or :func:`get_init_model` when parsing resource or suite initialization files, respectively. """ - return _get_model(get_tokens, source, data_only, curdir) + return _get_model(get_tokens, source, data_only, curdir, lang) -def get_resource_model(source, data_only=False, curdir=None): +def get_resource_model(source, data_only=False, curdir=None, lang=None): """Parses the given source to a resource file model. Otherwise same as :func:`get_model` but the source is considered to be a resource file. This affects, for example, what settings are valid. """ - return _get_model(get_resource_tokens, source, data_only, curdir) + return _get_model(get_resource_tokens, source, data_only, curdir, lang) -def get_init_model(source, data_only=False, curdir=None): +def get_init_model(source, data_only=False, curdir=None, lang=None): """Parses the given source to a init file model. Otherwise same as :func:`get_model` but the source is considered to be a suite initialization file. This affects, for example, what settings are valid. """ - return _get_model(get_init_tokens, source, data_only, curdir) + return _get_model(get_init_tokens, source, data_only, curdir, lang) -def _get_model(token_getter, source, data_only=False, curdir=None): - tokens = token_getter(source, data_only) +def _get_model(token_getter, source, data_only=False, curdir=None, lang=None): + tokens = token_getter(source, data_only, lang=lang) statements = _tokens_to_statements(tokens, curdir) model = _statements_to_model(statements, source) model.validate_model() diff --git a/src/robot/run.py b/src/robot/run.py index 7c3a4811253..e1adee45e03 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -98,6 +98,7 @@ -N --name name Set the name of the top level suite. By default the name is created based on the executed file or directory. + --language lang * TODO -D --doc documentation Set the documentation of the top level suite. Simple formatting is supported (e.g. *bold*). If the documentation contains spaces, it must be quoted. @@ -431,6 +432,7 @@ def main(self, datasources, **options): builder = TestSuiteBuilder(settings.suite_names, included_extensions=settings.extension, rpa=settings.rpa, + lang=settings.languages, allow_empty_suite=settings.run_empty_suite) suite = builder.build(*datasources) settings.rpa = suite.rpa diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 49985130808..16d74fba388 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -47,7 +47,8 @@ class TestSuiteBuilder: """ def __init__(self, included_suites=None, included_extensions=('robot',), - rpa=None, allow_empty_suite=False, process_curdir=True): + rpa=None, lang=None, allow_empty_suite=False, + process_curdir=True): """ :param include_suites: List of suite names to include. If ``None`` or an empty list, all @@ -67,6 +68,7 @@ def __init__(self, included_suites=None, included_extensions=('robot',), changed by giving this argument ``False`` value. """ self.rpa = rpa + self.lang = lang self.included_suites = included_suites self.included_extensions = included_extensions self.allow_empty_suite = allow_empty_suite @@ -80,7 +82,7 @@ def build(self, *paths): structure = SuiteStructureBuilder(self.included_extensions, self.included_suites).build(paths) parser = SuiteStructureParser(self.included_extensions, - self.rpa, self.process_curdir) + self.rpa, self.lang, self.process_curdir) suite = parser.parse(structure) if not self.included_suites and not self.allow_empty_suite: self._validate_test_counts(suite, multisource=len(paths) > 1) @@ -101,16 +103,16 @@ def validate(suite): class SuiteStructureParser(SuiteStructureVisitor): - def __init__(self, included_extensions, rpa=None, process_curdir=True): + def __init__(self, included_extensions, rpa=None, lang=None, process_curdir=True): self.rpa = rpa self._rpa_given = rpa is not None self.suite = None self._stack = [] - self.parsers = self._get_parsers(included_extensions, process_curdir) + self.parsers = self._get_parsers(included_extensions, lang, process_curdir) - def _get_parsers(self, extensions, process_curdir): - robot_parser = RobotParser(process_curdir) - rest_parser = RestParser(process_curdir) + def _get_parsers(self, extensions, lang, process_curdir): + robot_parser = RobotParser(lang, process_curdir) + rest_parser = RestParser(lang, process_curdir) parsers = { None: NoInitFileDirectoryParser(), 'robot': robot_parser, @@ -190,7 +192,8 @@ def _validate_execution_mode(self, suite): class ResourceFileBuilder: - def __init__(self, process_curdir=True): + def __init__(self, lang=None, process_curdir=True): + self.lang = lang self.process_curdir = process_curdir def build(self, source): @@ -205,5 +208,5 @@ def build(self, source): def _parse(self, source): if os.path.splitext(source)[1].lower() in ('.rst', '.rest'): - return RestParser(self.process_curdir).parse_resource_file(source) - return RobotParser(self.process_curdir).parse_resource_file(source) + return RestParser(self.lang, self.process_curdir).parse_resource_file(source) + return RobotParser(self.lang, self.process_curdir).parse_resource_file(source) diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index e3407555acc..4ff83bb2eb6 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -40,7 +40,8 @@ def parse_resource_file(self, source): class RobotParser(BaseParser): - def __init__(self, process_curdir=True): + def __init__(self, lang=None, process_curdir=True): + self.lang = lang self.process_curdir = process_curdir def parse_init_file(self, source, defaults=None): @@ -62,7 +63,7 @@ def _build(self, suite, source, defaults, model=None, get_model=get_model): defaults = TestDefaults() if model is None: model = get_model(self._get_source(source), data_only=True, - curdir=self._get_curdir(source)) + curdir=self._get_curdir(source), lang=self.lang) ErrorReporter(source).visit(model) SettingsBuilder(suite, defaults).visit(model) SuiteBuilder(suite, defaults).visit(model) @@ -79,7 +80,7 @@ def _get_source(self, source): def parse_resource_file(self, source): model = get_resource_model(self._get_source(source), data_only=True, - curdir=self._get_curdir(source)) + curdir=self._get_curdir(source), lang=self.lang) resource = ResourceFile(source=source) ErrorReporter(source).visit(model) ResourceBuilder(resource).visit(model) diff --git a/src/robot/running/importer.py b/src/robot/running/importer.py index 0aea7e1dcaf..dc614dc4e72 100644 --- a/src/robot/running/importer.py +++ b/src/robot/running/importer.py @@ -52,12 +52,12 @@ def import_library(self, name, args, alias, variables): LOGGER.info("Imported library '%s' with name '%s'" % (name, alias)) return lib - def import_resource(self, path): + def import_resource(self, path, lang=None): self._validate_resource_extension(path) if path in self._resource_cache: LOGGER.info("Found resource file '%s' from cache" % path) else: - resource = ResourceFileBuilder().build(path) + resource = ResourceFileBuilder(lang=lang).build(path) self._resource_cache[path] = resource return self._resource_cache[path] diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index dfb2785d732..566aac68563 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -38,9 +38,10 @@ class Namespace: _library_import_by_path_ends = ('.py', '/', os.sep) _variables_import_by_path_ends = _library_import_by_path_ends + ('.yaml', '.yml') - def __init__(self, variables, suite, resource): + def __init__(self, variables, suite, resource, languages): LOGGER.info(f"Initializing namespace for suite '{suite.longname}'.") self.variables = variables + self.languages = languages self._imports = resource.imports self._kw_store = KeywordStore(resource) self._imported_variable_files = ImportCache() @@ -81,7 +82,7 @@ def _import_resource(self, import_setting, overwrite=False): path = self._resolve_name(import_setting) self._validate_not_importing_init_file(path) if overwrite or path not in self._kw_store.resources: - resource = IMPORTER.import_resource(path) + resource = IMPORTER.import_resource(path, self.languages) self.variables.set_from_variable_table(resource.variables, overwrite) user_library = UserLibrary(resource) self._kw_store.resources[path] = user_library diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index bc24402bc5c..1e579b0bffc 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -67,7 +67,7 @@ def start_suite(self, suite): self._settings.exit_on_failure, self._settings.exit_on_error, self._settings.skip_teardown_on_exit) - ns = Namespace(self._variables, result, suite.resource) + ns = Namespace(self._variables, result, suite.resource, self._settings.languages) ns.start_suite() ns.variables.set_from_variable_table(suite.resource.variables) EXECUTION_CONTEXTS.start_suite(result, ns, self._output, diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 2ce101887b8..cedc07954a9 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -56,6 +56,7 @@ def test_SectionHeader(self): Token.SETTING_HEADER: 'Settings', Token.VARIABLE_HEADER: 'Variables', Token.TESTCASE_HEADER: 'Test Cases', + Token.TASK_HEADER: 'Tasks', Token.KEYWORD_HEADER: 'Keywords', Token.COMMENT_HEADER: 'Comments' } From 7235e9825eb19af19fa71e7d0f2c2eb6f3e048b9 Mon Sep 17 00:00:00 2001 From: yanne Date: Wed, 8 Jun 2022 18:10:28 +0300 Subject: [PATCH 0029/1592] Suppport external language files Related issue #4096 --- src/robot/conf/languages.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 426124f1d6d..5cf5015c718 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -13,7 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import is_string +import inspect + +from robot.utils import is_string, Importer class Language: @@ -48,9 +50,14 @@ class Language: @classmethod def get_languages(cls, languages): languages = cls._resolve_languages(languages) - available = {c.__name__.lower(): c() for c in cls.__subclasses__()} - # FIXME: support for external lang files - return [available[name.lower()] for name in languages if name.lower() in available] + available = {c.__name__.lower(): c for c in cls.__subclasses__()} + returned = [] + for lang in languages: + if lang.lower() in available: + returned.append(available[lang.lower()]) + else: + returned.extend(cls._import_languages(lang)) + return [subclass() for subclass in returned] @classmethod def _resolve_languages(cls, languages): @@ -62,6 +69,17 @@ def _resolve_languages(cls, languages): languages.append('en') return languages + @classmethod + def _import_languages(cls, lang): + def find_subclass(member): + return (inspect.isclass(member) + and issubclass(member, Language) + and member is not Language) + # FIXME: make sure only module is imported + # FIXME: error handling + module = Importer().import_class_or_module(lang) + return [value for _, value in inspect.getmembers(module, find_subclass)] + @property def settings(self): settings = { From af107044bc609262ba9f5267f21dad62d37e58d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 8 Jun 2022 18:28:24 +0300 Subject: [PATCH 0030/1592] Fixes to initial localization support. #4096 --- src/robot/conf/languages.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 5cf5015c718..9f6bb6e853c 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -14,17 +14,18 @@ # limitations under the License. import inspect +import os.path from robot.utils import is_string, Importer class Language: - setting_headers = {} - variable_headers = {} - test_case_headers = {} - task_headers = {} - keyword_headers = {} - comment_headers = {} + setting_headers = set() + variable_headers = set() + test_case_headers = set() + task_headers = set() + keyword_headers = set() + comment_headers = set() library = None resource = None variables = None @@ -77,6 +78,8 @@ def find_subclass(member): and member is not Language) # FIXME: make sure only module is imported # FIXME: error handling + if os.path.exists(lang): + lang = os.path.abspath(lang) module = Importer().import_class_or_module(lang) return [value for _, value in inspect.getmembers(module, find_subclass)] From bb1dee788e19c85f3f4411a94f0152c72c960be0 Mon Sep 17 00:00:00 2001 From: Oliver Boehmer Date: Thu, 9 Jun 2022 11:46:18 +0200 Subject: [PATCH 0031/1592] Add `robot:(recursive-)stop-on-failure` for disabling continue-on-failure mode (#4313) Implements #4303. --- .../running/continue_on_failure_tag.robot | 85 +++++- .../running/continue_on_failure_tag.robot | 281 +++++++++++++++--- .../src/ExecutingTestCases/TestExecution.rst | 116 +++++++- src/robot/errors.py | 6 +- src/robot/running/context.py | 16 +- 5 files changed, 426 insertions(+), 78 deletions(-) diff --git a/atest/robot/running/continue_on_failure_tag.robot b/atest/robot/running/continue_on_failure_tag.robot index 336ed2d7a1a..318e340c088 100644 --- a/atest/robot/running/continue_on_failure_tag.robot +++ b/atest/robot/running/continue_on_failure_tag.robot @@ -3,25 +3,25 @@ Suite Setup Run Tests ${EMPTY} running/continue_on_failure_tag.robot Resource atest_resource.robot *** Test Cases *** -Continue in test with tag +Continue in test with continue tag Check Test Case ${TESTNAME} Continue in test with Set Tags Check Test Case ${TESTNAME} -Continue in user keyword with tag +Continue in user keyword with continue tag Check Test Case ${TESTNAME} -Continue in test with tag and UK without tag +Continue in test with continue tag and UK without tag Check Test Case ${TESTNAME} -Continue in test with tag and nested UK with and without tag +Continue in test with continue tag and nested UK with and without tag Check Test Case ${TESTNAME} -Continue in test with tag and two nested UK with tag +Continue in test with continue tag and two nested UK with continue tag Check Test Case ${TESTNAME} -Continue in FOR loop with tag +Continue in FOR loop with continue tag Check Test Case ${TESTNAME} Continue in FOR loop with Set Tags @@ -30,13 +30,13 @@ Continue in FOR loop with Set Tags No continue in FOR loop without tag Check Test Case ${TESTNAME} -Continue in FOR loop in UK with tag +Continue in FOR loop in UK with continue tag Check Test Case ${TESTNAME} Continue in FOR loop in UK without tag Check Test Case ${TESTNAME} -Continue in IF with tag +Continue in IF with continue tag Check Test Case ${TESTNAME} Continue in IF with set and remove tag @@ -45,22 +45,28 @@ Continue in IF with set and remove tag No continue in IF without tag Check Test Case ${TESTNAME} -Continue in IF in UK with tag +Continue in IF in UK with continue tag Check Test Case ${TESTNAME} No continue in IF in UK without tag Check Test Case ${TESTNAME} -Continue in Run Keywords with tag +Continue in Run Keywords with continue tag Check Test Case ${TESTNAME} -Recursive continue in test with tag and two nested UK without tag +Recursive continue in test with continue tag and two nested UK without tag Check Test Case ${TESTNAME} Recursive continue in test with Set Tags and two nested UK without tag Check Test Case ${TESTNAME} -Recursive continue in test with tag and two nested UK with and without tag +Recursive continue in test with continue tag and two nested UK with and without tag + Check Test Case ${TESTNAME} + +Recursive continue in test with continue tag and UK with stop tag + Check Test Case ${TESTNAME} + +Recursive continue in test with continue tag and UK with recursive stop tag Check Test Case ${TESTNAME} Recursive continue in user keyword @@ -68,3 +74,58 @@ Recursive continue in user keyword Recursive continue in nested keyword Check Test Case ${TESTNAME} + +stop-on-failure in keyword in Teardown + Check Test Case ${TESTNAME} + +stop-on-failure with continuable failure in keyword in Teardown + Check Test Case ${TESTNAME} + +stop-on-failure with run-kw-and-continue failure in keyword in Teardown + Check Test Case ${TESTNAME} + +stop-on-failure with run-kw-and-continue failure in keyword + Check Test Case ${TESTNAME} + +Test teardown using run keywords with stop tag in test case + Check Test Case ${TESTNAME} + +Test teardown using user keyword with recursive stop tag in test case + Check Test Case ${TESTNAME} + +Test teardown using user keyword with stop tag in test case + Check Test Case ${TESTNAME} + +Test Teardown with stop tag in user keyword + Check Test Case ${TESTNAME} + +Test Teardown with recursive stop tag in user keyword + Check Test Case ${TESTNAME} + +Test Teardown with recursive stop tag and UK with continue tag + Check Test Case ${TESTNAME} + +Test Teardown with recursive stop tag and UK with recursive continue tag + Check Test Case ${TESTNAME} + +stop-on-failure with Template + Check Test Case ${TESTNAME} + +recursive-stop-on-failure with Template + Check Test Case ${TESTNAME} + +stop-on-failure with Template and Teardown + Check Test Case ${TESTNAME} + +stop-on-failure does not stop continuable failure in test + Check Test Case ${TESTNAME} + +Test recursive-continue-recursive-stop + Check Test Case ${TESTNAME} + +Test recursive-stop-recursive-continue + Check Test Case ${TESTNAME} + +Test recursive-stop-recursive-continue-recursive-stop + Check Test Case ${TESTNAME} + diff --git a/atest/testdata/running/continue_on_failure_tag.robot b/atest/testdata/running/continue_on_failure_tag.robot index 18c473f871a..f47fb83ce1b 100644 --- a/atest/testdata/running/continue_on_failure_tag.robot +++ b/atest/testdata/running/continue_on_failure_tag.robot @@ -1,8 +1,12 @@ +*** Settings *** +Library Exceptions + *** Variables *** ${HEADER} Several failures occurred: +${EXC} ContinuableApocalypseException *** Test Cases *** -Continue in test with tag +Continue in test with continue tag [Documentation] FAIL ${HEADER}\n\n ... 1) 1\n\n ... 2) 2 @@ -20,14 +24,14 @@ Continue in test with Set Tags Fail 2 Log This should be executed -Continue in user keyword with tag +Continue in user keyword with continue tag [Documentation] FAIL ${HEADER}\n\n ... 1) kw1a\n\n ... 2) kw1b - Failure in user keyword with tag + Failure in user keyword with continue tag Fail This should not be executed -Continue in test with tag and UK without tag +Continue in test with continue tag and UK without tag [Documentation] FAIL ${HEADER}\n\n ... 1) kw2a\n\n ... 2) This should be executed @@ -35,17 +39,17 @@ Continue in test with tag and UK without tag Failure in user keyword without tag Fail This should be executed -Continue in test with tag and nested UK with and without tag +Continue in test with continue tag and nested UK with and without tag [Documentation] FAIL ${HEADER}\n\n ... 1) kw1a\n\n ... 2) kw1b\n\n ... 3) kw2a\n\n ... 4) This should be executed [Tags] robot: continue-on-failure # spaces should be collapsed - Failure in user keyword with tag run_kw=Failure in user keyword without tag + Failure in user keyword with continue tag run_kw=Failure in user keyword without tag Fail This should be executed -Continue in test with tag and two nested UK with tag +Continue in test with continue tag and two nested UK with continue tag [Documentation] FAIL ${HEADER}\n\n ... 1) kw1a\n\n ... 2) kw1b\n\n @@ -53,10 +57,10 @@ Continue in test with tag and two nested UK with tag ... 4) kw1b\n\n ... 5) This should be executed [Tags] robot:continue-on-failure - Failure in user keyword with tag run_kw=Failure in user keyword with tag + Failure in user keyword with continue tag run_kw=Failure in user keyword with continue tag Fail This should be executed -Continue in FOR loop with tag +Continue in FOR loop with continue tag [Documentation] FAIL ${HEADER}\n\n ... 1) loop-1\n\n ... 2) loop-2\n\n @@ -82,18 +86,18 @@ No continue in FOR loop without tag Fail loop-${val} END -Continue in FOR loop in UK with tag +Continue in FOR loop in UK with continue tag [Documentation] FAIL ${HEADER}\n\n - ... 1) kw-loop-1\n\n - ... 2) kw-loop-2\n\n - ... 3) kw-loop-3 - FOR loop in in user keyword with tag + ... 1) kw-loop1-1\n\n + ... 2) kw-loop1-2\n\n + ... 3) kw-loop1-3 + FOR loop in in user keyword with continue tag Continue in FOR loop in UK without tag - [Documentation] FAIL kw-loop-1 + [Documentation] FAIL kw-loop2-1 FOR loop in in user keyword without tag -Continue in IF with tag +Continue in IF with continue tag [Documentation] FAIL ${HEADER}\n\n ... 1) 1\n\n ... 2) 2\n\n @@ -136,26 +140,26 @@ No continue in IF without tag Fail This should not be executed END -Continue in IF in UK with tag +Continue in IF in UK with continue tag [Documentation] FAIL ${HEADER}\n\n - ... 1) kw1a\n\n - ... 2) kw1b\n\n - ... 3) kw1c\n\n - ... 4) kw1d - IF in user keyword with tag + ... 1) kw7a\n\n + ... 2) kw7b\n\n + ... 3) kw7c\n\n + ... 4) kw7d + IF in user keyword with continue tag No continue in IF in UK without tag - [Documentation] FAIL kw1a + [Documentation] FAIL kw8a IF in user keyword without tag -Continue in Run Keywords with tag +Continue in Run Keywords with continue tag [Documentation] FAIL ${HEADER}\n\n ... 1) 1\n\n ... 2) 2 [Tags] robot:continue-on-failure Run Keywords Fail 1 AND Fail 2 -Recursive continue in test with tag and two nested UK without tag +Recursive continue in test with continue tag and two nested UK without tag [Documentation] FAIL ${HEADER}\n\n ... 1) kw2a\n\n ... 2) kw2b\n\n @@ -177,7 +181,7 @@ Recursive continue in test with Set Tags and two nested UK without tag Failure in user keyword without tag run_kw=Failure in user keyword without tag Fail This should be executed -Recursive continue in test with tag and two nested UK with and without tag +Recursive continue in test with continue tag and two nested UK with and without tag [Documentation] FAIL ${HEADER}\n\n ... 1) kw1a\n\n ... 2) kw1b\n\n @@ -185,27 +189,169 @@ Recursive continue in test with tag and two nested UK with and without tag ... 4) kw2b\n\n ... 5) This should be executed [Tags] ROBOT:RECURSIVE-CONTINUE-ON-FAILURE # case shouldn't matter - Failure in user keyword with tag run_kw=Failure in user keyword without tag + Failure in user keyword with continue tag run_kw=Failure in user keyword without tag + Fail This should be executed + +Recursive continue in test with continue tag and UK with stop tag + [Documentation] FAIL ${HEADER}\n\n + ... 1) kw4a\n\n + ... 2) This should be executed + [Tags] robot:recursive-continue-on-failure + Failure in user keyword with stop tag + Fail This should be executed + +Recursive continue in test with continue tag and UK with recursive stop tag + [Documentation] FAIL ${HEADER}\n\n + ... 1) kw11a\n\n + ... 2) This should be executed + [Tags] robot:recursive-continue-on-failure + Failure in user keyword with recursive stop tag Fail This should be executed Recursive continue in user keyword [Documentation] FAIL ${HEADER}\n\n - ... 1) kw1a\n\n - ... 2) kw1b\n\n + ... 1) kw3a\n\n + ... 2) kw3b\n\n ... 3) kw2a\n\n ... 4) kw2b - Failure in user keyword with recursive tag run_kw=Failure in user keyword without tag + Failure in user keyword with recursive continue tag run_kw=Failure in user keyword without tag Fail This should not be executed Recursive continue in nested keyword [Documentation] FAIL ${HEADER}\n\n + ... 1) kw3a\n\n + ... 2) kw3b + Failure in user keyword without tag run_kw=Failure in user keyword with recursive continue tag + Fail This should not be executed + +stop-on-failure in keyword in Teardown + [Documentation] FAIL Teardown failed:\nkw4a + [Teardown] Failure in user keyword with stop tag + No Operation + +stop-on-failure with continuable failure in keyword in Teardown + [Documentation] FAIL Teardown failed:\n${HEADER}\n\n + ... 1) ${EXC}: kw9a\n\n + ... 2) kw9b + [Teardown] Continuable Failure in user keyword with stop tag + No Operation + +stop-on-failure with run-kw-and-continue failure in keyword in Teardown + [Documentation] FAIL Teardown failed:\n${HEADER}\n\n + ... 1) kw10a\n\n + ... 2) kw10b + [Teardown] run-kw-and-continue failure in user keyword with stop tag + No Operation + +stop-on-failure with run-kw-and-continue failure in keyword + [Documentation] FAIL ${HEADER}\n\n + ... 1) kw10a\n\n + ... 2) kw10b + run-kw-and-continue failure in user keyword with stop tag + +Test teardown using run keywords with stop tag in test case + [Documentation] FAIL Teardown failed:\n1 + [Tags] robot:stop-on-failure + [Teardown] Run Keywords Fail 1 AND Fail 2 + No Operation + +Test teardown using user keyword with stop tag in test case + [Documentation] FAIL Teardown failed:\n${HEADER}\n\n + ... 1) kw2a\n\n + ... 2) kw2b + [Tags] robot:stop-on-failure + [Teardown] Failure in user keyword without tag + No Operation + +Test teardown using user keyword with recursive stop tag in test case + [Documentation] FAIL Teardown failed:\nkw2a + [Tags] robot:recursive-stop-on-failure + [Teardown] Failure in user keyword without tag + No Operation + +Test Teardown with stop tag in user keyword + [Documentation] FAIL Keyword teardown failed:\nkw5a + Teardown with stop tag in user keyword + No Operation + +Test Teardown with recursive stop tag in user keyword + [Documentation] FAIL Keyword teardown failed:\nkw6a + Teardown with recursive stop tag in user keyword + +Test Teardown with recursive stop tag and UK with continue tag + # continue-on-failure overrides recursive-stop-on-failure + [Documentation] FAIL Keyword teardown failed:\n${HEADER}\n\n ... 1) kw1a\n\n ... 2) kw1b - Failure in user keyword without tag run_kw=Failure in user keyword with recursive tag - Fail This should not be executed + Teardown with recursive stop tag in user keyword run_kw=Failure in user keyword with continue tag + +Test Teardown with recursive stop tag and UK with recursive continue tag + # recursive-continue-on-failure overrides recursive-stop-on-failure + [Documentation] FAIL Keyword teardown failed:\n${HEADER}\n\n + ... 1) kw3a\n\n + ... 2) kw3b + Teardown with recursive stop tag in user keyword run_kw=Failure in user keyword with recursive continue tag + +stop-on-failure with Template + [Documentation] FAIL 42 != 43 + [Tags] robot:stop-on-failure + [Template] Should Be Equal + Same Same + 42 43 + Something Different + +recursive-stop-on-failure with Template + [Documentation] FAIL 42 != 43 + [Tags] robot:recursive-stop-on-failure + [Template] Should Be Equal + Same Same + 42 43 + Something Different + +stop-on-failure with Template and Teardown + [Documentation] FAIL 42 != 43\n\nAlso teardown failed:\n1 + [Tags] robot:stop-on-failure + [Teardown] Run Keywords Fail 1 AND Fail 2 + [Template] Should Be Equal + Same Same + 42 43 + Something Different + +stop-on-failure does not stop continuable failure in test + [Documentation] FAIL ${HEADER}\n\n + ... 1) 1\n\n + ... 2) 2 + [Tags] robot:stop-on-failure + Run Keyword And Continue On Failure Fail 1 + Fail 2 + +Test recursive-continue-recursive-stop + [Documentation] FAIL ${HEADER}\n\n + ... 1) kw11a\n\n + ... 2) 2 + [Tags] robot:recursive-continue-on-failure + Failure in user keyword with recursive stop tag + Fail 2 + +Test recursive-stop-recursive-continue + [Documentation] FAIL ${HEADER}\n\n + ... 1) kw3a\n\n + ... 2) kw3b + [Tags] robot:recursive-stop-on-failure + Failure in user keyword with recursive continue tag + Fail 2 + +Test recursive-stop-recursive-continue-recursive-stop + [Documentation] FAIL ${HEADER}\n\n + ... 1) kw3a\n\n + ... 2) kw3b\n\n + ... 3) kw11a + [Tags] robot:recursive-stop-on-failure + Failure in user keyword with recursive continue tag run_kw=Failure in user keyword with recursive stop tag + Fail 2 *** Keywords *** -Failure in user keyword with tag +Failure in user keyword with continue tag [Arguments] ${run_kw}=No Operation [Tags] robot:continue-on-failure Fail kw1a @@ -219,46 +365,85 @@ Failure in user keyword without tag Fail kw2a Fail kw2b -Failure in user keyword with recursive tag +Failure in user keyword with recursive continue tag [Arguments] ${run_kw}=No Operation [Tags] robot:recursive-continue-on-failure - Fail kw1a - Fail kw1b + Fail kw3a + Fail kw3b Log This should be executed Run Keyword ${run_kw} -FOR loop in in user keyword with tag +Failure in user keyword with stop tag + [Tags] robot:stop-on-failure + Fail kw4a + Log This should not be executed + Fail kw4b + +Failure in user keyword with recursive stop tag + [Tags] robot:recursive-stop-on-failure + Fail kw11a + Log This is not executed + Fail kw11b + +Teardown with stop tag in user keyword + [Tags] robot:stop-on-failure + [Teardown] Run Keywords Fail kw5a AND Fail kw5b + No Operation + +Teardown with recursive stop tag in user keyword + [Arguments] ${run_kw}=No Operation + [Tags] robot:recursive-stop-on-failure + [Teardown] Run Keywords ${run_kw} AND Fail kw6a AND Fail kw6b + No Operation + +FOR loop in in user keyword with continue tag [Tags] robot:continue-on-failure FOR ${val} IN 1 2 3 - Fail kw-loop-${val} + Fail kw-loop1-${val} END FOR loop in in user keyword without tag FOR ${val} IN 1 2 3 - Fail kw-loop-${val} + Fail kw-loop2-${val} END -IF in user keyword with tag +IF in user keyword with continue tag [Tags] robot:continue-on-failure IF 1==1 - Fail kw1a - Fail kw1b + Fail kw7a + Fail kw7b END IF 1==2 No Operation ELSE - Fail kw1c - Fail kw1d + Fail kw7c + Fail kw7d END IF in user keyword without tag IF 1==1 - Fail kw1a - Fail kw1b + Fail kw8a + Fail kw8b END IF 1==2 No Operation ELSE - Fail kw1c - Fail kw1d + Fail kw8c + Fail kw8d END + +Continuable Failure in user keyword with stop tag + [Tags] robot:stop-on-failure + Raise Continuable Failure kw9a + Log This is executed + Fail kw9b + Log This is not executed + Fail kw9c + +run-kw-and-continue failure in user keyword with stop tag + [Tags] robot:stop-on-failure + Run Keyword And Continue On Failure Fail kw10a + Log This is executed + Fail kw10b + Log This is not executed + Fail kw10c diff --git a/doc/userguide/src/ExecutingTestCases/TestExecution.rst b/doc/userguide/src/ExecutingTestCases/TestExecution.rst index a55bbbc18d2..81986aac838 100644 --- a/doc/userguide/src/ExecutingTestCases/TestExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/TestExecution.rst @@ -381,6 +381,9 @@ originating from library keywords. Controlling continue on failure using reserved tags ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +robot:continue-on-failure +''''''''''''''''''''''''' + All keywords executed as part of test cases or user keywords which are tagged with the reserved tag `robot:continue-on-failure` are considered continuable by default. @@ -410,10 +413,11 @@ Thus, the following two test cases :name:`Test 1` and :name:`Test 2` behave iden Log this message is logged -These tags also influence continue-on-failure in FOR loops and +These tags also influence continue-on-failure in FOR and WHILE loops +(support in WHILE loops was added in RobotFramework 5.1) and within IF/ELSE branches. The below test case will execute the test 10 times, no matter if -the "Perform some test keyword" failed or not. +the "Perform some test" keyword failed or not. .. sourcecode:: robotframework @@ -426,13 +430,13 @@ the "Perform some test keyword" failed or not. Setting `robot:continue-on-failure` within a test case will not -propagate the continue on failure behaviour into user keywords +propagate the continue on failure behavior into user keywords executed from within this test case (same is true for user keywords executed from within a user keyword with the reserved tag set). -To support use cases where the behaviour should propagate from +To support use cases where the behavior should propagate from test cases into user keywords (and/or from user keywords into other -user keywords), the reserved tag `robot:recursive-continue-on-failure` +user keywords), the reserved tag **`robot:recursive-continue-on-failure`** can be used. The below examples executes all the keywords listed. .. sourcecode:: robotframework @@ -455,8 +459,100 @@ can be used. The below examples executes all the keywords listed. Log log from keyword 2 -The `robot:continue-on-failure` and `robot:recursive-continue-on-failure` -tags are new in Robot Framework 4.1. +You can override the recursive continue behavior using the reserved +`robot:stop-on-failure` keyword tag: + +.. sourcecode:: robotframework + + *** Test Cases *** + Test + [Tags] robot:recursive-continue-on-failure + Should be Equal 1 2 + User Keyword 1 + Log log from test case + + *** Keywords *** + User Keyword 1 + Should be Equal 3 4 + Log this is executed + User Keyword 2 + Log this is also executed + + User Keyword 2 + [Tags] robot:stop-on-failure + Should be Equal 5 6 + Log this is not executed + +robot:stop-on-failure +''''''''''''''''''''' + +The `robot:stop-on-failure` can also be used to alter +the default continue behavior of `Execution continues on teardowns automatically`_ +and `All top-level keywords are executed when tests have templates`_: + +In the example below, the teardown keyword will not continue following the failure. + +.. sourcecode:: robotframework + + *** Test Cases *** + Test + No Operation + [Teardown] Teardown keyword + + *** Keywords *** + Teardown keyword + [Tags] robot:stop-on-failure + Should be Equal 1 2 + Log this is not executed + + +The same is true for Template exection. In the below example, the template +execution stops after the failure comparing 'Something' and 'Different'. + +.. sourcecode:: robotframework + + *** Test Cases *** + Template Test + [Tags] robot:stop-on-failure + [Template] Should Be Equal + Same Same + Something Different + This is not compared + +The tag `robot:stop-on-failure` defined in the test case does +not propagate to user keywords executed in Teardown or in Template. +If this behavior is desired, you can leverage the **`robot:recursive-stop-on-failure`** +tag in the test case (or user keyword) which propagates to all user +keywords executed from within the test case (or user keyword), similar +to the `robot:recursive-continue-on-failure` seen earlier. + +.. note:: + + - The specific `robot:stop-on-failure` and `robot:continue-on-failure` tags + in a test case or user keyword take preference over `robot:recursive-stop-on-failure` + and `robot:recursive-continue-on-failure` defined in its parents. + - If both `robot:recursive-stop-on-failure` and `robot:recursive-continue-on-failure` + are defined in a user keyword's parents, the one "closest" to the user keyword + in the call chain takes preference. + - If both reserved stop and continue tags are set on a test case or user keyword, the stop + variant takes preference. + - Both `robot:stop-on-failure` or `robot:recursive-stop-on-failure` do NOT + alter the behavior of continuable keywords. In the following example, all + keywords are executed: + + .. sourcecode:: robotframework + + *** Test Cases *** + Test + [Tags] robot:stop-on-failure + Run Keyword and Continue on Failure Fail failure + Log this is executed + + + - The `robot:continue-on-failure` and `robot:recursive-continue-on-failure` + tags are new in Robot Framework 4.1. + - The `robot:stop-on-failure` and `robot:recursive-stop-on-failure` + tags are new in Robot Framework 5.1. Execution continues on teardowns automatically ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -466,6 +562,9 @@ continue on failure mode is automatically on in `test and suite teardowns`__. In practice this means that in teardowns all the keywords in all levels are always executed. +The reserved tags `robot:stop-on-failure`_ and `robot:recursive-stop-on-failure` +can be used to alter this behavior. + __ `Setups and teardowns`_ All top-level keywords are executed when tests have templates @@ -476,6 +575,9 @@ make it sure that all the different combinations are tested. In this usage continuing is limited to the top-level keywords, and inside them the execution ends normally if there are non-continuable failures. +The reserved tags `robot:stop-on-failure`_ and `robot:recursive-stop-on-failure` +can be used to alter this behavior. + Stopping test execution gracefully ---------------------------------- diff --git a/src/robot/errors.py b/src/robot/errors.py index 572398046ca..2d5c4c0c044 100644 --- a/src/robot/errors.py +++ b/src/robot/errors.py @@ -139,14 +139,12 @@ def can_continue(self, context, templated=False): if self.syntax or self.exit or self.skip or self.test_timeout: return False if templated: - return True + return context.continue_on_failure(default=True) if self.keyword_timeout: if context.in_teardown: self.keyword_timeout = False return False - if context.in_teardown or context.continue_on_failure: - return True - return self.continue_on_failure + return self.continue_on_failure or context.continue_on_failure() def get_errors(self): return [self] diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 67d52b1bf68..26d0722e517 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -126,14 +126,16 @@ def in_teardown(self): def variables(self): return self.namespace.variables - @property - def continue_on_failure(self): + def continue_on_failure(self, default=False): parents = ([self.test] if self.test else []) + self.user_keywords - if not parents: - return False - if 'robot:continue-on-failure' in parents[-1].tags: - return True - return any('robot:recursive-continue-on-failure' in p.tags for p in parents) + for index, parent in enumerate(reversed(parents)): + if ('robot:recursive-stop-on-failure' in parent.tags + or index == 0 and 'robot:stop-on-failure' in parent.tags): + return False + if ('robot:recursive-continue-on-failure' in parent.tags + or index == 0 and 'robot:continue-on-failure' in parent.tags): + return True + return default or self.in_teardown @property def allow_loop_control(self): From c77780dc5a511975902461d4efad1574c80a79fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 8 Jun 2022 22:28:15 +0300 Subject: [PATCH 0032/1592] Make sure localizations are always imported as modules. Also add new utility method fot that purpose. --- src/robot/conf/languages.py | 3 +-- src/robot/utils/importer.py | 54 +++++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 9f6bb6e853c..297e9e28137 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -76,11 +76,10 @@ def find_subclass(member): return (inspect.isclass(member) and issubclass(member, Language) and member is not Language) - # FIXME: make sure only module is imported # FIXME: error handling if os.path.exists(lang): lang = os.path.abspath(lang) - module = Importer().import_class_or_module(lang) + module = Importer().import_module(lang) return [value for _, value in inspect.getmembers(module, find_subclass)] @property diff --git a/src/robot/utils/importer.py b/src/robot/utils/importer.py index 5bc24bce7e2..00f093db130 100644 --- a/src/robot/utils/importer.py +++ b/src/robot/utils/importer.py @@ -80,9 +80,11 @@ def import_class_or_module(self, name_or_path, instantiate_with_args=None, the name or path like ``Example:arg1:arg2``, separate :func:`~robot.utils.text.split_args_from_name_or_path` function can be used to split them before calling this method. + + Use :meth:`import_module` if only a module needs to be imported. """ try: - imported, source = self._import_class_or_module(name_or_path) + imported, source = self._import(name_or_path) self._log_import_succeeded(imported, name_or_path, source) imported = self._instantiate_if_needed(imported, instantiate_with_args) except DataError as err: @@ -90,10 +92,37 @@ def import_class_or_module(self, name_or_path, instantiate_with_args=None, else: return self._handle_return_values(imported, source, return_source) - def _import_class_or_module(self, name): + def import_module(self, name_or_path, return_source=False): + """Imports Python module based on the given name or path. + + :param name_or_path: + Name or path of the module to import. + :param return_source: + When true, returns a tuple containing the imported module + and a path to it. By default, returns only the imported module. + + The module to import can be specified either as a name, in which + case it must be in the module search path, or as a path to the file or + directory implementing the module. See :meth:`import_class_or_module_by_path` + for more information about importing modules by path. + + Use :meth:`import_class_or_module` if it is desired to get a class + from the imported module automatically. + + New in Robot Framework 5.1. + """ + try: + imported, source = self._import(name_or_path, get_class=False) + self._log_import_succeeded(imported, name_or_path, source) + except DataError as err: + self._raise_import_failed(name_or_path, err) + else: + return self._handle_return_values(imported, source, return_source) + + def _import(self, name, get_class=True): for importer in self._importers: if importer.handles(name): - return importer.import_(name) + return importer.import_(name, get_class) def _handle_return_values(self, imported, source, return_source=False): if not return_source: @@ -217,11 +246,12 @@ class ByPathImporter(_Importer): def handles(self, path): return os.path.isabs(path) - def import_(self, path): + def import_(self, path, get_class=True): self._verify_import_path(path) self._remove_wrong_module_from_sys_modules(path) - module = self._import_by_path(path) - imported = self._get_class_from_module(module) or module + imported = self._import_by_path(path) + if get_class: + imported = self._get_class_from_module(imported) or imported return self._verify_type(imported), path def _verify_import_path(self, path): @@ -277,9 +307,10 @@ class NonDottedImporter(_Importer): def handles(self, name): return '.' not in name - def import_(self, name): - module = self._import(name) - imported = self._get_class_from_module(module) or module + def import_(self, name, get_class=True): + imported = self._import(name) + if get_class: + imported = self._get_class_from_module(imported) or imported return self._verify_type(imported), self._get_source(imported) @@ -288,7 +319,7 @@ class DottedImporter(_Importer): def handles(self, name): return '.' in name - def import_(self, name): + def import_(self, name, get_class=True): parent_name, lib_name = name.rsplit('.', 1) parent = self._import(parent_name, fromlist=[str(lib_name)]) try: @@ -296,7 +327,8 @@ def import_(self, name): except AttributeError: raise DataError("Module '%s' does not contain '%s'." % (parent_name, lib_name)) - imported = self._get_class_from_module(imported, lib_name) or imported + if get_class: + imported = self._get_class_from_module(imported, lib_name) or imported return self._verify_type(imported), self._get_source(imported) From f03c75ee8edffc8f47412e00cdc83bcd492ac4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 9 Jun 2022 12:19:08 +0300 Subject: [PATCH 0033/1592] Fine-tune newly added `Importer.import_module()`. - Remove `return_source` as it's not really useful. Modules have `__file__` anyway. - Add tests. Also small import cleanup. --- src/robot/utils/importer.py | 11 ++++------- utest/utils/test_importer_util.py | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/robot/utils/importer.py b/src/robot/utils/importer.py index 00f093db130..3461a3cdfe3 100644 --- a/src/robot/utils/importer.py +++ b/src/robot/utils/importer.py @@ -15,8 +15,8 @@ import os import sys +import importlib import inspect -from importlib import invalidate_caches as invalidate_import_caches from robot.errors import DataError @@ -92,14 +92,11 @@ def import_class_or_module(self, name_or_path, instantiate_with_args=None, else: return self._handle_return_values(imported, source, return_source) - def import_module(self, name_or_path, return_source=False): + def import_module(self, name_or_path): """Imports Python module based on the given name or path. :param name_or_path: Name or path of the module to import. - :param return_source: - When true, returns a tuple containing the imported module - and a path to it. By default, returns only the imported module. The module to import can be specified either as a name, in which case it must be in the module search path, or as a path to the file or @@ -117,7 +114,7 @@ def import_module(self, name_or_path, return_source=False): except DataError as err: self._raise_import_failed(name_or_path, err) else: - return self._handle_return_values(imported, source, return_source) + return imported def _import(self, name, get_class=True): for importer in self._importers: @@ -215,7 +212,7 @@ def _import(self, name, fromlist=None): if name in sys.builtin_module_names: raise DataError('Cannot import custom module with same name as ' 'Python built-in module.') - invalidate_import_caches() + importlib.invalidate_caches() try: return __import__(name, fromlist=fromlist) except: diff --git a/utest/utils/test_importer_util.py b/utest/utils/test_importer_util.py index b5a0241a937..6a6c4ae633c 100644 --- a/utest/utils/test_importer_util.py +++ b/utest/utils/test_importer_util.py @@ -258,7 +258,7 @@ def test_import_file_by_path(self): module = self._import_module(join(LIBDIR, 'module_library.py')) assert_equal(module.__name__, expected.__name__) assert_equal(dirname(normpath(module.__file__)), - dirname(normpath(expected.__file__))) + dirname(normpath(expected.__file__))) assert_equal(dir(module), dir(expected)) def test_import_class_from_file_by_path(self): @@ -301,6 +301,19 @@ def _import(self, name, type=None, logger=None): return Importer(type, logger or LoggerStub()).import_class_or_module(name) +class TestImportModule(unittest.TestCase): + + def test_import_module(self): + module = Importer().import_module('ExampleLibrary') + assert_equal(module.ExampleLibrary().return_string_from_library('xxx'), 'xxx') + + def test_logging(self): + logger = LoggerStub(remove_extension=True) + Importer(logger=logger).import_module('ExampleLibrary') + logger.assert_message("Imported module 'ExampleLibrary' from '%s'." + % join(LIBDIR, 'ExampleLibrary')) + + class TestErrorDetails(unittest.TestCase): def test_no_traceback(self): From f7ed80c0f996a168302f932eb08d1e3962856799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 9 Jun 2022 17:54:37 +0300 Subject: [PATCH 0034/1592] UG: Enhance the Continuing on failure section. - Better order of discussed topics. - Mention TRY/EXCEPT. - Enhance docs of robot:(recursive-)stop/continue-on-failure. This is related to #4303. - Smaller enhancements also also elsewhere in the section. --- .../src/ExecutingTestCases/TestExecution.rst | 317 +++++++++--------- 1 file changed, 160 insertions(+), 157 deletions(-) diff --git a/doc/userguide/src/ExecutingTestCases/TestExecution.rst b/doc/userguide/src/ExecutingTestCases/TestExecution.rst index 81986aac838..332ecb29a24 100644 --- a/doc/userguide/src/ExecutingTestCases/TestExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/TestExecution.rst @@ -215,8 +215,8 @@ specified tags or tag patterns are skipped:: --skip windowsANDversion9? --skip python2.* --skip python3.[0-6] -Starting from RF 5.0, a test case can also be skipped by tagging the test with the -reserved tag `robot:skip`: +Starting from Robot Framework 5.0, a test case can also be skipped by tagging +the test with the reserved tag `robot:skip`: .. sourcecode:: robotframework @@ -317,43 +317,65 @@ Suite status is determined solely based on statuses of the tests it contains: - If there are no failures but at least one test has passed, suite status is PASS. - If all tests have been skipped or the are no tests at all, suite status is SKIP. -Continue on failure -------------------- +.. _continue on failure: + +Continuing on failure +--------------------- Normally test cases are stopped immediately when any of their keywords fail. This behavior shortens test execution time and prevents subsequent keywords hanging or otherwise causing problems if the -system under test is in unstable state. This has the drawback that often +system under test is in unstable state. This has a drawback that often subsequent keywords would give more information about the state of the -system. Hence Robot Framework offers several features to continue after -failures. +system, though, and in some cases those subsequent keywords would actually +take care of the needed cleanup activities. Hence Robot Framework offers +several features to continue even if there are failures. + +Execution continues on teardowns automatically +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -:name:`Run Keyword And Ignore Error` and :name:`Run Keyword And Expect Error` keywords -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +To make it sure that all the cleanup activities are taken care of, the +continue-on-failure mode is automatically enabled in `suite, test and keyword +teardowns`__. In practice this means that in teardowns all the +keywords in all levels are always executed. -BuiltIn_ keywords :name:`Run Keyword And Ignore Error` and :name:`Run -Keyword And Expect Error` handle failures so that test execution is not -terminated immediately. Though, using these keywords for this purpose -often adds extra complexity to test cases, so the following features are -worth considering to make continuing after failures easier. +If this behavior is not desired, it `can be disabled`__ using the special +`robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags. -:name:`Run Keyword And Warn On Failure` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -BuiltIn_ keyword :name:`Run Keyword And Warn On Failure` handles failure -similar to :name:`Run Keyword And Ignore Error` in the sense that test -execution is not terminated immediately, but will report failures as a -warning message. +__ `Setups and teardowns`_ +__ `Disabling continue-on-failure using tags`_ + +All top-level keywords are executed when tests have templates +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When using `test templates`_, all the top-level keywords are executed to +make it sure that all the different combinations are covered. In this +usage continuing is limited to the top-level keywords, and inside them +the execution ends normally if there are non-continuable failures. + +.. sourcecode:: robotframework + + *** Test Cases *** + Continue with templates + [Template] Should be Equal + this fails + this is run + +If this behavior is not desired, it `can be disabled`__ using the special +`robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags. + +__ `Disabling continue-on-failure using tags`_ Special failures from keywords ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ `Library keywords`_ report failures using exceptions, and it is -possible to use special exceptions to tell the core framework that +possible to use special exceptions to tell Robot Framework that execution can continue regardless the failure. How these exceptions can be created is explained in the `Continuable failures`_ section in the `Creating test libraries`_ section. -When a test ends and there has been one or more continuable failure, +When a test ends and there have been continuable failures, the test will be marked failed. If there are more than one failure, all of them will be enumerated in the final error message:: @@ -361,10 +383,10 @@ all of them will be enumerated in the final error message:: 1) First error message. - 2) Second error message ... + 2) Second error message. -Test execution ends also if a normal failure occurs after continuable -failures. Also in that case all the failures will be listed in the +Test execution ends also if a normal failure occurs after a continuable +failure. Also in that case all the failures will be listed in the final error message. The return value from failed keywords, possibly assigned to a @@ -376,207 +398,188 @@ variable, is always the Python `None`. BuiltIn_ keyword :name:`Run Keyword And Continue On Failure` allows converting any failure into a continuable failure. These failures are handled by the framework exactly the same way as continuable failures -originating from library keywords. +originating from library keywords discussed above. -Controlling continue on failure using reserved tags -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. sourcecode:: robotframework -robot:continue-on-failure -''''''''''''''''''''''''' + *** Test Cases *** + Example + Run Keyword and Continue on Failure Should be Equal 1 2 + Log This is executed but test fails in the end -All keywords executed as part of test cases or user keywords which are -tagged with the reserved tag `robot:continue-on-failure` are considered continuable -by default. +Enabling continue-on-failure using tags +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Thus, the following two test cases :name:`Test 1` and :name:`Test 2` behave identically: +All keywords executed as part of test cases or user keywords which are +tagged with the `robot:continue-on-failure` tag are considered continuable +by default. For example, the following two tests behave identically: .. sourcecode:: robotframework *** Test Cases *** Test 1 - Run Keyword and Continue on Failure Should be Equal 1 2 + Run Keyword and Continue on Failure Should be Equal 1 2 User Keyword 1 Test 2 [Tags] robot:continue-on-failure - Should be Equal 1 2 + Should be Equal 1 2 User Keyword 2 *** Keywords *** User Keyword 1 - Run Keyword and Continue on Failure Should be Equal 3 4 - Log this message is logged + Run Keyword and Continue on Failure Should be Equal 3 4 + Log This is executed User Keyword 2 [Tags] robot:continue-on-failure - Should be Equal 3 4 - Log this message is logged - + Should be Equal 3 4 + Log This is executed -These tags also influence continue-on-failure in FOR and WHILE loops -(support in WHILE loops was added in RobotFramework 5.1) and -within IF/ELSE branches. -The below test case will execute the test 10 times, no matter if -the "Perform some test" keyword failed or not. +These tags also affect the continue-on-failure mode with different `control +structures`_. For example, the below test case will execute the +:name:`Do Something` keyword ten times regardless does it succeed or not: .. sourcecode:: robotframework *** Test Cases *** - Test Case + Example [Tags] robot:continue-on-failure FOR ${index} IN RANGE 10 - Perform some test + Do Something END - -Setting `robot:continue-on-failure` within a test case will not -propagate the continue on failure behavior into user keywords -executed from within this test case (same is true for user keywords -executed from within a user keyword with the reserved tag set). - -To support use cases where the behavior should propagate from -test cases into user keywords (and/or from user keywords into other -user keywords), the reserved tag **`robot:recursive-continue-on-failure`** -can be used. The below examples executes all the keywords listed. +Setting `robot:continue-on-failure` within a test case or a user keyword +will not propagate the continue-on-failure behavior into user keywords +they call. If such recursive behavior is needed, the +`robot:recursive-continue-on-failure` tag can be used. For example, all +keywords in the following example are executed: .. sourcecode:: robotframework *** Test Cases *** - Test + Example [Tags] robot:recursive-continue-on-failure - Should be Equal 1 2 + Should be Equal 1 2 User Keyword 1 - Log log from test case + Log This is executed *** Keywords *** User Keyword 1 - Should be Equal 3 4 - Log log from keyword 1 + Should be Equal 3 4 User Keyword 2 + Log This is executed User Keyword 2 - Should be Equal 5 6 - Log log from keyword 2 + Should be Equal 5 6 + Log This is executed + +.. note:: The `robot:continue-on-failure` and `robot:recursive-continue-on-failure` + tags are new in Robot Framework 4.1. They do not work properly with + `WHILE` loops prior to Robot Framework 5.1. + +Disabling continue-on-failure using tags +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Special tags `robot:stop-on-failure` and `robot:recursive-stop-on-failure` +can be used to disable the continue-on-failure mode if needed. They work +when `continue-on-failure has been enabled using tags`__ and also with +teardowns__ and templates__: -You can override the recursive continue behavior using the reserved -`robot:stop-on-failure` keyword tag: +__ `Enabling continue-on-failure using tags`_ +__ `Execution continues on teardowns automatically`_ +__ `All top-level keywords are executed when tests have templates`_ .. sourcecode:: robotframework *** Test Cases *** - Test + Disable continue-in-failure set using tags [Tags] robot:recursive-continue-on-failure - Should be Equal 1 2 - User Keyword 1 - Log log from test case + Keyword + Keyword # This is executed - *** Keywords *** - User Keyword 1 - Should be Equal 3 4 - Log this is executed - User Keyword 2 - Log this is also executed + Disable continue-in-failure in teardown + No Operation + [Teardown] Keyword - User Keyword 2 + Disable continue-in-failure with templates [Tags] robot:stop-on-failure - Should be Equal 5 6 - Log this is not executed - -robot:stop-on-failure -''''''''''''''''''''' - -The `robot:stop-on-failure` can also be used to alter -the default continue behavior of `Execution continues on teardowns automatically`_ -and `All top-level keywords are executed when tests have templates`_: + [Template] Should be Equal + this fails + this is not run -In the example below, the teardown keyword will not continue following the failure. + *** Keywords *** + Keyword + [Tags] robot:stop-on-failure + Should be Equal this fails + Should be Equal this is not run + +The `robot:stop-on-failure` tag affects only test cases and user keywords +where it is used and does not propagate to user keywords they call nor to +their own teardowns. If recursive behavior affecting all called user keywords +and teardowns is desired, the `robot:recursive-stop-on-failure` tag can be +used instead. If there is a need, its effect can again be disabled in lower +level keywords by using `robot:continue-on-failure` or +`robot:recursive-continue-on-failure` tags. + +The `robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags do not +alter the behavior of continuable failures caused by `library keywords`__ or +by `Run Keyword And Continue On Failure`__. For example, both keywords in this +example are run even though `robot:stop-on-failure` is used: .. sourcecode:: robotframework *** Test Cases *** - Test - No Operation - [Teardown] Teardown keyword - - *** Keywords *** - Teardown keyword + Example [Tags] robot:stop-on-failure - Should be Equal 1 2 - Log this is not executed + Run Keyword and Continue on Failure Should be Equal 1 2 + Log This is executed regardless the tag + +__ `Special failures from keywords`_ +__ `Run Keyword And Continue On Failure keyword`_ + +.. note:: The `robot:stop-on-failure` and `robot:recursive-stop-on-failure` + tags are new in Robot Framework 5.1. +TRY/EXCEPT +~~~~~~~~~~ -The same is true for Template exection. In the below example, the template -execution stops after the failure comparing 'Something' and 'Different'. +Robot Framework 5.0 introduced native `TRY/EXCEPT` syntax that can be used for +handling failures: .. sourcecode:: robotframework - *** Test Cases *** - Template Test - [Tags] robot:stop-on-failure - [Template] Should Be Equal - Same Same - Something Different - This is not compared - -The tag `robot:stop-on-failure` defined in the test case does -not propagate to user keywords executed in Teardown or in Template. -If this behavior is desired, you can leverage the **`robot:recursive-stop-on-failure`** -tag in the test case (or user keyword) which propagates to all user -keywords executed from within the test case (or user keyword), similar -to the `robot:recursive-continue-on-failure` seen earlier. - -.. note:: - - - The specific `robot:stop-on-failure` and `robot:continue-on-failure` tags - in a test case or user keyword take preference over `robot:recursive-stop-on-failure` - and `robot:recursive-continue-on-failure` defined in its parents. - - If both `robot:recursive-stop-on-failure` and `robot:recursive-continue-on-failure` - are defined in a user keyword's parents, the one "closest" to the user keyword - in the call chain takes preference. - - If both reserved stop and continue tags are set on a test case or user keyword, the stop - variant takes preference. - - Both `robot:stop-on-failure` or `robot:recursive-stop-on-failure` do NOT - alter the behavior of continuable keywords. In the following example, all - keywords are executed: - - .. sourcecode:: robotframework - - *** Test Cases *** - Test - [Tags] robot:stop-on-failure - Run Keyword and Continue on Failure Fail failure - Log this is executed - - - - The `robot:continue-on-failure` and `robot:recursive-continue-on-failure` - tags are new in Robot Framework 4.1. - - The `robot:stop-on-failure` and `robot:recursive-stop-on-failure` - tags are new in Robot Framework 5.1. + *** Test Cases *** + Example + TRY + Some Keyword + EXCEPT Expected error message + Error Handler Keyword + END -Execution continues on teardowns automatically -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +For more details see the separate `TRY/EXCEPT syntax`_ section. -To make it sure that all the cleanup activities are taken care of, the -continue on failure mode is automatically on in `test and suite -teardowns`__. In practice this means that in teardowns all the -keywords in all levels are always executed. +BuiltIn keywords +~~~~~~~~~~~~~~~~ -The reserved tags `robot:stop-on-failure`_ and `robot:recursive-stop-on-failure` -can be used to alter this behavior. +There are several BuiltIn_ keywords that can be used to execute other keywords +so that execution can continue after possible failures: -__ `Setups and teardowns`_ +- :name:`Run Keyword And Expect Error` executes a keyword and expects it to fail + with the specified error message. The aforementioned `TRY/EXCEPT` syntax is + nowadays generally recommended instead. -All top-level keywords are executed when tests have templates -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- :name:`Run Keyword And Ignore Error` executes a keyword and silences possible + error. It returns the status along with possible keyword return value or + error message. The `TRY/EXCEPT` syntax generally works better in this case + as well. -When using `test templates`_, all the data rows are always executed to -make it sure that all the different combinations are tested. In this -usage continuing is limited to the top-level keywords, and inside them -the execution ends normally if there are non-continuable failures. +- :name:`Run Keyword And Warn On Failure` is a wrapper for + :name:`Run Keyword And Ignore Error` that automatically logs a warning + if the executed keyword fails. -The reserved tags `robot:stop-on-failure`_ and `robot:recursive-stop-on-failure` -can be used to alter this behavior. +- :name:`Run Keyword And Return Status` executes a keyword and returns Boolean + `True` or `False` depending on did it pass or fail. Stopping test execution gracefully ---------------------------------- From a8cf7dfeaad935ab0fce5a53e0f5de290f98c656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 9 Jun 2022 17:57:15 +0300 Subject: [PATCH 0035/1592] FIXME about missing docs --- src/robot/parsing/lexer/lexer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/robot/parsing/lexer/lexer.py b/src/robot/parsing/lexer/lexer.py index 785dd51bcda..12c55d6f781 100644 --- a/src/robot/parsing/lexer/lexer.py +++ b/src/robot/parsing/lexer/lexer.py @@ -24,6 +24,7 @@ from .tokens import EOS, END, Token +# FIXME: Documentation for `lang`. def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): """Parses the given source to tokens. From bfec5a53346b2cfb1a537aeb3814bab748272910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 14 Jun 2022 14:57:21 +0300 Subject: [PATCH 0036/1592] Add initial Finnish translations for markers (#4096) This was needed for testing purposes (and tests were added as well). Need to be still updated based on what's agreed on at https://robotframework.crowdin.com/robot-framework --- atest/robot/parsing/translations.robot | 34 ++++++++++++++ atest/testdata/parsing/finnish.resource | 9 ++++ atest/testdata/parsing/finnish.robot | 62 +++++++++++++++++++++++++ atest/testdata/parsing/variables.py | 1 + src/robot/conf/languages.py | 36 +++++++++++++- 5 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 atest/robot/parsing/translations.robot create mode 100644 atest/testdata/parsing/finnish.resource create mode 100644 atest/testdata/parsing/finnish.robot create mode 100644 atest/testdata/parsing/variables.py diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot new file mode 100644 index 00000000000..c737faa9f76 --- /dev/null +++ b/atest/robot/parsing/translations.robot @@ -0,0 +1,34 @@ +*** Settings *** +Resource atest_resource.robot + +*** Test Cases *** +Built-in language + Run Tests --lang fi parsing/finnish.robot + Validate Translations + +*** Keywords *** +Validate Translations + Should Be Equal ${SUITE.doc} Suite documentation. + Should Be Equal ${SUITE.metadata}[Metadata] Value + Should Be Equal ${SUITE.setup.name} Suite Setup + Should Be Equal ${SUITE.teardown.name} Suite Teardown + Should Be Equal ${SUITE.status} PASS + ${tc} = Check Test Case Test without settings + Should Be Equal ${tc.doc} ${EMPTY} + Should Be Equal ${tc.tags} ${{['forced tag', 'default tag']}} + Should Be Equal ${tc.timeout} 1 minute + Should Be Equal ${tc.setup.name} Test Setup + Should Be Equal ${tc.teardown.name} Test Teardown + Should Be Equal ${tc.body[0].name} Test Template + ${tc} = Check Test Case Test with settings + Should Be Equal ${tc.doc} Test documentation. + Should Be Equal ${tc.tags} ${{['forced tag', 'own tag']}} + Should Be Equal ${tc.timeout} ${NONE} + Should Be Equal ${tc.setup.name} ${NONE} + Should Be Equal ${tc.teardown.name} ${NONE} + Should Be Equal ${tc.body[0].name} Keyword + Should Be Equal ${tc.body[0].doc} Keyword documentation. + Should Be Equal ${tc.body[0].tags} ${{['kw tag']}} + Should Be Equal ${tc.body[0].timeout} 1 hour + Should Be Equal ${tc.body[0].teardown.name} BuiltIn.No Operation + diff --git a/atest/testdata/parsing/finnish.resource b/atest/testdata/parsing/finnish.resource new file mode 100644 index 00000000000..1a23730ed2a --- /dev/null +++ b/atest/testdata/parsing/finnish.resource @@ -0,0 +1,9 @@ +*** Asetus *** +Dokumentaatio Example documentation. + +*** Muuttuja *** +${RESOURCE FILE} variable in resource file + +*** Avainsana *** +Keyword In Resource + No Operation diff --git a/atest/testdata/parsing/finnish.robot b/atest/testdata/parsing/finnish.robot new file mode 100644 index 00000000000..18569310a0c --- /dev/null +++ b/atest/testdata/parsing/finnish.robot @@ -0,0 +1,62 @@ +*** Asetukset *** +Dokumentaatio Suite documentation. +Metadata Metadata Value +Setin Alustus Suite Setup +Setin Purku Suite Teardown +Testin Alustus Test Setup +Testin Purku Test Teardown +Testin Malli Test Template +Testin Tagit forced tag +Oletus Tagit default tag +Testin Aikaraja 1 minute +Kirjasto OperatingSystem +Resurssi finnish.resource +Muuttujat variables.py + +*** Muuttujat *** +${VARIABLE} variable value + +*** Testit *** +Test without settings + Nothing to see here + +Test with settings + [Dokumentaatio] Test documentation. + [Tagit] own tag + [Alustus] NONE + [Purku] NONE + [Malli] NONE + [Aikaraja] NONE + ${result} = Keyword ${VARIABLE} + Should Be Equal ${result} To be deprecated + +*** Avainsanat *** +Suite Setup + Directory Should Exist ${CURDIR} + +Suite Teardown + Keyword In Resource + +Test Setup + Should Be Equal ${VARIABLE} variable value + Should Be Equal ${RESOURCE FILE} variable in resource file + Should Be Equal ${VARIABLE FILE} variable in variable file + +Test Teardown + No Operation + +Test Template + [Argumentit] ${message} + Log ${message} + +Keyword + [Dokumentaatio] Keyword documentation. + [Argumentit] ${arg} + [Tagit] kw tag + [Aikaraja] 1h + Should Be Equal ${arg} ${VARIABLE} + [Purku] No Operation + [Paluuarvo] To be deprecated + +*** Kommentit *** +Ignored comments. diff --git a/atest/testdata/parsing/variables.py b/atest/testdata/parsing/variables.py new file mode 100644 index 00000000000..a53655ccb1d --- /dev/null +++ b/atest/testdata/parsing/variables.py @@ -0,0 +1 @@ +variable_file = 'variable in variable file' diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 297e9e28137..2f04bb70f28 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -127,14 +127,46 @@ class En(Language): test_setup = 'Test Setup' test_teardown = 'Test Teardown' test_template = 'Test Template' - test_timeout = 'Test Timeout' force_tags = 'Force Tags' default_tags = 'Default Tags' - tags = 'Tags' + test_timeout = 'Test Timeout' setup = 'Setup' teardown = 'Teardown' template = 'Template' + tags = 'Tags' timeout = 'Timeout' arguments = 'Arguments' return_ = 'Return' bdd_prefixes = {'Given', 'When', 'Then', 'And', 'But'} + + +class Fi(Language): + # FIXME: Update based on terms agreed at + # https://robotframework.crowdin.com/robot-framework + setting_headers = {'Asetukset', 'Asetus'} + variable_headers = {'Muuttujat', 'Muuttuja'} + test_case_headers = {'Testit', 'Testi'} + task_headers = {'Tehtävät', 'Tehtävä'} + keyword_headers = {'Avainsanat', 'Avainsana'} + comment_headers = {'Kommentit', 'Kommentti'} + library = 'Kirjasto' + resource = 'Resurssi' + variables = 'Muuttujat' + documentation = 'Dokumentaatio' + metadata = 'Metadata' + suite_setup = 'Setin Alustus' + suite_teardown = 'Setin Purku' + test_setup = 'Testin Alustus' + test_teardown = 'Testin Purku' + test_template = 'Testin Malli' + force_tags = 'Testin Tagit' + default_tags = 'Oletus Tagit' + test_timeout = 'Testin Aikaraja' + setup = 'Alustus' + teardown = 'Purku' + template = 'Malli' + tags = 'Tagit' + timeout = 'Aikaraja' + arguments = 'Argumentit' + return_ = 'Paluuarvo' + bdd_prefixes = {} From a66710423207758c0102e1149e2afabeaaf4ea6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 14 Jun 2022 15:19:18 +0300 Subject: [PATCH 0037/1592] Test for custom localization of data (#4096) --- atest/robot/parsing/translations.robot | 4 ++ atest/testdata/parsing/custom-lang.py | 31 +++++++++++++ atest/testdata/parsing/custom.resource | 9 ++++ atest/testdata/parsing/custom.robot | 62 ++++++++++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 atest/testdata/parsing/custom-lang.py create mode 100644 atest/testdata/parsing/custom.resource create mode 100644 atest/testdata/parsing/custom.robot diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index c737faa9f76..8b94aa431e7 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -6,6 +6,10 @@ Built-in language Run Tests --lang fi parsing/finnish.robot Validate Translations +Custom language + Run Tests --lang ${DATADIR}/parsing/custom-lang.py parsing/custom.robot + Validate Translations + *** Keywords *** Validate Translations Should Be Equal ${SUITE.doc} Suite documentation. diff --git a/atest/testdata/parsing/custom-lang.py b/atest/testdata/parsing/custom-lang.py new file mode 100644 index 00000000000..f5226472f72 --- /dev/null +++ b/atest/testdata/parsing/custom-lang.py @@ -0,0 +1,31 @@ +from robot.conf import Language + + +class Fi(Language): + setting_headers = {'H 1'} + variable_headers = {'H 2'} + test_case_headers = {'H 3'} + task_headers = {'H 4'} + keyword_headers = {'H 5'} + comment_headers = {'H 6'} + library = 'L' + resource = 'R' + variables = 'V' + documentation = 'S 1' + metadata = 'S 2' + suite_setup = 'S 3' + suite_teardown = 'S 4' + test_setup = 'S 5' + test_teardown = 'S 6' + test_template = 'S 7' + force_tags = 'S 8' + default_tags = 'S 9' + test_timeout = 'S 10' + setup = 'S 11' + teardown = 'S 12' + template = 'S 13' + tags = 'S 14' + timeout = 'S 15' + arguments = 'S 16' + return_ = 'S 17' + bdd_prefixes = {} diff --git a/atest/testdata/parsing/custom.resource b/atest/testdata/parsing/custom.resource new file mode 100644 index 00000000000..af1ffb804c5 --- /dev/null +++ b/atest/testdata/parsing/custom.resource @@ -0,0 +1,9 @@ +*** h 1 *** +S 1 Example documentation. + +*** h 2 *** +${RESOURCE FILE} variable in resource file + +*** h 5 *** +Keyword In Resource + No Operation diff --git a/atest/testdata/parsing/custom.robot b/atest/testdata/parsing/custom.robot new file mode 100644 index 00000000000..44ea200fb3f --- /dev/null +++ b/atest/testdata/parsing/custom.robot @@ -0,0 +1,62 @@ +*** H 1 *** +S 1 Suite documentation. +S 2 Metadata Value +S 3 Suite Setup +S 4 Suite Teardown +S 5 Test Setup +S 6 Test Teardown +S 7 Test Template +S 8 forced tag +S 9 default tag +S 10 1 minute +L OperatingSystem +R custom.resource +V variables.py + +*** H 2 *** +${VARIABLE} variable value + +*** H 3 *** +Test without settings + Nothing to see here + +Test with settings + [S 1] Test documentation. + [S 14] own tag + [S 11] NONE + [S 12] NONE + [S 13] NONE + [S 15] NONE + ${result} = Keyword ${VARIABLE} + Should Be Equal ${result} To be deprecated + +*** H 5 *** +Suite Setup + Directory Should Exist ${CURDIR} + +Suite Teardown + Keyword In Resource + +Test Setup + Should Be Equal ${VARIABLE} variable value + Should Be Equal ${RESOURCE FILE} variable in resource file + Should Be Equal ${VARIABLE FILE} variable in variable file + +Test Teardown + No Operation + +Test Template + [S 16] ${message} + Log ${message} + +Keyword + [S 1] Keyword documentation. + [S 16] ${arg} + [S 14] kw tag + [S 15] 1h + Should Be Equal ${arg} ${VARIABLE} + [S 12] No Operation + [S 17] To be deprecated + +*** H 6 *** +Ignored comments. From fc6d44b9fddd7d392333d7e043da9902dbd6b978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=20H=C3=A4nninen?= Date: Thu, 16 Jun 2022 12:50:40 +0300 Subject: [PATCH 0038/1592] UG: Update inheritance example (#4367) --- .../src/ExtendingRobotFramework/CreatingTestLibraries.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 69dcdca3eec..6f227310cd2 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -3262,10 +3262,12 @@ inheritance. This is illustrated by the example below that adds new .. sourcecode:: python from SeleniumLibrary import SeleniumLibrary + from SeleniumLibrary.base import keyword class ExtendedSeleniumLibrary(SeleniumLibrary): + @keyword def title_should_start_with(self, expected): title = self.get_title() if not title.startswith(expected): From 0cdd0ef94a0cf848db6b1448432180962b71d564 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jun 2022 12:51:00 +0300 Subject: [PATCH 0039/1592] Bump actions/setup-python from 3.1.2 to 4.0.0 (#4361) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 3.1.2 to 4.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v3.1.2...v4.0.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index ebce7335e16..80f8fa24db8 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: 3.6 architecture: 'x64' @@ -49,7 +49,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 8388268e170..00f0eb268c7 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: 3.6 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@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 1892634f184..c5b4b52901a 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.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 bd35f81fb1a..ba465324cf4 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From 0add504fef69f7ba20ee697acb18d4c37bfe97c7 Mon Sep 17 00:00:00 2001 From: Rikerfi Date: Fri, 17 Jun 2022 13:07:52 +0300 Subject: [PATCH 0040/1592] Maintenance: XUnitFileWriter code streamlined (#4363) --- src/robot/reporting/xunitwriter.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/robot/reporting/xunitwriter.py b/src/robot/reporting/xunitwriter.py index e0fc2269cf2..48346122a4a 100644 --- a/src/robot/reporting/xunitwriter.py +++ b/src/robot/reporting/xunitwriter.py @@ -39,23 +39,16 @@ def __init__(self, xml_writer): self._writer = xml_writer def start_suite(self, suite): - tests, failures, skipped = self._get_stats(suite.statistics) + stats = suite.statistics # Accessing property only once. attrs = {'name': suite.name, - 'tests': tests, + 'tests': f'{stats.total}', 'errors': '0', - 'failures': failures, - 'skipped': skipped, + 'failures': f'{stats.failed}', + 'skipped': f'{stats.skipped}', 'time': self._time_as_seconds(suite.elapsedtime), 'timestamp' : self._starttime_to_isoformat(suite.starttime)} self._writer.start('testsuite', attrs) - def _get_stats(self, statistics): - return ( - str(statistics.total), - str(statistics.failed), - str(statistics.skipped) - ) - def end_suite(self, suite): if suite.metadata or suite.doc: self._writer.start('properties') @@ -80,7 +73,7 @@ def visit_test(self, test): self._writer.end('testcase') def _time_as_seconds(self, millis): - return '{:.3f}'.format(millis / 1000) + return format(millis / 1000, '.3f') def visit_keyword(self, kw): pass From 4765a88535262df6373e4f8d2111032fc290da85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 14 Jun 2022 17:08:12 +0300 Subject: [PATCH 0041/1592] Fix class name in test file --- atest/testdata/parsing/custom-lang.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atest/testdata/parsing/custom-lang.py b/atest/testdata/parsing/custom-lang.py index f5226472f72..df2f33a5add 100644 --- a/atest/testdata/parsing/custom-lang.py +++ b/atest/testdata/parsing/custom-lang.py @@ -1,7 +1,7 @@ from robot.conf import Language -class Fi(Language): +class Custom(Language): setting_headers = {'H 1'} variable_headers = {'H 2'} test_case_headers = {'H 3'} From 2098470f98836a24b0b130fc6e6e25e46b66074d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 16 Jun 2022 16:05:06 +0300 Subject: [PATCH 0042/1592] Enhance tests for continuable failures with WHILE. Related to #4355. --- atest/robot/running/while/while.robot | 3 +++ atest/testdata/running/while/while.robot | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/atest/robot/running/while/while.robot b/atest/robot/running/while/while.robot index 8df1dc7715e..2182290f6e0 100644 --- a/atest/robot/running/while/while.robot +++ b/atest/robot/running/while/while.robot @@ -29,6 +29,9 @@ Continuable failure in loop Normal failure after continuable failure in loop Check While Loop FAIL 2 +Normal failure outside loop after continuable failures in loop + Check While Loop FAIL 2 + Loop in loop Check While Loop PASS 5 Check While Loop PASS 3 path=body[0].body[0].body[2] diff --git a/atest/testdata/running/while/while.robot b/atest/testdata/running/while/while.robot index 49e2ca96075..cb38b8d1725 100644 --- a/atest/testdata/running/while/while.robot +++ b/atest/testdata/running/while/while.robot @@ -45,11 +45,14 @@ Continuable failure in loop ... 2) Oh no 2! ... ... 3) Oh no 3! + ... + ... 4) Oh no outside loop! [Tags] robot:continue-on-failure WHILE $variable < 4 Fail Oh no ${variable}! ${variable}= Evaluate $variable + 1 END + Fail Oh no outside loop! Normal failure after continuable failure in loop [Documentation] FAIL @@ -64,6 +67,22 @@ Normal failure after continuable failure in loop ${variable}= Evaluate $variable + 1 END +Normal failure outside loop after continuable failures in loop + [Documentation] FAIL + ... Several failures occurred: + ... + ... 1) Oh no 1! + ... + ... 2) Oh no 2! + ... + ... 3) Oh no for real! + WHILE $variable < 3 + Run Keyword And Continue On Failure Fail Oh no ${variable}! + ${variable}= Evaluate $variable + 1 + END + Fail Oh no for real! + Fail Should not be executed. + Loop in loop WHILE $variable < 6 Log Outer ${variable} From 7e08c961739fd7e9fee7fe045bf8d8d5a5d1dc6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Sun, 19 Jun 2022 20:50:24 +0300 Subject: [PATCH 0043/1592] UG: Enhance --timestampoutputs docs. Fixes #4346. --- doc/userguide/src/Appendices/CommandLineOptions.rst | 4 ++-- .../src/ExecutingTestCases/ConfiguringExecution.rst | 6 ++---- doc/userguide/src/ExecutingTestCases/OutputFiles.rst | 8 ++++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/doc/userguide/src/Appendices/CommandLineOptions.rst b/doc/userguide/src/Appendices/CommandLineOptions.rst index 931fa32572e..d8abdaf2d17 100644 --- a/doc/userguide/src/Appendices/CommandLineOptions.rst +++ b/doc/userguide/src/Appendices/CommandLineOptions.rst @@ -38,7 +38,7 @@ Command line options for test execution -r, --report Sets the path to the generated `report file`_. -x, --xunit Sets the path to the generated `xUnit compatible result file`_. -b, --debugfile A `debug file`_ that is written during execution. - -T, --timestampoutputs `Adds a timestamp`_ to all output files. + -T, --timestampoutputs `Adds a timestamp`_ to `output files`_ listed above. --splitlog `Split log file`_ into smaller pieces that open in browser transparently. --logtitle `Sets a title`_ for the generated test log. @@ -109,7 +109,7 @@ Command line options for post-processing outputs -l, --log <file> Sets the path to the generated `log file`_. -r, --report <file> Sets the path to the generated `report file`_. -x, --xunit <file> Sets the path to the generated `xUnit compatible result file`_. - -T, --timestampoutputs `Adds a timestamp`_ to all output files. + -T, --timestampoutputs `Adds a timestamp`_ to `output files`_ listed above. --splitlog `Split log file`_ into smaller pieces that open in browser transparently. --logtitle <title> `Sets a title`_ for the generated test log. diff --git a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst index 21907b6ab6d..1169062a827 100644 --- a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst @@ -3,10 +3,8 @@ Configuring execution This section explains different command line options that can be used for configuring the `test execution`_ or `post-processing -outputs`_. Options related to generated output files are discussed in -the `next section`__. - -__ `Created outputs`_ +outputs`_. Options related to generated `output files`_ are discussed in +the next section. .. contents:: :depth: 2 diff --git a/doc/userguide/src/ExecutingTestCases/OutputFiles.rst b/doc/userguide/src/ExecutingTestCases/OutputFiles.rst index 015beff8574..d0947fd3333 100644 --- a/doc/userguide/src/ExecutingTestCases/OutputFiles.rst +++ b/doc/userguide/src/ExecutingTestCases/OutputFiles.rst @@ -1,5 +1,5 @@ -Created outputs -=============== +Output files +============ Several output files are created when tests are executed, and all of them are somehow related to test results. This section discusses what @@ -165,11 +165,11 @@ Debug files are not created unless the command line option Timestamping output files ~~~~~~~~~~~~~~~~~~~~~~~~~ -All output files listed in this section can be automatically timestamped +All output files generated by Robot Framework itself can be automatically timestamped with the option :option:`--timestampoutputs (-T)`. When this option is used, a timestamp in the format `YYYYMMDD-hhmmss` is placed between the extension and the base name of each file. The example below would, -for example, create such output files as +for example, create output files like :file:`output-20080604-163225.xml` and :file:`mylog-20080604-163225.html`:: robot --timestampoutputs --log mylog.html --report NONE tests.robot From 04db11948955c215c003af24bdf3c2aa6631cd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 19 Jun 2022 21:28:03 +0300 Subject: [PATCH 0044/1592] Fix Libdoc error when library argument matches resource extension. Fixes #4351. --- atest/robot/libdoc/cli.robot | 16 ++++++++++++++++ atest/testdata/libdoc/LibraryArguments.py | 8 ++++++++ src/robot/libdocpkg/builder.py | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 atest/testdata/libdoc/LibraryArguments.py diff --git a/atest/robot/libdoc/cli.robot b/atest/robot/libdoc/cli.robot index b7927c81952..5974c8217dd 100644 --- a/atest/robot/libdoc/cli.robot +++ b/atest/robot/libdoc/cli.robot @@ -21,6 +21,9 @@ Using --specdocformat to specify doc format in output --format XML --specdocformat RAW String ${OUTBASE}.libspec XML String path=${OUTBASE}.libspec --format XML --specdocformat HTML String ${OUTBASE}.libspec LIBSPEC String path=${OUTBASE}.libspec +Library arguments + ${TESTDATADIR}/LibraryArguments.py::required::true ${OUTHTML} HTML LibraryArguments + Library name matching spec extension --pythonpath ${DATADIR}/libdoc LIBPKG.JSON ${OUTXML} XML LIBPKG.JSON path=${OUTXML} [Teardown] Keyword Name Should Be 0 Keyword In Json @@ -29,6 +32,19 @@ Library name matching resource extension --pythonpath ${DATADIR}/libdoc LIBPKG.resource ${OUTXML} XML LIBPKG.resource path=${OUTXML} [Teardown] Keyword Name Should Be 0 Keyword In Resource +Library argument matching resource extension + ${TESTDATADIR}/LibraryArguments.py::required::true::foo.resource ${OUTHTML} HTML LibraryArguments + +Library argument matching resource extension when import fails + [Template] Run libdoc and verify output + NonExisting::foo.resource ${OUTHTML} + ... Importing library 'NonExisting' failed: ModuleNotFoundError: No module named 'NonExisting' + ... Traceback (most recent call last): + ... ${SPACE*2}None + ... PYTHONPATH: + ... * + ... ${USAGE TIP[1:]} + Override name and version --name MyName --version 42 String ${OUTHTML} HTML MyName 42 -n MyName -v 42 -f xml BuiltIn ${OUTHTML} XML MyName 42 diff --git a/atest/testdata/libdoc/LibraryArguments.py b/atest/testdata/libdoc/LibraryArguments.py new file mode 100644 index 00000000000..ad16a7e14bd --- /dev/null +++ b/atest/testdata/libdoc/LibraryArguments.py @@ -0,0 +1,8 @@ +class LibraryArguments: + + def __init__(self, required, args: bool, optional=None): + assert required == 'required' + assert args is True + + def keyword(self): + pass diff --git a/src/robot/libdocpkg/builder.py b/src/robot/libdocpkg/builder.py index 4884bea2095..fbec1a78b63 100644 --- a/src/robot/libdocpkg/builder.py +++ b/src/robot/libdocpkg/builder.py @@ -58,7 +58,8 @@ def _build(builder, source): def _get_extension(source): - return os.path.splitext(source)[1][1:].lower() + path, *args = source.split('::') + return os.path.splitext(path)[1][1:].lower() def DocumentationBuilder(library_or_resource): From 7ac5766f7c9338ac38091dcb294e2c26608c94df Mon Sep 17 00:00:00 2001 From: yanne <janne.harkonen@iki.fi> Date: Wed, 22 Jun 2022 15:31:34 +0300 Subject: [PATCH 0045/1592] Create Languages instance earlier for performance Relates to #4096 --- src/robot/conf/__init__.py | 2 +- src/robot/conf/languages.py | 74 ++++++++++++++++-------------- src/robot/conf/settings.py | 6 ++- src/robot/parsing/lexer/markers.py | 7 ++- 4 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/robot/conf/__init__.py b/src/robot/conf/__init__.py index fa6bde20530..f5ffc583854 100644 --- a/src/robot/conf/__init__.py +++ b/src/robot/conf/__init__.py @@ -24,5 +24,5 @@ Instantiating them is not likely to change, though. """ -from .languages import Language +from .languages import Languages, Language from .settings import RobotSettings, RebotSettings diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 2f04bb70f28..98d63fcaf33 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -19,6 +19,46 @@ from robot.utils import is_string, Importer +class Languages: + + def __init__(self, languages): + self.languages = self._get_languages(languages) + + def _get_languages(self, languages): + languages = self._resolve_languages(languages) + available = {c.__name__.lower(): c for c in Language.__subclasses__()} + returned = [] + for lang in languages: + if lang.lower() in available: + returned.append(available[lang.lower()]) + else: + returned.extend(self._import_languages(lang)) + return [subclass() for subclass in returned] + + def _resolve_languages(self, languages): + if not languages: + languages = [] + if is_string(languages): + languages = [languages] + if 'en' not in languages: + languages.append('en') + return languages + + def _import_languages(self, lang): + def find_subclass(member): + return (inspect.isclass(member) + and issubclass(member, Language) + and member is not Language) + # FIXME: error handling + if os.path.exists(lang): + lang = os.path.abspath(lang) + module = Importer().import_module(lang) + return [value for _, value in inspect.getmembers(module, find_subclass)] + + def __iter__(self): + return iter(self.languages) + + class Language: setting_headers = set() variable_headers = set() @@ -48,40 +88,6 @@ class Language: return_ = None bdd_prefixes = set() # These are not used yet - @classmethod - def get_languages(cls, languages): - languages = cls._resolve_languages(languages) - available = {c.__name__.lower(): c for c in cls.__subclasses__()} - returned = [] - for lang in languages: - if lang.lower() in available: - returned.append(available[lang.lower()]) - else: - returned.extend(cls._import_languages(lang)) - return [subclass() for subclass in returned] - - @classmethod - def _resolve_languages(cls, languages): - if not languages: - languages = [] - if is_string(languages): - languages = [languages] - if 'en' not in languages: - languages.append('en') - return languages - - @classmethod - def _import_languages(cls, lang): - def find_subclass(member): - return (inspect.isclass(member) - and issubclass(member, Language) - and member is not Language) - # FIXME: error handling - if os.path.exists(lang): - lang = os.path.abspath(lang) - module = Importer().import_module(lang) - return [value for _, value in inspect.getmembers(module, find_subclass)] - @property def settings(self): settings = { diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index 607b45edac8..f6ed53ba341 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -31,6 +31,7 @@ seq2str, split_args_from_name_or_path) from .gatherfailed import gather_failed_tests, gather_failed_suites +from .languages import Languages class _BaseSettings: @@ -475,6 +476,7 @@ class RobotSettings(_BaseSettings): 'ConsoleMarkers' : ('consolemarkers', 'AUTO'), 'DebugFile' : ('debugfile', None), 'Language' : ('language', [])} + _languages = None def get_rebot_settings(self): settings = RebotSettings() @@ -503,7 +505,9 @@ def debug_file(self): @property def languages(self): - return self['Language'] + if not self._languages: + self._languages = Languages(self['Language']) + return self._languages @property def suite_config(self): diff --git a/src/robot/parsing/lexer/markers.py b/src/robot/parsing/lexer/markers.py index 29b60b0a1f2..543f5124a3e 100644 --- a/src/robot/parsing/lexer/markers.py +++ b/src/robot/parsing/lexer/markers.py @@ -13,12 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.conf import Language +from robot.conf import Languages class Markers: def __init__(self, languages): + if not isinstance(languages, Languages): + # FIXME: add unit test + languages = Languages(languages) self.setting_headers = set() self.variable_headers = set() self.test_case_headers = set() @@ -26,7 +29,7 @@ def __init__(self, languages): self.keyword_headers = set() self.comment_headers = set() self.settings = {} - for lang in Language.get_languages(languages): + for lang in languages: self.setting_headers |= lang.setting_headers self.variable_headers |= lang.variable_headers self.test_case_headers |= lang.test_case_headers From b72a399a33b241161597c5a3c55091d5707526bf Mon Sep 17 00:00:00 2001 From: yanne <janne.harkonen@iki.fi> Date: Wed, 22 Jun 2022 16:13:35 +0300 Subject: [PATCH 0046/1592] Support localized BDD-prefixes (#519) Add Finnish translations for the prefixes Also make space after BDD/prefix manadatory, fixes #4379 --- .../keywords/optional_given_when_then.robot | 13 ++++++++++++- .../keywords/optional_given_when_then.robot | 16 +++++++++++++++- src/robot/conf/languages.py | 2 +- src/robot/parsing/lexer/markers.py | 1 + src/robot/running/namespace.py | 16 ++++++++++------ 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/atest/robot/keywords/optional_given_when_then.robot b/atest/robot/keywords/optional_given_when_then.robot index 50a5ac48259..3fc6b6b878e 100644 --- a/atest/robot/keywords/optional_given_when_then.robot +++ b/atest/robot/keywords/optional_given_when_then.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} keywords/optional_given_when_then.robot +Suite Setup Run Tests --lang fi keywords/optional_given_when_then.robot Resource atest_resource.robot *** Test Cases *** @@ -46,3 +46,14 @@ Keyword can be used with and without prefix Should Be Equal ${tc.kws[5].name} Then we are in Berlin city Should Be Equal ${tc.kws[6].name} we are in Berlin city +In user keyword name with normal arguments and localized prefixes + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].name} Oletetaan we don't drink too many beers + Should Be Equal ${tc.kws[1].name} Kun we are in + Should Be Equal ${tc.kws[2].name} mutta we don't drink too many beers + Should Be Equal ${tc.kws[3].name} Ja time + Should Be Equal ${tc.kws[4].name} Niin we get this feature ready today + Should Be Equal ${tc.kws[5].name} ja we don't drink too many beers + +Prefix must be followed by space + Check Test Case ${TEST NAME} diff --git a/atest/testdata/keywords/optional_given_when_then.robot b/atest/testdata/keywords/optional_given_when_then.robot index 396280c96a6..b5afa8f374b 100644 --- a/atest/testdata/keywords/optional_given_when_then.robot +++ b/atest/testdata/keywords/optional_given_when_then.robot @@ -42,6 +42,21 @@ Keyword can be used with and without prefix Then we are in Berlin city we are in Berlin city +In user keyword name with normal arguments and localized prefixes + Oletetaan we don't drink too many beers + Kun we are in museum cafe + mutta we don't drink too many beers + Ja time does not run out + Niin we get this feature ready today + ja we don't drink too many beers + +Prefix must be followed by space + [Documentation] FAIL + ... No keyword with name 'Givenwe don't drink too many beers' found. Did you mean: + ... ${SPACE*4}We Don't Drink Too Many Beers + Givenwe don't drink too many beers + + *** Keywords *** We don't drink too many beers No Operation @@ -68,4 +83,3 @@ We ${x} This ${thing} Implemented We Go To ${somewhere} Should Be Equal ${somewhere} walking tour - diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 98d63fcaf33..569459b637c 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -175,4 +175,4 @@ class Fi(Language): timeout = 'Aikaraja' arguments = 'Argumentit' return_ = 'Paluuarvo' - bdd_prefixes = {} + bdd_prefixes = {'Oletetaan', 'Kun', 'Niin', 'Ja', 'Mutta'} diff --git a/src/robot/parsing/lexer/markers.py b/src/robot/parsing/lexer/markers.py index 543f5124a3e..2f16690be13 100644 --- a/src/robot/parsing/lexer/markers.py +++ b/src/robot/parsing/lexer/markers.py @@ -17,6 +17,7 @@ class Markers: + # FIXME: should this be merged with conf.Languages def __init__(self, languages): if not isinstance(languages, Languages): diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 566aac68563..5fb86747a3e 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -43,7 +43,7 @@ def __init__(self, variables, suite, resource, languages): self.variables = variables self.languages = languages self._imports = resource.imports - self._kw_store = KeywordStore(resource) + self._kw_store = KeywordStore(resource, languages) self._imported_variable_files = ImportCache() self._suite_name = suite.longname self._running_test = False @@ -223,11 +223,12 @@ def get_runner(self, name): class KeywordStore: - def __init__(self, resource): + def __init__(self, resource, languages): self.user_keywords = UserLibrary(resource, UserLibrary.TEST_CASE_FILE_TYPE) self.libraries = OrderedDict() self.resources = ImportCache() self.search_order = () + self.languages = languages def get_library(self, name_or_instance): if name_or_instance is None: @@ -294,10 +295,13 @@ def _get_runner(self, name): return runner def _get_bdd_style_runner(self, name): - lower = name.lower() - for prefix in ['given ', 'when ', 'then ', 'and ', 'but ']: - if lower.startswith(prefix): - runner = self._get_runner(name[len(prefix):]) + parts = name.split(maxsplit=1) + if len(parts) == 1: + return None + prefix, keyword = parts + for lang in self.languages: + if prefix.title() in lang.bdd_prefixes: + runner = self._get_runner(keyword) if runner: runner = copy.copy(runner) runner.name = name From 7bd13b285939ea0f6e9ad2cf2dfd294c41b1fd12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 20 Jun 2022 15:38:28 +0300 Subject: [PATCH 0047/1592] Document how to import resource files bundled into Python packages Fixes #4372 --- .../ResourceAndVariableFiles.rst | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst index 0068148cffe..345c25fecb9 100644 --- a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst +++ b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst @@ -25,21 +25,22 @@ Taking resource files into use ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Resource files are imported using the :setting:`Resource` setting in the -Settings section. The path to the resource file is given as an argument -to the setting. When using the `plain text format`__ for creating resource -files, it is possible to use the normal :file:`.robot` extension but the -dedicated :file:`.resource` extension is recommended to separate resource -files from test case files. +Settings section so that the path to the resource file is given as an argument +to the setting. The recommended extension for resource files is +:file:`.resource`, but also the normal :file:`.robot` extension works. -__ `Supported file formats`_ - -If the path is given in an absolute format, it is used directly. In other -cases, the resource file is first searched relatively to the directory +If the resource file path is absolute, it is used directly. Otherwise, +the resource file is first searched relatively to the directory where the importing file is located. If the file is not found there, it is then searched from the directories in Python's `module search path`_. -The path can contain variables, and it is recommended to use them to make paths -system-independent (for example, :file:`${RESOURCES}/login.resource` or -:file:`${RESOURCE_PATH}`). Additionally, forward slashes (`/`) in the path +Searching resource files from the module search path makes it possible to +bundle them into Python packages as `package data`__ and importing +them like :file:`package/example.resource`. + +The resource file path can contain variables, and it is recommended to use +them to make paths system-independent (for example, +:file:`${RESOURCES}/login.resource` or just :file:`${RESOURCE_PATH}`). +Additionally, forward slashes (`/`) in the path are automatically changed to backslashes (:codesc:`\\`) on Windows. .. sourcecode:: robotframework @@ -47,6 +48,7 @@ are automatically changed to backslashes (:codesc:`\\`) on Windows. *** Settings *** Resource example.resource Resource ../data/resources.robot + Resource package/example.resource Resource ${RESOURCES}/common.resource The user keywords and variables defined in a resource file are @@ -57,6 +59,8 @@ resource file. .. note:: The :file:`.resource` extension is new in Robot Framework 3.1. +__ https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#package-data + Resource file structure ~~~~~~~~~~~~~~~~~~~~~~~ From 254b601523a3f7505e487951238e065bc351b7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 22 Jun 2022 14:28:17 +0300 Subject: [PATCH 0048/1592] Don't use using etree.Element in Boolean context. This avoids a deprecation warning. Also f-strings. --- src/robot/libdocpkg/xmlbuilder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/libdocpkg/xmlbuilder.py b/src/robot/libdocpkg/xmlbuilder.py index 2ba05ecb08a..bdc23f07fcd 100644 --- a/src/robot/libdocpkg/xmlbuilder.py +++ b/src/robot/libdocpkg/xmlbuilder.py @@ -38,7 +38,7 @@ def build(self, path): libdoc.inits = self._create_keywords(spec, 'inits/init', libdoc.source) libdoc.keywords = self._create_keywords(spec, 'keywords/kw', libdoc.source) # RF >= 5 have 'typedocs', RF >= 4 have 'datatypes', older/custom may have neither. - if spec.find('typedocs'): + if spec.find('typedocs') is not None: libdoc.type_docs = self._parse_type_docs(spec) else: libdoc.type_docs = self._parse_data_types(spec) @@ -46,11 +46,11 @@ def build(self, path): def _parse_spec(self, path): if not os.path.isfile(path): - raise DataError("Spec file '%s' does not exist." % path) + raise DataError(f"Spec file '{path}' does not exist.") with ETSource(path) as source: root = ET.parse(source).getroot() if root.tag != 'keywordspec': - raise DataError("Invalid spec file '%s'." % path) + raise DataError(f"Invalid spec file '{path}'.") version = root.get('specversion') if version not in ('3', '4'): raise DataError(f"Invalid spec file version '{version}'. " From 326469f69118c939f4479ebbc34d7d21077e1d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 22 Jun 2022 16:41:16 +0300 Subject: [PATCH 0049/1592] Add `AS` alias for `WITH NAME` in library imports. Fixes #4371. --- atest/robot/test_libraries/with_name.robot | 6 ++++- .../testdata/test_libraries/with_name_1.robot | 4 ++-- .../testdata/test_libraries/with_name_2.robot | 17 ++++++++++---- .../testdata/test_libraries/with_name_3.robot | 1 + .../testdata/test_libraries/with_name_4.robot | 19 ++++++++------- .../testlibs/ParameterLibrary.py | 5 ++-- .../CreatingTestData/UsingTestLibraries.rst | 23 +++++++++++-------- src/robot/libraries/BuiltIn.py | 2 +- src/robot/parsing/lexer/settings.py | 2 +- src/robot/utils/escaping.py | 2 +- utest/utils/test_escaping.py | 2 +- 11 files changed, 51 insertions(+), 32 deletions(-) diff --git a/atest/robot/test_libraries/with_name.robot b/atest/robot/test_libraries/with_name.robot index cc1e85afe48..ec4f765b94c 100644 --- a/atest/robot/test_libraries/with_name.robot +++ b/atest/robot/test_libraries/with_name.robot @@ -112,8 +112,12 @@ With Name When Library Arguments Are Not Strings Syslog Should Contain Imported library 'ParameterLibrary' with arguments [ 1 | 2 ] 'WITH NAME' is case-sensitive - Error In File -1 test_libraries/with_name_3.robot 5 + # WITH NAME + Error In File -2 test_libraries/with_name_3.robot 5 ... Library 'ParameterLibrary' expected 0 to 2 arguments, got 4. + # AS + Error In File -1 test_libraries/with_name_3.robot 6 + ... Library 'ParameterLibrary' expected 0 to 2 arguments, got 5. 'WITH NAME' cannot come from variable Check Test Case ${TEST NAME} diff --git a/atest/testdata/test_libraries/with_name_1.robot b/atest/testdata/test_libraries/with_name_1.robot index dbadf142b88..f9e8a443b22 100644 --- a/atest/testdata/test_libraries/with_name_1.robot +++ b/atest/testdata/test_libraries/with_name_1.robot @@ -1,9 +1,9 @@ *** Settings *** Library OperatingSystem Library ParameterLibrary before1 before2 -Library ParameterLibrary before1with before2with WITH NAME Params +Library ParameterLibrary before1with before2with AS Params Library libraryscope.Global WITH NAME GlobalScope -Library libraryscope.Suite WITH NAME Suite Scope +Library libraryscope.Suite AS Suite Scope Library libraryscope.Test WITH NAME TEST SCOPE Library ParameterLibrary ${1} 2 diff --git a/atest/testdata/test_libraries/with_name_2.robot b/atest/testdata/test_libraries/with_name_2.robot index 24ba0e8c128..eb7c446818f 100644 --- a/atest/testdata/test_libraries/with_name_2.robot +++ b/atest/testdata/test_libraries/with_name_2.robot @@ -2,18 +2,18 @@ Library OperatingSystem WITH NAME OS Library ParameterLibrary 1 2 WITH NAME Param1 Library ParameterLibrary ${VAR} ${42} WITH NAME Param2 -Library ParameterLibrary a b WITH NAME ${VAR} +Library ParameterLibrary a b AS ${VAR} Library ParameterLibrary whatever WITH NAME Library BuiltIn WITH NAME B2 -Library module_library WITH NAME MOD1 +Library module_library AS MOD1 Library pythonmodule.library WITH NAME mod 2 Library MyLibFile.py WITH NAME Params -Library Embedded.py WITH NAME Embedded1 +Library Embedded.py AS Embedded1 Library Embedded.py WITH NAME Embedded2 Library RunKeywordLibrary WITH NAME dynamic -Library libraryscope.Global WITH NAME G Scope +Library libraryscope.Global AS G Scope Library libraryscope.Suite WITH NAME S Scope -Library libraryscope.Test WITH NAME T Scope +Library libraryscope.Test AS T Scope *** Variables *** ${VAR} VAR @@ -62,15 +62,22 @@ Name Given Using "With Name" Can Be Reused In Different Suites Para MS.Keyword In My Lib File Import Library Keyword + # WITH NAME BuiltIn.Import Library OperatingSystem WITH NAME MyOS MyOS.Directory Should Exist . B2.Import Library ParameterLibrary my first argument second arg WITH NAME MyParamLib My Param Lib.Parameters should be my first argument second arg + # AS + BuiltIn.Import Library OperatingSystem AS MyAS + MyAS.Directory Should Exist . + B2.Import Library ParameterLibrary my first argument second arg AS MyParamLibAs + My Param LibAs.Parameters should be my first argument second arg Correct Error When Using Keyword From Same Library With Different Names Without Prefix 2 [Documentation] FAIL Multiple keywords with name 'Parameters' found. \ ... Give the full name of the keyword you want to use: ... ${SPACE*4}MyParamLib.Parameters + ... ${SPACE*4}MyParamLibAs.Parameters ... ${SPACE*4}Param1.Parameters ... ${SPACE*4}Param2.Parameters ... ${SPACE*4}ParameterLibrary.Parameters diff --git a/atest/testdata/test_libraries/with_name_3.robot b/atest/testdata/test_libraries/with_name_3.robot index 495641a845f..416efe97b63 100644 --- a/atest/testdata/test_libraries/with_name_3.robot +++ b/atest/testdata/test_libraries/with_name_3.robot @@ -3,6 +3,7 @@ Library OperatingSystem Library ParameterLibrary after1with after2with WITH NAME Params Library ParameterLibrary after1 after2 Library ParameterLibrary xxx yyy with name Won't work +Library ParameterLibrary xxx yyy zzz as Won't work *** Test Cases *** Import Library Normally After Importing With Name In Another Suite diff --git a/atest/testdata/test_libraries/with_name_4.robot b/atest/testdata/test_libraries/with_name_4.robot index 09cb7375e51..0fad26dd437 100644 --- a/atest/testdata/test_libraries/with_name_4.robot +++ b/atest/testdata/test_libraries/with_name_4.robot @@ -1,23 +1,26 @@ *** Settings *** Library ParameterLibrary.V1 ${WITH NAME} foo -Library ParameterLibrary.V2 @{LIST WITH NAME} +Library ParameterLibrary.V2 @{LIST AS} *** Variables *** -${WITH NAME} WITH NAME -@{LIST WITH NAME} WITH NAME bar +${WITH NAME} WITH NAME +@{LIST AS} AS bar *** Test Cases *** 'WITH NAME' cannot come from variable ParameterLibrary.V1.Parameters should be WITH NAME foo - ParameterLibrary.V2.Parameters should be WITH NAME bar + ParameterLibrary.V2.Parameters should be AS bar 'WITH NAME' cannot come from variable with 'Import Library' keyword Import Library ParameterLibrary.V3 ${WITH NAME} zap - Import Library ParameterLibrary.V4 @{LIST WITH NAME} + Import Library ParameterLibrary.V4 @{LIST AS} ParameterLibrary.V3.Parameters should be WITH NAME zap - ParameterLibrary.V4.Parameters should be WITH NAME bar + ParameterLibrary.V4.Parameters should be AS bar 'WITH NAME' cannot come from variable with 'Import Library' keyword even when list variable opened - @{must open to find name} = Create List Import Library ParameterLibrary.V5 @{LIST WITH NAME} + @{must open to find name} = Create List Import Library ParameterLibrary.V5 ${WITH NAME} foo Run Keyword @{must open to find name} - ParameterLibrary.V5.Parameters should be WITH NAME bar + ParameterLibrary.V5.Parameters should be WITH NAME foo + @{must open to find name} = Create List Import Library ParameterLibrary.V6 @{LIST AS} + Run Keyword @{must open to find name} + ParameterLibrary.V6.Parameters should be AS bar diff --git a/atest/testresources/testlibs/ParameterLibrary.py b/atest/testresources/testlibs/ParameterLibrary.py index f2ef2fcb743..f3d314fb4ee 100644 --- a/atest/testresources/testlibs/ParameterLibrary.py +++ b/atest/testresources/testlibs/ParameterLibrary.py @@ -2,11 +2,11 @@ class ParameterLibrary: - + def __init__(self, host='localhost', port='8080'): self.host = host self.port = port - + def parameters(self): return self.host, self.port @@ -21,3 +21,4 @@ class V2(ParameterLibrary): pass class V3(ParameterLibrary): pass class V4(ParameterLibrary): pass class V5(ParameterLibrary): pass +class V6(ParameterLibrary): pass diff --git a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst index f8779bfca0b..8fad3937875 100644 --- a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst +++ b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst @@ -145,32 +145,31 @@ __ `Handling keywords with same names`_ - The library name is misleading or otherwise poor. In this case, changing the actual name is, of course, a better solution. - The basic syntax for specifying the new name is having the text -`WITH NAME` (case-sensitive) after the library name and then -having the new name in the next cell. The specified name is shown in +`AS` (case-sensitive) after the library name and then +having the new name after that. The specified name is shown in logs and must be used in the test data when using keywords' full name (:name:`LibraryName.Keyword Name`). .. sourcecode:: robotframework *** Settings *** - Library com.company.TestLib WITH NAME TestLib - Library ${LIBRARY} WITH NAME MyName + Library packagename.TestLib AS TestLib + Library ${LIBRARY} AS MyName -Possible arguments to the library are placed into cells between the -original library name and the `WITH NAME` text. The following example +Possible arguments to the library are placed between the +original library name and the `AS` marker. The following example illustrates how the same library can be imported several times with different arguments: .. sourcecode:: robotframework *** Settings *** - Library SomeLibrary localhost 1234 WITH NAME LocalLib - Library SomeLibrary server.domain 8080 WITH NAME RemoteLib + Library SomeLibrary localhost 1234 AS LocalLib + Library SomeLibrary server.domain 8080 AS RemoteLib *** Test Cases *** - My Test + Example LocalLib.Some Keyword some arg second arg RemoteLib.Some Keyword another arg whatever LocalLib.Another Keyword @@ -178,6 +177,10 @@ different arguments: Setting a custom name to a test library works both when importing a library in the Setting section and when using the :name:`Import Library` keyword. +.. note:: Prior to Robot Framework 5.1 the marker to use when giving a custom name + to a library was `WITH NAME` instead of `AS`. The old syntax continues + to work, but it is considered deprecated and will eventually be removed. + Standard libraries ------------------ diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index f815ad3d3d3..8d5277f3210 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -3129,7 +3129,7 @@ def import_library(self, name, *args): raise RuntimeError(str(err)) def _split_alias(self, args): - if len(args) > 1 and normalize_whitespace(args[-2]) == 'WITH NAME': + if len(args) > 1 and normalize_whitespace(args[-2]) in ('WITH NAME', 'AS'): return args[:-2], args[-1] return args, None diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 9780f7d3007..787e0b4bc97 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -118,7 +118,7 @@ def _lex_name_and_arguments(self, tokens): def _lex_name_arguments_and_with_name(self, tokens): self._lex_name_and_arguments(tokens) if len(tokens) > 1 and \ - normalize_whitespace(tokens[-2].value) == 'WITH NAME': + normalize_whitespace(tokens[-2].value) in ('WITH NAME', 'AS'): tokens[-2].type = Token.WITH_NAME tokens[-1].type = Token.NAME diff --git a/src/robot/utils/escaping.py b/src/robot/utils/escaping.py index d394571f241..7656aeb6461 100644 --- a/src/robot/utils/escaping.py +++ b/src/robot/utils/escaping.py @@ -18,7 +18,7 @@ from .robottypes import is_string -_CONTROL_WORDS = frozenset(('ELSE', 'ELSE IF', 'AND', 'WITH NAME')) +_CONTROL_WORDS = frozenset(('ELSE', 'ELSE IF', 'AND', 'WITH NAME', 'AS')) _SEQUENCES_TO_BE_ESCAPED = ('\\', '${', '@{', '%{', '&{', '*{', '=') diff --git a/utest/utils/test_escaping.py b/utest/utils/test_escaping.py index 12e3508a2f0..64d067a6c1b 100644 --- a/utest/utils/test_escaping.py +++ b/utest/utils/test_escaping.py @@ -157,7 +157,7 @@ def test_escape(self): assert_equal(escape(inp), exp, inp) def test_escape_control_words(self): - for inp in ['ELSE', 'ELSE IF', 'AND']: + for inp in ['ELSE', 'ELSE IF', 'AND', 'WITH NAME', 'AS']: assert_equal(escape(inp), '\\' + inp) assert_equal(escape(inp.lower()), inp.lower()) assert_equal(escape('other' + inp), 'other' + inp) From a8b980d78f198d7d5eaa782260364853b1f6f6ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 22 Jun 2022 20:05:52 +0300 Subject: [PATCH 0050/1592] Fix bug introduced in b72a399a33b241161597c5a3c55091d5707526bf --- src/robot/running/namespace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 5fb86747a3e..5421c33303c 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -296,7 +296,7 @@ def _get_runner(self, name): def _get_bdd_style_runner(self, name): parts = name.split(maxsplit=1) - if len(parts) == 1: + if len(parts) < 2: return None prefix, keyword = parts for lang in self.languages: From f387f4911bbcb8ad15baff0eab90bac478ee71f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 23 Jun 2022 03:36:07 +0300 Subject: [PATCH 0051/1592] Deprecate using '-tag' syntax with [Tags]. #4380 Documentation still missing. --- atest/resources/atest_resource.robot | 4 +-- atest/robot/tags/-tag_syntax.robot | 36 +++++++++++++++++++++++ atest/testdata/tags/-tag_syntax.resource | 4 +++ atest/testdata/tags/-tag_syntax.robot | 26 ++++++++++++++++ src/robot/running/builder/transformers.py | 15 ++++++++++ 5 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 atest/robot/tags/-tag_syntax.robot create mode 100644 atest/testdata/tags/-tag_syntax.resource create mode 100644 atest/testdata/tags/-tag_syntax.robot diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index cfdaeed6553..93451d51919 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -349,7 +349,7 @@ Reset PYTHONPATH Error in file [Arguments] ${index} ${path} ${lineno} @{message} ${traceback}= - ... ${stacktrace}= ${pattern}=True + ... ${stacktrace}= ${pattern}=True ${level}=ERROR ${path} = Join Path ${DATADIR} ${path} ${message} = Catenate @{message} ${error} = Set Variable Error in file '${path}' on line ${lineno}: ${message} @@ -359,7 +359,7 @@ Error in file ${error} = Set Variable If $stacktrace ... ${error}\n*${stacktrace}* ... ${error} - Check Log Message ${ERRORS}[${index}] ${error} level=ERROR pattern=${pattern} + Check Log Message ${ERRORS}[${index}] ${error} level=${level} pattern=${pattern} Error in library [Arguments] ${name} @{message} ${pattern}=False ${index}=0 diff --git a/atest/robot/tags/-tag_syntax.robot b/atest/robot/tags/-tag_syntax.robot new file mode 100644 index 00000000000..446960b45f0 --- /dev/null +++ b/atest/robot/tags/-tag_syntax.robot @@ -0,0 +1,36 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} tags/-tag_syntax.robot +Resource atest_resource.robot + +*** Test Cases *** +Deprecation warning with test + Check Test Tags Deprecation warning -literal-with-force -warn-with-test + Check Deprecation Warning 0 tags/-tag_syntax.robot 11 -warn-with-test + +Deprecation warning with keyword + ${tc} = Check Test Case Deprecation warning + Check Keyword Data ${tc.kws[0]} Keyword tags=-warn-with-keyword + Check Deprecation Warning 1 tags/-tag_syntax.robot 25 -warn-with-keyword + +Deprecation warning with keyword in resource + ${tc} = Check Test Case Deprecation warning + Check Keyword Data ${tc.kws[1]} -tag_syntax.Keyword In Resource tags=-warn-with-keyword-in-resource + Check Deprecation Warning 2 tags/-tag_syntax.resource 3 -warn-with-keyword-in-resource + +No deprecation warning from Settings, when escaped, or with variables + Length Should Be ${ERRORS} 3 + +Escaped + Check Test Tags ${TESTNAME} -literal-escaped -literal-with-force + +Variable + Check Test Tags ${TESTNAME} -literal-with-force -literal-with-variable + +*** Keywords *** +Check Deprecation Warning + [Arguments] ${index} ${source} ${lineno} ${tag} + Error in file ${index} ${source} ${lineno} + ... Settings tags starting with a hyphen using the '[Tags]' setting is deprecated. + ... In Robot Framework 5.2 this syntax will be used for removing tags. + ... Escape '${tag}' like '\\${tag}' to use the literal value and to avoid this warning. + ... level=WARN pattern=False diff --git a/atest/testdata/tags/-tag_syntax.resource b/atest/testdata/tags/-tag_syntax.resource new file mode 100644 index 00000000000..32730617134 --- /dev/null +++ b/atest/testdata/tags/-tag_syntax.resource @@ -0,0 +1,4 @@ +*** Keywords *** +Keyword In Resource + [Tags] -warn-with-keyword-in-resource + No Operation diff --git a/atest/testdata/tags/-tag_syntax.robot b/atest/testdata/tags/-tag_syntax.robot new file mode 100644 index 00000000000..438e51b1fcc --- /dev/null +++ b/atest/testdata/tags/-tag_syntax.robot @@ -0,0 +1,26 @@ +*** Settings *** +Force Tags -literal-with-force +Default Tags -literal-with-default +Resource -tag_syntax.resource + +*** Variables *** +${TAG} -literal-with-variable + +*** Test Cases *** +Deprecation warning + [Tags] -warn-with-test + Keyword + Keyword In Resource + +Escaped + [Tags] \-literal-escaped + No Operation + +Variable + [Tags] ${TAG} + No Operation + +*** Keywords *** +Keyword + [Tags] -warn-with-keyword + No Operation diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 58efbdabb60..0609373f026 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -15,6 +15,7 @@ from ast import NodeVisitor +from robot.output import LOGGER from robot.variables import VariableIterator from .testsettings import TestSettings @@ -218,6 +219,7 @@ def visit_Timeout(self, node): self.settings.timeout = node.value def visit_Tags(self, node): + deprecate_tags_starting_with_hyphen(node, self.suite.source) self.settings.tags = node.values def visit_Template(self, node): @@ -264,6 +266,7 @@ def visit_Arguments(self, node): % format_error(node.errors)) def visit_Tags(self, node): + deprecate_tags_starting_with_hyphen(node, self.resource.source) self.kw.tags = node.values def visit_Return(self, node): @@ -550,3 +553,15 @@ def format_error(errors): if len(errors) == 1: return errors[0] return '\n- '.join(('Multiple errors:',) + errors) + + +def deprecate_tags_starting_with_hyphen(node, source): + for tag in node.values: + if tag.startswith('-'): + LOGGER.warn( + f"Error in file '{source}' on line {node.lineno}: " + f"Settings tags starting with a hyphen using the '[Tags]' setting " + f"is deprecated. In Robot Framework 5.2 this syntax will be used " + f"for removing tags. Escape '{tag}' like '\\{tag}' to use the " + f"literal value and to avoid this warning." + ) From 78a3c9c31761d1a0df1fa4aa4107b4e5dd06fa7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 23 Jun 2022 03:53:45 +0300 Subject: [PATCH 0052/1592] cleanup --- src/robot/running/builder/transformers.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 0609373f026..e445a717791 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -247,14 +247,11 @@ class KeywordBuilder(NodeVisitor): def __init__(self, resource): self.resource = resource self.kw = None - self.teardown = None def visit_Keyword(self, node): - self.kw = self.resource.keywords.create(name=node.name, - lineno=node.lineno) + self.kw = self.resource.keywords.create(name=node.name, lineno=node.lineno) self.generic_visit(node) - if self.teardown is not None: - self.kw.teardown.config(**self.teardown) + def visit_Documentation(self, node): self.kw.doc = node.value @@ -262,8 +259,8 @@ def visit_Documentation(self, node): def visit_Arguments(self, node): self.kw.args = node.values if node.errors: - self.kw.error = ('Invalid argument specification: %s' - % format_error(node.errors)) + error = format_error(node.errors) + self.kw.error = f'Invalid argument specification: {error}' def visit_Tags(self, node): deprecate_tags_starting_with_hyphen(node, self.resource.source) @@ -276,9 +273,8 @@ def visit_Timeout(self, node): self.kw.timeout = node.value def visit_Teardown(self, node): - self.teardown = { - 'name': node.name, 'args': node.args, 'lineno': node.lineno - } + self.kw.teardown.config(name=node.name, args=node.args, + lineno=node.lineno) def visit_KeywordCall(self, node): self.kw.body.create_keyword(name=node.keyword, args=node.args, From dc6091b4cd86d4270e82b9a9371dce2233d8956f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Jun 2022 17:49:51 +0300 Subject: [PATCH 0053/1592] Add 'Test/Task Tags' as alias for 'Force Tags'. #4368 Documentation missing. --- atest/robot/rpa/run_rpa_tasks.robot | 2 +- ...plate_timeout.robot => task_aliases.robot} | 8 ++++---- atest/robot/tags/test_tags.robot | 19 +++++++++++++++++++ ...plate_timeout.robot => task_aliases.robot} | 2 ++ atest/testdata/tags/test_tags.robot | 10 ++++++++++ ...d_force_tags_cannot_be_used_together.robot | 11 +++++++++++ src/robot/parsing/lexer/settings.py | 2 ++ 7 files changed, 49 insertions(+), 5 deletions(-) rename atest/robot/rpa/{task_setup_teardown_template_timeout.robot => task_aliases.robot} (88%) create mode 100644 atest/robot/tags/test_tags.robot rename atest/testdata/rpa/{task_setup_teardown_template_timeout.robot => task_aliases.robot} (93%) create mode 100644 atest/testdata/tags/test_tags.robot create mode 100644 atest/testdata/tags/test_tags_and_force_tags_cannot_be_used_together.robot diff --git a/atest/robot/rpa/run_rpa_tasks.robot b/atest/robot/rpa/run_rpa_tasks.robot index cd64a3a7221..30e5a9d0ae7 100644 --- a/atest/robot/rpa/run_rpa_tasks.robot +++ b/atest/robot/rpa/run_rpa_tasks.robot @@ -29,7 +29,7 @@ Conflicting headers cause error [Template] Run and validate conflict rpa/tests.robot rpa/tasks rpa/tasks/stuff.robot tasks tests rpa/ rpa/tests.robot tests tasks - ... [[] ERROR ] Error in file '*[/\\]task_setup_teardown_template_timeout.robot' on line 6: + ... [[] ERROR ] Error in file '*[/\\]task_aliases.robot' on line 7: ... Non-existing setting 'Tesk Setup'. Did you mean:\n ... ${SPACE*3}Test Setup\n ... ${SPACE*3}Task Setup\n diff --git a/atest/robot/rpa/task_setup_teardown_template_timeout.robot b/atest/robot/rpa/task_aliases.robot similarity index 88% rename from atest/robot/rpa/task_setup_teardown_template_timeout.robot rename to atest/robot/rpa/task_aliases.robot index 1df6f414510..5b889449ce2 100644 --- a/atest/robot/rpa/task_setup_teardown_template_timeout.robot +++ b/atest/robot/rpa/task_aliases.robot @@ -1,10 +1,10 @@ *** Settings *** -Suite Setup Run Tests --loglevel DEBUG rpa/task_setup_teardown_template_timeout.robot +Suite Setup Run Tests --loglevel DEBUG rpa/task_aliases.robot Resource atest_resource.robot *** Test Cases *** Defaults - ${tc} = Check Test Case ${TESTNAME} + ${tc} = Check Test Tags ${TESTNAME} task tags Check timeout message ${tc.setup.msgs[0]} 1 minute 10 seconds Check log message ${tc.setup.msgs[1]} Setup has an alias! Check timeout message ${tc.kws[0].msgs[0]} 1 minute 10 seconds @@ -13,7 +13,7 @@ Defaults Should be equal ${tc.timeout} 1 minute 10 seconds Override - ${tc} = Check Test Case ${TESTNAME} + ${tc} = Check Test Tags ${TESTNAME} task tags own Check log message ${tc.setup.msgs[0]} Overriding setup Check log message ${tc.kws[0].msgs[0]} Overriding settings Check log message ${tc.teardown.msgs[0]} Overriding teardown as well @@ -29,7 +29,7 @@ Invalid task timeout Task aliases are included in setting recommendations Error In File - ... 0 rpa/task_setup_teardown_template_timeout.robot 6 + ... 0 rpa/task_aliases.robot 7 ... SEPARATOR=\n ... Non-existing setting 'Tesk Setup'. Did you mean: ... ${SPACE*4}Test Setup diff --git a/atest/robot/tags/test_tags.robot b/atest/robot/tags/test_tags.robot new file mode 100644 index 00000000000..c905a277167 --- /dev/null +++ b/atest/robot/tags/test_tags.robot @@ -0,0 +1,19 @@ +*** Setting *** +Suite Setup Run Tests ${EMPTY} tags/force_tags.robot +Resource atest_resource.robot + +*** Test Cases *** +Test Tags + Run Tests ${EMPTY} tags/test_tags.robot + Check Test Tags No own tags tags test + Check Test Tags Own tags tags test own + Should Be Empty ${ERRORS} + + +Test Tags and Force Tags cannot be used together + Run Tests ${EMPTY} tags/test_tags_and_force_tags_cannot_be_used_together.robot + Check Test Tags No own tags tags test + Check Test Tags Own tags tags test own + Error In File 0 tags/test_tags_and_force_tags_cannot_be_used_together.robot 3 + ... Setting 'Force Tags' is allowed only once. Only the first value is used. + diff --git a/atest/testdata/rpa/task_setup_teardown_template_timeout.robot b/atest/testdata/rpa/task_aliases.robot similarity index 93% rename from atest/testdata/rpa/task_setup_teardown_template_timeout.robot rename to atest/testdata/rpa/task_aliases.robot index bf18f151eae..73bdf2bbc7b 100644 --- a/atest/testdata/rpa/task_setup_teardown_template_timeout.robot +++ b/atest/testdata/rpa/task_aliases.robot @@ -1,4 +1,5 @@ *** Settings *** +Task Tags task tags Task Setup Log Setup has an alias! Task Teardown Log Also teardown has an alias!! Task Template Log @@ -14,6 +15,7 @@ Defaults Using default settings Override + [Tags] own tags [Setup] Log Overriding setup [Template] NONE [Timeout] NONE diff --git a/atest/testdata/tags/test_tags.robot b/atest/testdata/tags/test_tags.robot new file mode 100644 index 00000000000..253e043b83b --- /dev/null +++ b/atest/testdata/tags/test_tags.robot @@ -0,0 +1,10 @@ +*** Settings *** +Test Tags test tags + +*** Test Cases *** +No own tags + No Operation + +Own tags + [Tags] own tags + No Operation diff --git a/atest/testdata/tags/test_tags_and_force_tags_cannot_be_used_together.robot b/atest/testdata/tags/test_tags_and_force_tags_cannot_be_used_together.robot new file mode 100644 index 00000000000..f0446fa1400 --- /dev/null +++ b/atest/testdata/tags/test_tags_and_force_tags_cannot_be_used_together.robot @@ -0,0 +1,11 @@ +*** Settings *** +Test Tags test tags +Force Tags ignored + +*** Test Cases *** +No own tags + No Operation + +Own tags + [Tags] own tags + No Operation diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 787e0b4bc97..4bfb0096803 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -148,6 +148,8 @@ class TestCaseFileSettings(Settings): 'Task Teardown': 'Test Teardown', 'Task Template': 'Test Template', 'Task Timeout': 'Test Timeout', + 'Test Tags': 'Force Tags', + 'Task Tags': 'Force Tags', } From adfe8ce1f31e77e354130ad854eb939a9c8bbafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Jun 2022 17:51:35 +0300 Subject: [PATCH 0054/1592] Cleanup --- atest/resources/atest_resource.robot | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index 93451d51919..c0ae3252ef1 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -84,9 +84,10 @@ Get Execution Arguments Set Execution Environment Remove Directory ${OUTDIR} recursive Create Directory ${OUTDIR} - Return From Keyword If not ${SET SYSLOG} - Set Environment Variable ROBOT_SYSLOG_FILE ${SYSLOG FILE} - Set Environment Variable ROBOT_SYSLOG_LEVEL ${SYSLOG LEVEL} + IF ${SET SYSLOG} + Set Environment Variable ROBOT_SYSLOG_FILE ${SYSLOG FILE} + Set Environment Variable ROBOT_SYSLOG_LEVEL ${SYSLOG LEVEL} + END Copy Previous Outfile Copy File ${OUTFILE} ${OUTFILE COPY} @@ -94,7 +95,8 @@ Copy Previous Outfile Check Test Suite [Arguments] ${name} ${message} ${status}=${None} ${suite} = Get Test Suite ${name} - Run Keyword If $status is not None Should Be Equal ${suite.status} ${status} + IF $status is not None + ... Should Be Equal ${suite.status} ${status} Should Be Equal ${suite.full_message} ${message} [Return] ${suite} @@ -142,7 +144,7 @@ All Keywords Should Have Passed Get Output File [Arguments] ${path} [Documentation] Output encoding avare helper - ${encoding} = Set Variable If r'${path}' in [r'${STDERR FILE}',r'${STDOUT FILE}'] SYSTEM UTF-8 + ${encoding} = Set Variable If r'${path}' in [r'${STDERR FILE}', r'${STDOUT FILE}'] SYSTEM UTF-8 ${file} = Get File ${path} ${encoding} [Return] ${file} @@ -305,7 +307,7 @@ Elapsed Time Should Be Valid Should Be True isinstance($time, int) Not valid elapsed time: ${time} # On CI elapsed time has sometimes been negative. We cannot control system time there, # so better to log a warning than fail the test in that case. - Run Keyword If $time < 0 + IF $time < 0 ... Log Negative elapsed time '${time}'. Someone messing with system time? WARN Previous test should have passed From de8dbbb75a7f9f6654634fc8018e1c9b84fd4b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Jun 2022 18:01:22 +0300 Subject: [PATCH 0055/1592] Add ExecutionErrors.__str__ --- src/robot/result/executionerrors.py | 7 +++++++ utest/result/test_executionerrors.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 utest/result/test_executionerrors.py diff --git a/src/robot/result/executionerrors.py b/src/robot/result/executionerrors.py index cec7a0e39c5..2d940596a94 100644 --- a/src/robot/result/executionerrors.py +++ b/src/robot/result/executionerrors.py @@ -47,3 +47,10 @@ def __len__(self): def __getitem__(self, index): return self.messages[index] + + def __str__(self): + if not self: + return 'No execution errors' + if len(self) == 1: + return f'Execution error: {self[0]}' + return '\n'.join(['Execution errors:'] + ['- ' + str(m) for m in self]) diff --git a/utest/result/test_executionerrors.py b/utest/result/test_executionerrors.py new file mode 100644 index 00000000000..27066543350 --- /dev/null +++ b/utest/result/test_executionerrors.py @@ -0,0 +1,22 @@ +import unittest + +from robot.result.executionerrors import ExecutionErrors, Message +from robot.utils.asserts import assert_equal + + +class TestExecutionErrors(unittest.TestCase): + + def test_str_without_messages(self): + assert_equal(str(ExecutionErrors()), 'No execution errors') + + def test_str_with_one_message(self): + assert_equal(str(ExecutionErrors([Message('Only one')])), + 'Execution error: Only one') + + def test_str_with_multiple_messages(self): + assert_equal(str(ExecutionErrors([Message('1st'), Message('2nd')])), + 'Execution errors:\n- 1st\n- 2nd') + + +if __name__ == '__main__': + unittest.main() From 46e19ab452b83a39423bfe4d0bd3ed0893d77ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 29 Jun 2022 00:43:48 +0300 Subject: [PATCH 0056/1592] Support setting aliases in init files - Support new Test Tags. Part of #4368. - Support task aliases. Fixes #4384. --- atest/robot/rpa/run_rpa_tasks.robot | 14 +++++++------- atest/robot/rpa/task_aliases.robot | 14 ++++++++++++++ atest/robot/tags/test_tags.robot | 6 +++++- atest/testdata/rpa/tasks/__init__.robot | 6 ++++++ atest/testdata/rpa/tasks/aliases.robot | 13 +++++++++++++ atest/testdata/tags/directory/__init__.robot | 2 ++ atest/testdata/tags/directory/tests.robot | 10 ++++++++++ src/robot/parsing/lexer/settings.py | 11 +++++++++-- 8 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 atest/testdata/rpa/tasks/__init__.robot create mode 100644 atest/testdata/rpa/tasks/aliases.robot create mode 100644 atest/testdata/tags/directory/__init__.robot create mode 100644 atest/testdata/tags/directory/tests.robot diff --git a/atest/robot/rpa/run_rpa_tasks.robot b/atest/robot/rpa/run_rpa_tasks.robot index 30e5a9d0ae7..e8504351d21 100644 --- a/atest/robot/rpa/run_rpa_tasks.robot +++ b/atest/robot/rpa/run_rpa_tasks.robot @@ -5,7 +5,7 @@ Test Template Run and validate RPA tasks Resource atest_resource.robot *** Variables *** -@{ALL TASKS} Task Another task Task Failing Passing Test +@{ALL TASKS} Defaults Override Task Another task Task Failing Passing Test ... Defaults Override Task timeout exceeded Invalid task timeout *** Test Cases *** @@ -16,30 +16,30 @@ Task header in multiple files ${EMPTY} rpa/tasks1.robot rpa/tasks2.robot Task Failing Passing Task header in directory - ${EMPTY} rpa/tasks Task Another task + ${EMPTY} rpa/tasks Task Another task Defaults Override Test header with --rpa --rpa rpa/tests.robot Test Task header with --norpa [Template] Run and validate test cases - --norpa rpa/tasks Task Another task + --norpa rpa/tasks Task Another task Defaults Override Conflicting headers cause error [Template] Run and validate conflict - rpa/tests.robot rpa/tasks rpa/tasks/stuff.robot tasks tests - rpa/ rpa/tests.robot tests tasks + rpa/tests.robot rpa/tasks rpa/tasks/aliases.robot tasks tests + rpa/ rpa/tests.robot tests tasks ... [[] ERROR ] Error in file '*[/\\]task_aliases.robot' on line 7: ... Non-existing setting 'Tesk Setup'. Did you mean:\n ... ${SPACE*3}Test Setup\n ... ${SPACE*3}Task Setup\n Conflicting headers with --rpa are fine - --RPA rpa/tasks rpa/tests.robot Task Another task Test + --RPA rpa/tasks rpa/tests.robot Task Another task Defaults Override Test Conflicting headers with --norpa are fine [Template] Run and validate test cases - --NorPA -v TIMEOUT:Test rpa/ @{ALL TASKS} + --NorPA -v TIMEOUT:Test rpa/ @{ALL TASKS} Conflicting headers in same file cause error [Documentation] Using --rpa or --norpa doesn't affect the behavior. diff --git a/atest/robot/rpa/task_aliases.robot b/atest/robot/rpa/task_aliases.robot index 5b889449ce2..70d856b23f1 100644 --- a/atest/robot/rpa/task_aliases.robot +++ b/atest/robot/rpa/task_aliases.robot @@ -42,6 +42,20 @@ Task settings are not allowed in resource file 3 4 Task Template 4 5 Task Timeout +In init file + Run Tests --loglevel DEBUG rpa/tasks + ${tc} = Check Test Tags Defaults file tag task tags + Check timeout message ${tc.setup.msgs[0]} 1 minute 10 seconds + Check log message ${tc.setup.msgs[1]} Setup has an alias! + Check timeout message ${tc.body[0].msgs[0]} 1 minute 10 seconds + Check log message ${tc.teardown.msgs[0]} Also teardown has an alias!! + Should be equal ${tc.timeout} 1 minute 10 seconds + ${tc} = Check Test Tags Override file tag task tags own + Check log message ${tc.setup.msgs[0]} Overriding setup + Check log message ${tc.teardown.msgs[0]} Overriding teardown as well + Should be equal ${tc.timeout} ${NONE} + Should be empty ${ERRORS} + *** Keywords *** Check timeout message [Arguments] ${msg} ${timeout} diff --git a/atest/robot/tags/test_tags.robot b/atest/robot/tags/test_tags.robot index c905a277167..9b934c77246 100644 --- a/atest/robot/tags/test_tags.robot +++ b/atest/robot/tags/test_tags.robot @@ -9,7 +9,6 @@ Test Tags Check Test Tags Own tags tags test own Should Be Empty ${ERRORS} - Test Tags and Force Tags cannot be used together Run Tests ${EMPTY} tags/test_tags_and_force_tags_cannot_be_used_together.robot Check Test Tags No own tags tags test @@ -17,3 +16,8 @@ Test Tags and Force Tags cannot be used together Error In File 0 tags/test_tags_and_force_tags_cannot_be_used_together.robot 3 ... Setting 'Force Tags' is allowed only once. Only the first value is used. +In init file + Run Tests ${EMPTY} tags/directory + Check Test Tags No own tags init file tags test file + Check Test Tags Own tags init file tags test file own + Should Be Empty ${ERRORS} diff --git a/atest/testdata/rpa/tasks/__init__.robot b/atest/testdata/rpa/tasks/__init__.robot new file mode 100644 index 00000000000..0c7bb059787 --- /dev/null +++ b/atest/testdata/rpa/tasks/__init__.robot @@ -0,0 +1,6 @@ +*** Settings *** +Task Tags task tags +Task Setup Log Setup has an alias! +Task Teardown Log Also teardown has an alias!! +Task Timeout 70 seconds + diff --git a/atest/testdata/rpa/tasks/aliases.robot b/atest/testdata/rpa/tasks/aliases.robot new file mode 100644 index 00000000000..3c092fbe295 --- /dev/null +++ b/atest/testdata/rpa/tasks/aliases.robot @@ -0,0 +1,13 @@ +*** Settings *** +Task Tags file tag + +*** Tasks *** +Defaults + No Operation + +Override + [Tags] own tags + [Setup] Log Overriding setup + [Timeout] NONE + No Operation + [Teardown] Log Overriding teardown as well diff --git a/atest/testdata/tags/directory/__init__.robot b/atest/testdata/tags/directory/__init__.robot new file mode 100644 index 00000000000..60312452b25 --- /dev/null +++ b/atest/testdata/tags/directory/__init__.robot @@ -0,0 +1,2 @@ +*** Settings *** +Test Tags init file tags diff --git a/atest/testdata/tags/directory/tests.robot b/atest/testdata/tags/directory/tests.robot new file mode 100644 index 00000000000..817f774ade9 --- /dev/null +++ b/atest/testdata/tags/directory/tests.robot @@ -0,0 +1,10 @@ +*** Settings *** +Test Tags test file tags + +*** Test Cases *** +No own tags + No Operation + +Own tags + [Tags] own tags + No Operation diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 4bfb0096803..ccda97ccc30 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -144,12 +144,12 @@ class TestCaseFileSettings(Settings): 'Variables' ) aliases = { + 'Test Tags': 'Force Tags', + 'Task Tags': 'Force Tags', 'Task Setup': 'Test Setup', 'Task Teardown': 'Test Teardown', 'Task Template': 'Test Template', 'Task Timeout': 'Test Timeout', - 'Test Tags': 'Force Tags', - 'Task Tags': 'Force Tags', } @@ -167,6 +167,13 @@ class InitFileSettings(Settings): 'Resource', 'Variables' ) + aliases = { + 'Test Tags': 'Force Tags', + 'Task Tags': 'Force Tags', + 'Task Setup': 'Test Setup', + 'Task Teardown': 'Test Teardown', + 'Task Timeout': 'Test Timeout', + } class ResourceFileSettings(Settings): From fba95ea9af67ac986e00182626e532e937b664fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 29 Jun 2022 14:41:56 +0300 Subject: [PATCH 0057/1592] UG: Enhance Tagging test cases section. - General enhancements and also fixes to documentation. - Explain new Test Tags as well as deprecation of Force Tags and Default Tags. Fixes #4368. - Mention deprecation of using '-tag' with [Tags] as a literal value. Fixes #4380. --- .../CreatingTestData/CreatingTestCases.rst | 163 +++++++++++------- .../src/ExecutingTestCases/TestExecution.rst | 8 +- 2 files changed, 109 insertions(+), 62 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index c413aa6ee70..ef08bb1c3ad 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -590,85 +590,109 @@ Tagging test cases ------------------ Using tags in Robot Framework is a simple, yet powerful mechanism for -classifying test cases. Tags are free text and they can be used at -least for the following purposes: +classifying test cases and also `user keywords`_. Tags are free text and +Robot Framework itself has no special meaning for them except for the +`reserved tags`_ discussed below. Tags can be used at least for the following +purposes: -- Tags are shown in test reports_, logs_ and, of course, in the test +- They are shown in test reports_, logs_ and, of course, in the test data, so they provide metadata to test cases. -- Statistics__ about test cases (total, passed, failed are - automatically collected based on tags). -- With tags, you can `include or exclude`__ test cases to be executed. -- With tags, you can specify which test cases should be skipped_. +- Statistics__ about test cases (total, passed, failed and skipped) are + automatically collected based on them. +- They can be used to `include and exclude`__ as well as to skip_ test cases. __ `Configuring statistics`_ __ `By tag names`_ -In this section it is only explained how to set tags for test -cases, and different ways to do it are listed below. These -approaches can naturally be used together. +There are multiple ways how to specify tags for test cases explained below: -`Force Tags`:setting: in the Setting section - All test cases in a test case file with this setting always get - specified tags. If it is used in the `test suite initialization file`, - all test cases in sub test suites get these tags. +`Test Tags`:setting: in the Setting section + All tests in a test case file with this setting always get specified tags. + If this setting is used in a `test suite initialization file`_, all tests + in child suites get these tags. -`Default Tags`:setting: in the Setting section - Test cases that do not have a :setting:`[Tags]` setting of their own - get these tags. Default tags are not supported in test suite initialization - files. - -`[Tags]`:setting: in the Test Case section - A test case always gets these tags. Additionally, it does not get the - possible tags specified with :setting:`Default Tags`, so it is possible - to override the :setting:`Default Tags` by using empty value. It is - also possible to use value `NONE` to override default tags. +`[Tags]`:setting: with each test case + Tests get these tags in addition to tags specified using the + :setting:`Test Tags` setting. `--settag`:option: command line option - All executed test cases get tags set with this option in addition - to tags they got elsewhere. + All tests get tags set with this option in addition to tags they got elsewhere. `Set Tags`:name:, `Remove Tags`:name:, `Fail`:name: and `Pass Execution`:name: keywords These BuiltIn_ keywords can be used to manipulate tags dynamically during the test execution. -Tags are free text, but they are normalized so that they are converted -to lowercase and all spaces are removed. If a test case gets the same tag -several times, other occurrences than the first one are removed. Tags -can be created using variables, assuming that those variables exist. +Example: .. sourcecode:: robotframework *** Settings *** - Force Tags req-42 - Default Tags owner-john smoke + Test Tags requirement: 42 smoke *** Variables *** ${HOST} 10.0.1.42 *** Test Cases *** No own tags - [Documentation] This test has tags owner-john, smoke and req-42. - No Operation - - With own tags - [Documentation] This test has tags not_ready, owner-mrx and req-42. - [Tags] owner-mrx not_ready + [Documentation] This test has tags 'requirement: 42' and 'smoke'. No Operation - Own tags with variables - [Documentation] This test has tags host-10.0.1.42 and req-42. - [Tags] host-${HOST} + Own tags + [Documentation] This test has tags 'requirement: 42', 'smoke' and 'not ready'. + [Tags] not ready No Operation - Empty own tags - [Documentation] This test has only tag req-42. - [Tags] + Own tags with variable + [Documentation] This test has tags 'requirement: 42', 'smoke' and 'host: 10.0.1.42'. + [Tags] host: ${HOST} No Operation - Set Tags and Remove Tags Keywords - [Documentation] This test has tags mytag and owner-john. - Set Tags mytag - Remove Tags smoke req-* + Set Tags and Remove Tags keywords + [Documentation] This test has tags 'smoke', 'example' and 'another'. + Set Tags example another + Remove Tags requirement: * + +As the example shows, tags can be created using variables, but otherwise they +preserve the exact name used in the data. When tags are compared, for example, +to collect statistics, to select test to be executed, or to remove duplicates, +comparisons are case, space and underscore insensitive. + +.. note:: The :setting:`Test Tags` setting is new in Robot Framework 5.1. + Earlier versions support :setting:`Force Tags` and :setting:`Default Tags` + settings discussed below. + +Deprecation of :setting:`Force Tags` and :setting:`Default Tags` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Prior to Robot Framework 5.1, tags could be specified to tests in the Setting section +using two different settings: + +:setting:`Force Tags` + All tests unconditionally get these tags. This is exactly the same as + :setting:`Test Tags` nowadays. + +:setting:`Default Tags` + All tests get these tags by default. If a test has :setting:`[Tags]`, + it will not get these tags. + +Both of these settings still work, but they are considered deprecated. +A visible deprecation warning will be added in the future, most likely +in Robot Framework 6.0, and eventually these settings will be removed. +Tools like Tidy__ can be used to ease transition. + +Robot Framework 5.2 will introduce a new way for tests to indicate they +`should not get certain globally specified tags`__. Instead of using a separate +setting that tests can override, tests can use syntax `-tag` with their +:setting:`[Tags]` setting to tell they should not get a tag named `tag`. +This syntax *does not* yet work in Robot Framework 5.1, but using +:setting:`[Tags]` with a literal value like `-tag` `is now deprecated`__. +If such tags are needed, they can be set using :setting:`Test Tags` or +escaped__ syntax `\-tag` can be used with :setting:`[Tags]`. + +__ https://robotidy.readthedocs.io/ +__ https://github.com/robotframework/robotframework/issues/4374 +__ https://github.com/robotframework/robotframework/issues/4380 +__ escaping_ Reserved tags ~~~~~~~~~~~~~ @@ -679,19 +703,42 @@ Framework itself, and using them for other purposes can have unexpected results. All special tags Robot Framework has and will have in the future have the `robot:` prefix. To avoid problems, users should thus not use any tag with this prefixes unless actually activating the special functionality. +The current reserved tags are listed below, but more such tags are likely +to be added in the future. + +`robot:continue-on-failure` and `robot:recursive-continue-on-failure` + Used for `enabling the continue-on-failure mode`__. + +`robot:stop-on-failure` and `robot:recursive-stop-on-failure` + Used for `disabling the continue-on-failure mode`__. + +`robot:skip-on-failure` + Mark test to be `skipped if it fails`__. -At the time of writing, the only special tags are `robot:exit`, that is -automatically added to tests when `stopping test execution gracefully`_, -and `robot:no-dry-run`, that can be used to disable the `dry run`_ mode as -well as `robot:continue-on-failure` which controls continuable execution. -More usages are likely to be added in the future. +`robot:skip` + Mark test to be `unconditionally skipped`__. -As of RobotFramework 4.1, reserved tags are suppressed by default in the -test suite's tag statistics. They will be shown when they are explicitly -included via the `--tagstatinclude 'robot:*'` command line option. +`robot:exclude` + Mark test to be `unconditionally excluded`__. -As of RobotFramework 5.0, new reserved tags include `robot:skip`, -`robot:skip-on-failure` and `robot:exclude`. +`robot:no-dry-run` + Mark keyword not to be executed in the `dry run`_ mode. + +`robot:exit` + Added to tests automatically when `execution is stopped gracefully`__. + +__ `Enabling continue-on-failure using tags`_ +__ `Disabling continue-on-failure using tags`_ +__ `Automatically skipping failed tests`_ +__ `Skipping before execution`_ +__ `By tag names`_ +__ `stopping test execution gracefully`_ + +As of RobotFramework 4.1, reserved tags are suppressed by default in +`tag statistics`__. They will be shown when they are explicitly +included via the `--tagstatinclude robot:*` command line option. + +__ `Configuring statistics`_ Test setup and teardown ----------------------- diff --git a/doc/userguide/src/ExecutingTestCases/TestExecution.rst b/doc/userguide/src/ExecutingTestCases/TestExecution.rst index 332ecb29a24..38a13320e03 100644 --- a/doc/userguide/src/ExecutingTestCases/TestExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/TestExecution.rst @@ -339,8 +339,8 @@ continue-on-failure mode is automatically enabled in `suite, test and keyword teardowns`__. In practice this means that in teardowns all the keywords in all levels are always executed. -If this behavior is not desired, it `can be disabled`__ using the special -`robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags. +If this behavior is not desired, the special `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags can be used to `disable it`__. __ `Setups and teardowns`_ __ `Disabling continue-on-failure using tags`_ @@ -361,8 +361,8 @@ the execution ends normally if there are non-continuable failures. this fails this is run -If this behavior is not desired, it `can be disabled`__ using the special -`robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags. +If this behavior is not desired, the special `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags can be used to `disable it`__. __ `Disabling continue-on-failure using tags`_ From 723568ed1e1c738db2d24fcf29913bdafe6c0350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 17:27:22 +0300 Subject: [PATCH 0058/1592] Initial implementation to add Keyword Tags setting. #4373 Todo: - Support Keyword Tags in suite init files. - Code cleanup (TestDefaults -> Defaults) - Documentation --- atest/robot/keywords/keyword_tags.robot | 16 +++++++++--- .../keywords/keyword_tags_setting.resource | 10 ++++++++ .../keywords/keyword_tags_setting.robot | 20 +++++++++++++++ src/robot/api/parsing.py | 5 ++-- src/robot/parsing/lexer/settings.py | 3 +++ src/robot/parsing/lexer/tokens.py | 2 ++ src/robot/parsing/model/statements.py | 14 +++++++++++ src/robot/running/builder/testsettings.py | 2 ++ src/robot/running/builder/transformers.py | 25 +++++++++++++------ utest/parsing/test_lexer.py | 4 +++ utest/parsing/test_statements.py | 18 +++++++++++-- 11 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 atest/testdata/keywords/keyword_tags_setting.resource create mode 100644 atest/testdata/keywords/keyword_tags_setting.robot diff --git a/atest/robot/keywords/keyword_tags.robot b/atest/robot/keywords/keyword_tags.robot index aae6472c416..914142f5e00 100644 --- a/atest/robot/keywords/keyword_tags.robot +++ b/atest/robot/keywords/keyword_tags.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} keywords/keyword_tags.robot +Suite Setup Run Tests ${EMPTY} keywords/keyword_tags.robot keywords/keyword_tags_setting.robot Resource atest_resource.robot Test Template Keyword tags should be @@ -41,8 +41,18 @@ User keyword tags with duplicates Dynamic library keyword with tags bar foo +Keyword tags setting in resource file + in resource + in resource own index=1 + +Keyword tags setting in test case file + first second + first own second index=1 + +Keyword tags setting in init file + *** Keywords *** Keyword tags should be - [Arguments] @{tags} + [Arguments] @{tags} ${index}=0 ${tc}= Check Test Case ${TESTNAME} - Lists should be equal ${tc.kws[0].tags} ${tags} + Lists should be equal ${tc.body[${index}].tags} ${tags} diff --git a/atest/testdata/keywords/keyword_tags_setting.resource b/atest/testdata/keywords/keyword_tags_setting.resource new file mode 100644 index 00000000000..7d7bdfa682c --- /dev/null +++ b/atest/testdata/keywords/keyword_tags_setting.resource @@ -0,0 +1,10 @@ +*** Settings *** +Keyword Tags in resource + +*** Keywords *** +Keyword without own tags in resource + No operation + +Keyword with own tags in resource + [Tags] own + No operation diff --git a/atest/testdata/keywords/keyword_tags_setting.robot b/atest/testdata/keywords/keyword_tags_setting.robot new file mode 100644 index 00000000000..af0940a3837 --- /dev/null +++ b/atest/testdata/keywords/keyword_tags_setting.robot @@ -0,0 +1,20 @@ +*** Settings *** +Keyword Tags first second +Resource keyword_tags_setting.resource + +*** Test Cases *** +Keyword tags setting in resource file + Keyword without own tags in resource + Keyword with own tags in resource + +Keyword tags setting in test case file + Keyword without own tags + Keyword with own tags + +*** Keywords *** +Keyword without own tags + No operation + +Keyword with own tags + [Tags] own + No operation diff --git a/src/robot/api/parsing.py b/src/robot/api/parsing.py index 44bd13dd66b..7ec3de74b7f 100644 --- a/src/robot/api/parsing.py +++ b/src/robot/api/parsing.py @@ -509,14 +509,15 @@ def visit_File(self, node): VariablesImport, Documentation, Metadata, - ForceTags, - DefaultTags, SuiteSetup, SuiteTeardown, TestSetup, TestTeardown, TestTemplate, TestTimeout, + ForceTags, + DefaultTags, + KeywordTags, Variable, TestCaseName, KeywordName, diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index ccda97ccc30..1a1ccc82b81 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -139,6 +139,7 @@ class TestCaseFileSettings(Settings): 'Test Timeout', 'Force Tags', 'Default Tags', + 'Keyword Tags', 'Library', 'Resource', 'Variables' @@ -163,6 +164,7 @@ class InitFileSettings(Settings): 'Test Teardown', 'Test Timeout', 'Force Tags', + 'Keyword Tags', 'Library', 'Resource', 'Variables' @@ -179,6 +181,7 @@ class InitFileSettings(Settings): class ResourceFileSettings(Settings): names = ( 'Documentation', + 'Keyword Tags', 'Library', 'Resource', 'Variables' diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index a499cf710f5..69a01db3bd9 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -58,6 +58,7 @@ class Token: TEST_TIMEOUT = 'TEST TIMEOUT' FORCE_TAGS = 'FORCE TAGS' DEFAULT_TAGS = 'DEFAULT TAGS' + KEYWORD_TAGS = 'KEYWORD TAGS' LIBRARY = 'LIBRARY' RESOURCE = 'RESOURCE' VARIABLES = 'VARIABLES' @@ -122,6 +123,7 @@ class Token: TEST_TIMEOUT, FORCE_TAGS, DEFAULT_TAGS, + KEYWORD_TAGS, LIBRARY, RESOURCE, VARIABLES, diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 6d6c77095e2..af61aacac95 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -407,6 +407,20 @@ def from_params(cls, values, separator=FOUR_SPACES, eol=EOL): return cls(tokens) +@Statement.register +class KeywordTags(MultiValue): + type = Token.KEYWORD_TAGS + + @classmethod + def from_params(cls, values, separator=FOUR_SPACES, eol=EOL): + tokens = [Token(Token.KEYWORD_TAGS, 'Keyword Tags')] + for tag in values: + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, tag)]) + tokens.append(Token(Token.EOL, eol)) + return cls(tokens) + + @Statement.register class SuiteSetup(Fixture): type = Token.SUITE_SETUP diff --git a/src/robot/running/builder/testsettings.py b/src/robot/running/builder/testsettings.py index 403f85d3afd..6e74f0124b0 100644 --- a/src/robot/running/builder/testsettings.py +++ b/src/robot/running/builder/testsettings.py @@ -16,6 +16,7 @@ NOTSET = object() +# FIXME: Rename to Defaults class TestDefaults: def __init__(self, parent=None): @@ -24,6 +25,7 @@ def __init__(self, parent=None): self._teardown = {} self._force_tags = () self.default_tags = () + self.keyword_tags = () self.template = None self._timeout = None diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index e445a717791..ea7bef79c70 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -18,13 +18,14 @@ from robot.output import LOGGER from robot.variables import VariableIterator -from .testsettings import TestSettings +from .testsettings import TestDefaults, TestSettings class SettingsBuilder(NodeVisitor): def __init__(self, suite, test_defaults): self.suite = suite + # FIXME: Rename test_defaults -> defaults self.test_defaults = test_defaults def visit_Documentation(self, node): @@ -60,6 +61,9 @@ def visit_DefaultTags(self, node): def visit_ForceTags(self, node): self.test_defaults.force_tags = node.values + def visit_KeywordTags(self, node): + self.test_defaults.keyword_tags = node.values + def visit_TestTemplate(self, node): self.test_defaults.template = node.value @@ -90,6 +94,7 @@ class SuiteBuilder(NodeVisitor): def __init__(self, suite, test_defaults): self.suite = suite + # FIXME: Rename self.test_defaults = test_defaults def visit_SettingSection(self, node): @@ -105,17 +110,21 @@ def visit_TestCase(self, node): TestCaseBuilder(self.suite, self.test_defaults).visit(node) def visit_Keyword(self, node): - KeywordBuilder(self.suite.resource).visit(node) + KeywordBuilder(self.suite.resource, self.test_defaults).visit(node) class ResourceBuilder(NodeVisitor): def __init__(self, resource): self.resource = resource + self.defaults = TestDefaults() def visit_Documentation(self, node): self.resource.doc = node.value + def visit_KeywordTags(self, node): + self.defaults.keyword_tags = node.values + def visit_LibraryImport(self, node): self.resource.imports.create(type='Library', name=node.name, args=node.args, alias=node.alias, @@ -136,7 +145,7 @@ def visit_Variable(self, node): error=format_error(node.errors)) def visit_Keyword(self, node): - KeywordBuilder(self.resource).visit(node) + KeywordBuilder(self.resource, self.defaults).visit(node) class TestCaseBuilder(NodeVisitor): @@ -244,15 +253,17 @@ def visit_Break(self, node): class KeywordBuilder(NodeVisitor): - def __init__(self, resource): + def __init__(self, resource, defaults): self.resource = resource + self.defaults = defaults self.kw = None def visit_Keyword(self, node): - self.kw = self.resource.keywords.create(name=node.name, lineno=node.lineno) + self.kw = self.resource.keywords.create(name=node.name, + tags=self.defaults.keyword_tags, + lineno=node.lineno) self.generic_visit(node) - def visit_Documentation(self, node): self.kw.doc = node.value @@ -264,7 +275,7 @@ def visit_Arguments(self, node): def visit_Tags(self, node): deprecate_tags_starting_with_hyphen(node, self.resource.source) - self.kw.tags = node.values + self.kw.tags.add(node.values) def visit_Return(self, node): self.kw.return_ = node.values diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 7452ce0ceb9..a2f67786461 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -41,6 +41,7 @@ def test_common_suite_settings(self): TEST TEARDOWN No Operation Test Timeout 1 day Force Tags foo bar +Keyword Tags tag ''' expected = [ (T.SETTING_HEADER, '*** Settings ***', 1, 0), @@ -83,6 +84,9 @@ def test_common_suite_settings(self): (T.ARGUMENT, 'foo', 11, 18), (T.ARGUMENT, 'bar', 11, 25), (T.EOS, '', 11, 28), + (T.KEYWORD_TAGS, 'Keyword Tags', 12, 0), + (T.ARGUMENT, 'tag', 12, 18), + (T.EOS, '', 12, 21), ] assert_tokens(data, expected, get_tokens, data_only=True) assert_tokens(data, expected, get_init_tokens, data_only=True) diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index cedc07954a9..35ee6140bae 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -160,7 +160,6 @@ def test_TestTeardown(self): ) def test_TestTemplate(self): - # *** Settings *** # Test Template Keyword Template tokens = [ Token(Token.TEST_TEMPLATE, 'Test Template'), @@ -175,7 +174,6 @@ def test_TestTemplate(self): ) def test_TestTimeout(self): - # *** Settings *** # Test Timeout 1 min tokens = [ Token(Token.TEST_TIMEOUT, 'Test Timeout'), @@ -189,6 +187,22 @@ def test_TestTimeout(self): value='1 min' ) + def test_KeywordTags(self): + # Keyword Tags first second + tokens = [ + Token(Token.KEYWORD_TAGS, 'Keyword Tags'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'first'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'second'), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + KeywordTags, + values=['first', 'second'] + ) + def test_Variable(self): # ${variable_name} {'a': 4, 'b': 'abc'} tokens = [ From df18cc587a5f578f050c001d44b021c2e051fc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 17:31:26 +0300 Subject: [PATCH 0059/1592] Refactor tests to ease adding new --- atest/robot/keywords/keyword_tags.robot | 2 +- .../{ => keyword_tags}/DynamicLibraryWithKeywordTags.py | 0 .../keywords/{ => keyword_tags}/LibraryWithKeywordTags.py | 0 atest/testdata/keywords/{ => keyword_tags}/keyword_tags.robot | 0 .../keywords/{ => keyword_tags}/keyword_tags_setting.resource | 0 .../keywords/{ => keyword_tags}/keyword_tags_setting.robot | 0 6 files changed, 1 insertion(+), 1 deletion(-) rename atest/testdata/keywords/{ => keyword_tags}/DynamicLibraryWithKeywordTags.py (100%) rename atest/testdata/keywords/{ => keyword_tags}/LibraryWithKeywordTags.py (100%) rename atest/testdata/keywords/{ => keyword_tags}/keyword_tags.robot (100%) rename atest/testdata/keywords/{ => keyword_tags}/keyword_tags_setting.resource (100%) rename atest/testdata/keywords/{ => keyword_tags}/keyword_tags_setting.robot (100%) diff --git a/atest/robot/keywords/keyword_tags.robot b/atest/robot/keywords/keyword_tags.robot index 914142f5e00..98e00da492a 100644 --- a/atest/robot/keywords/keyword_tags.robot +++ b/atest/robot/keywords/keyword_tags.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} keywords/keyword_tags.robot keywords/keyword_tags_setting.robot +Suite Setup Run Tests ${EMPTY} keywords/keyword_tags Resource atest_resource.robot Test Template Keyword tags should be diff --git a/atest/testdata/keywords/DynamicLibraryWithKeywordTags.py b/atest/testdata/keywords/keyword_tags/DynamicLibraryWithKeywordTags.py similarity index 100% rename from atest/testdata/keywords/DynamicLibraryWithKeywordTags.py rename to atest/testdata/keywords/keyword_tags/DynamicLibraryWithKeywordTags.py diff --git a/atest/testdata/keywords/LibraryWithKeywordTags.py b/atest/testdata/keywords/keyword_tags/LibraryWithKeywordTags.py similarity index 100% rename from atest/testdata/keywords/LibraryWithKeywordTags.py rename to atest/testdata/keywords/keyword_tags/LibraryWithKeywordTags.py diff --git a/atest/testdata/keywords/keyword_tags.robot b/atest/testdata/keywords/keyword_tags/keyword_tags.robot similarity index 100% rename from atest/testdata/keywords/keyword_tags.robot rename to atest/testdata/keywords/keyword_tags/keyword_tags.robot diff --git a/atest/testdata/keywords/keyword_tags_setting.resource b/atest/testdata/keywords/keyword_tags/keyword_tags_setting.resource similarity index 100% rename from atest/testdata/keywords/keyword_tags_setting.resource rename to atest/testdata/keywords/keyword_tags/keyword_tags_setting.resource diff --git a/atest/testdata/keywords/keyword_tags_setting.robot b/atest/testdata/keywords/keyword_tags/keyword_tags_setting.robot similarity index 100% rename from atest/testdata/keywords/keyword_tags_setting.robot rename to atest/testdata/keywords/keyword_tags/keyword_tags_setting.robot From 676a7ad605830fa6c3ffb2761ddac57fc5a3aab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 17:39:57 +0300 Subject: [PATCH 0060/1592] Test Keyword Tags in init files Earlier implementation was enough. Part of #4373. --- atest/robot/keywords/keyword_tags.robot | 11 ++++++++--- atest/testdata/keywords/keyword_tags/__init__.robot | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 atest/testdata/keywords/keyword_tags/__init__.robot diff --git a/atest/robot/keywords/keyword_tags.robot b/atest/robot/keywords/keyword_tags.robot index 98e00da492a..c4ca01b0537 100644 --- a/atest/robot/keywords/keyword_tags.robot +++ b/atest/robot/keywords/keyword_tags.robot @@ -50,9 +50,14 @@ Keyword tags setting in test case file first own second index=1 Keyword tags setting in init file + in init kw=${SUITE.setup} + in init own kw=${SUITE.teardown} *** Keywords *** Keyword tags should be - [Arguments] @{tags} ${index}=0 - ${tc}= Check Test Case ${TESTNAME} - Lists should be equal ${tc.body[${index}].tags} ${tags} + [Arguments] @{tags} ${index}=0 ${kw}= + IF not $kw + ${tc}= Check Test Case ${TESTNAME} + ${kw}= Set Variable ${tc.body}[${index}] + END + Lists should be equal ${kw.tags} ${tags} diff --git a/atest/testdata/keywords/keyword_tags/__init__.robot b/atest/testdata/keywords/keyword_tags/__init__.robot new file mode 100644 index 00000000000..0b53ad49881 --- /dev/null +++ b/atest/testdata/keywords/keyword_tags/__init__.robot @@ -0,0 +1,12 @@ +*** Settings *** +Suite Setup Keyword without own tags +Suite Teardown Keyword with own tags +Keyword Tags in init + +*** Keywords *** +Keyword without own tags + No operation + +Keyword with own tags + [Tags] own + No operation From 69eb39a6424ddc790f3ed769527201d133fad223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 17:52:24 +0300 Subject: [PATCH 0061/1592] Cleanup Rename TestDefaults to more generic Defaults because it's used also with keywords nowadays. Part or #4373. --- src/robot/running/builder/builders.py | 4 +-- src/robot/running/builder/parsers.py | 4 +-- .../builder/{testsettings.py => settings.py} | 3 +- src/robot/running/builder/transformers.py | 32 +++++++++---------- 4 files changed, 20 insertions(+), 23 deletions(-) rename src/robot/running/builder/{testsettings.py => settings.py} (98%) diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 16d74fba388..b0849f25a73 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -20,7 +20,7 @@ from robot.parsing import SuiteStructureBuilder, SuiteStructureVisitor from .parsers import RobotParser, NoInitFileDirectoryParser, RestParser -from .testsettings import TestDefaults +from .settings import Defaults class TestSuiteBuilder: @@ -161,7 +161,7 @@ def end_directory(self, structure): def _build_suite(self, structure): parent_defaults = self._stack[-1][-1] if self._stack else None source = structure.source - defaults = TestDefaults(parent_defaults) + defaults = Defaults(parent_defaults) parser = self._get_parser(structure.extension) try: if structure.is_directory: diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 4ff83bb2eb6..c8b76d1d708 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -21,7 +21,7 @@ from robot.parsing import get_model, get_resource_model, get_init_model, Token from robot.utils import FileReader, read_rest_data -from .testsettings import TestDefaults +from .settings import Defaults from .transformers import SuiteBuilder, SettingsBuilder, ResourceBuilder from ..model import TestSuite, ResourceFile @@ -60,7 +60,7 @@ def build_suite(self, model, name=None, defaults=None): def _build(self, suite, source, defaults, model=None, get_model=get_model): if defaults is None: - defaults = TestDefaults() + defaults = Defaults() if model is None: model = get_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) diff --git a/src/robot/running/builder/testsettings.py b/src/robot/running/builder/settings.py similarity index 98% rename from src/robot/running/builder/testsettings.py rename to src/robot/running/builder/settings.py index 6e74f0124b0..20b84e853be 100644 --- a/src/robot/running/builder/testsettings.py +++ b/src/robot/running/builder/settings.py @@ -16,8 +16,7 @@ NOTSET = object() -# FIXME: Rename to Defaults -class TestDefaults: +class Defaults: def __init__(self, parent=None): self.parent = parent diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index ea7bef79c70..bdfc775daec 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -18,15 +18,14 @@ from robot.output import LOGGER from robot.variables import VariableIterator -from .testsettings import TestDefaults, TestSettings +from .settings import Defaults, TestSettings class SettingsBuilder(NodeVisitor): - def __init__(self, suite, test_defaults): + def __init__(self, suite, defaults): self.suite = suite - # FIXME: Rename test_defaults -> defaults - self.test_defaults = test_defaults + self.defaults = defaults def visit_Documentation(self, node): self.suite.doc = node.value @@ -43,29 +42,29 @@ def visit_SuiteTeardown(self, node): lineno=node.lineno) def visit_TestSetup(self, node): - self.test_defaults.setup = { + self.defaults.setup = { 'name': node.name, 'args': node.args, 'lineno': node.lineno } def visit_TestTeardown(self, node): - self.test_defaults.teardown = { + self.defaults.teardown = { 'name': node.name, 'args': node.args, 'lineno': node.lineno } def visit_TestTimeout(self, node): - self.test_defaults.timeout = node.value + self.defaults.timeout = node.value def visit_DefaultTags(self, node): - self.test_defaults.default_tags = node.values + self.defaults.default_tags = node.values def visit_ForceTags(self, node): - self.test_defaults.force_tags = node.values + self.defaults.force_tags = node.values def visit_KeywordTags(self, node): - self.test_defaults.keyword_tags = node.values + self.defaults.keyword_tags = node.values def visit_TestTemplate(self, node): - self.test_defaults.template = node.value + self.defaults.template = node.value def visit_ResourceImport(self, node): self.suite.resource.imports.create(type='Resource', name=node.name, @@ -92,10 +91,9 @@ def visit_KeywordSection(self, node): class SuiteBuilder(NodeVisitor): - def __init__(self, suite, test_defaults): + def __init__(self, suite, defaults): self.suite = suite - # FIXME: Rename - self.test_defaults = test_defaults + self.defaults = defaults def visit_SettingSection(self, node): pass @@ -107,17 +105,17 @@ def visit_Variable(self, node): error=format_error(node.errors)) def visit_TestCase(self, node): - TestCaseBuilder(self.suite, self.test_defaults).visit(node) + TestCaseBuilder(self.suite, self.defaults).visit(node) def visit_Keyword(self, node): - KeywordBuilder(self.suite.resource, self.test_defaults).visit(node) + KeywordBuilder(self.suite.resource, self.defaults).visit(node) class ResourceBuilder(NodeVisitor): def __init__(self, resource): self.resource = resource - self.defaults = TestDefaults() + self.defaults = Defaults() def visit_Documentation(self, node): self.resource.doc = node.value From 8b7df052e20c9d5a7d3a11eb52b4470603b80617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 17:53:43 +0300 Subject: [PATCH 0062/1592] Test Keyword Tags with tags in documentation. Part of #4373. --- atest/robot/keywords/keyword_tags.robot | 6 ++++-- .../keywords/keyword_tags/keyword_tags_setting.resource | 4 ++++ .../keywords/keyword_tags/keyword_tags_setting.robot | 7 +++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/atest/robot/keywords/keyword_tags.robot b/atest/robot/keywords/keyword_tags.robot index c4ca01b0537..93d54b48f7a 100644 --- a/atest/robot/keywords/keyword_tags.robot +++ b/atest/robot/keywords/keyword_tags.robot @@ -43,11 +43,13 @@ Dynamic library keyword with tags Keyword tags setting in resource file in resource - in resource own index=1 + in resource own index=1 + in doc in resource index=2 Keyword tags setting in test case file first second - first own second index=1 + first own second index=1 + doc first in second index=2 Keyword tags setting in init file in init kw=${SUITE.setup} diff --git a/atest/testdata/keywords/keyword_tags/keyword_tags_setting.resource b/atest/testdata/keywords/keyword_tags/keyword_tags_setting.resource index 7d7bdfa682c..9f9d53db489 100644 --- a/atest/testdata/keywords/keyword_tags/keyword_tags_setting.resource +++ b/atest/testdata/keywords/keyword_tags/keyword_tags_setting.resource @@ -8,3 +8,7 @@ Keyword without own tags in resource Keyword with own tags in resource [Tags] own No operation + +Keyword with own tags in documentation in resource + [Documentation] Tags: in doc + No operation diff --git a/atest/testdata/keywords/keyword_tags/keyword_tags_setting.robot b/atest/testdata/keywords/keyword_tags/keyword_tags_setting.robot index af0940a3837..7509ff03892 100644 --- a/atest/testdata/keywords/keyword_tags/keyword_tags_setting.robot +++ b/atest/testdata/keywords/keyword_tags/keyword_tags_setting.robot @@ -6,10 +6,12 @@ Resource keyword_tags_setting.resource Keyword tags setting in resource file Keyword without own tags in resource Keyword with own tags in resource + Keyword with own tags in documentation in resource Keyword tags setting in test case file Keyword without own tags Keyword with own tags + Keyword with own tags in documentation *** Keywords *** Keyword without own tags @@ -18,3 +20,8 @@ Keyword without own tags Keyword with own tags [Tags] own No operation + +Keyword with own tags in documentation + [Documentation] Documentation. + ... Tags: in, doc + No operation From 9c6135fa5ece1857e2f7f8a132afed7941ad2c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 18:22:49 +0300 Subject: [PATCH 0063/1592] Document the Keyword Tags tags setting. Fixes #4373. --- .../src/Appendices/AvailableSettings.rst | 3 ++ .../CreatingTestData/CreatingUserKeywords.rst | 52 +++++++++++++++---- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/doc/userguide/src/Appendices/AvailableSettings.rst b/doc/userguide/src/Appendices/AvailableSettings.rst index a4bbda33648..b2e6d6a7538 100644 --- a/doc/userguide/src/Appendices/AvailableSettings.rst +++ b/doc/userguide/src/Appendices/AvailableSettings.rst @@ -41,6 +41,9 @@ importing libraries, resources, and variables. | Default Tags | Used for specifying default values for tags when | | | `tagging test cases`_. | +-----------------+--------------------------------------------------------+ + | Keyword Tags | User for specifying `user keyword tags`_ for all | + | | keywords in a certain file. | + +-----------------+--------------------------------------------------------+ | Test Setup | Used for specifying a default `test setup`_. | +-----------------+--------------------------------------------------------+ | Test Teardown | Used for specifying a default `test teardown`_. | diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index a9fc1b00751..267ae0b17f8 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -140,12 +140,34 @@ the `Deprecating keywords`_ section. User keyword tags ----------------- -Both user keywords and `library keywords`_ can have tags. User keyword -tags can be set with :setting:`[Tags]` setting similarly as `test case tags`_, -but possible :setting:`Force Tags` and :setting:`Default Tags` setting do not -affect them. Additionally keyword tags can be specified on the last line of -the documentation with `Tags:` prefix and separated by a comma. For example, -following two keywords would both get same three tags. +Both user keywords and `library keywords`_ can have tags. Similarly as when +`tagging test cases`_, there are two settings affecting user keyword tags: + +`Keyword Tags`:setting: in the Setting section + All keywords in a file with this setting always get specified tags. + +`[Tags]`:setting: with each keyword + Keywords get these tags in addition to possible tags specified using the + :setting:`Keyword Tags` setting. + +.. sourcecode:: robotframework + + *** Settings *** + Keyword Tags gui + + *** Keywords *** + No own tags + [Documentation] This test has tag 'gui'. + No Operation + + Own tags + [Documentation] This test has tags 'gui', 'own' and 'tags'. + [Tags] own tags + No Operation + +Additionally, keyword tags can be specified on the last line of the documentation +with `Tags:` prefix so that tags are separated with a comma. For example, +following two keywords get same three tags: .. sourcecode:: robotframework @@ -159,20 +181,32 @@ following two keywords would both get same three tags. ... Tags: my, fine, tags No Operation - Keyword tags are shown in logs and in documentation generated by Libdoc_, where the keywords can also be searched based on tags. The `--removekeywords`__ and `--flattenkeywords`__ commandline options also support selecting keywords by tag, and new usages for keywords tags are possibly added in later releases. -Similarly as with `test case tags`_, user keyword tags with `robot-` and -`robot:` prefixes are reserved__ for special features by Robot Framework +Similarly as with `test case tags`_, user keyword tags with the `robot:` +prefix are reserved__ for special features by Robot Framework itself. Users should thus not use any tag with these prefixes unless actually activating the special functionality. +.. note:: :setting:`Keyword Tags` is new in Robot Framework 5.1. With earlier + versions all keyword tags need to be specified using the + :setting:`[Tags]` setting. + +.. note:: Robot Framework 5.2 will support `removing globally set tags`__ using + the `-tag` syntax with the :setting:`[Tags]` setting. Creating tags + with literal value like `-tag` `is deprecated`__ in Robot Framework 5.1 + and escaped__ syntax `\-tag` must be used if such tags are actually + needed. + __ `Removing keywords`_ __ `Flattening keywords`_ __ `Reserved tags`_ +__ https://github.com/robotframework/robotframework/issues/4374 +__ https://github.com/robotframework/robotframework/issues/4380 +__ escaping_ User keyword arguments ---------------------- From 33371dae18cac1990ab6d543df1e93920d43280e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 18:32:11 +0300 Subject: [PATCH 0064/1592] UG: Update list of all available settings related to #4368. - Add Test Tags. - Mention that Force and Default Tags are deprecated. --- doc/userguide/src/Appendices/AvailableSettings.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/userguide/src/Appendices/AvailableSettings.rst b/doc/userguide/src/Appendices/AvailableSettings.rst index b2e6d6a7538..0cbd08bf626 100644 --- a/doc/userguide/src/Appendices/AvailableSettings.rst +++ b/doc/userguide/src/Appendices/AvailableSettings.rst @@ -35,11 +35,11 @@ importing libraries, resources, and variables. +-----------------+--------------------------------------------------------+ | Suite Teardown | Used for specifying the `suite teardown`_. | +-----------------+--------------------------------------------------------+ - | Force Tags | Used for specifying forced values for tags when | - | | `tagging test cases`_. | + | Test Tags | Used for specifying `test case tags`_ for all tests | + | | in a suite. | +-----------------+--------------------------------------------------------+ - | Default Tags | Used for specifying default values for tags when | - | | `tagging test cases`_. | + | Force Tags, | `Deprecated settings`__ for specifying test case tags. | + | Default Tags | | +-----------------+--------------------------------------------------------+ | Keyword Tags | User for specifying `user keyword tags`_ for all | | | keywords in a certain file. | @@ -61,6 +61,7 @@ importing libraries, resources, and variables. __ `Test suite documentation`_ __ `Documenting resource files`_ +__ `Deprecation of Force Tags and Default Tags`_ Test Case section ----------------- From 8ba179518a5901a250fdc7f864f79667c319cf2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 18:41:43 +0300 Subject: [PATCH 0065/1592] Enhance test --- utest/api/test_exposed_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utest/api/test_exposed_api.py b/utest/api/test_exposed_api.py index f731caeaa20..bef37b06ba7 100644 --- a/utest/api/test_exposed_api.py +++ b/utest/api/test_exposed_api.py @@ -47,7 +47,7 @@ def test_parsing_model_statements(self): def test_parsing_model_blocks(self): for name in ('File', 'SettingSection', 'VariableSection', 'TestCaseSection', 'KeywordSection', 'CommentSection', 'TestCase', 'Keyword', 'For', - 'If'): + 'If', 'Try', 'While'): assert_equal(getattr(api_parsing, name), getattr(parsing.model, name)) assert_true(not hasattr(api_parsing, 'Block')) From 46da274cc1b99e5f676fb1e1f89760f18ed30a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Jun 2022 23:47:18 +0300 Subject: [PATCH 0066/1592] Fix parsing line with only assignment. Fixes #4381. --- src/robot/parsing/model/statements.py | 2 +- utest/parsing/test_model.py | 15 ++++++++++++++ utest/parsing/test_statements.py | 29 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index af61aacac95..1461bb4c7a6 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -67,7 +67,7 @@ def from_tokens(cls, tokens): for token in tokens: if token.type in handlers: return handlers[token.type](tokens) - if tokens and all(token.type == Token.ASSIGN for token in tokens): + if any(token.type == Token.ASSIGN for token in tokens): return KeywordCall(tokens) return EmptyLine(tokens) diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 14c3213b028..c4b9f9da5b5 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -655,6 +655,21 @@ def test_assign(self): ) get_and_assert_model(data, expected) + def test_assign_only_inside(self): + data = ''' +*** Test Cases *** +Example + IF ${cond} ${assign} +''' + expected = If( + header=InlineIfHeader([Token(Token.INLINE_IF, 'IF', 3, 4), + Token(Token.ARGUMENT, '${cond}', 3, 10)]), + body=[KeywordCall([Token(Token.ASSIGN, '${assign}', 3, 21)])], + end=End([Token(Token.END, '', 3, 30)]), + errors=('Inline IF branches cannot contain assignments.',) + ) + get_and_assert_model(data, expected) + def test_invalid(self): data1 = ''' *** Test Cases *** diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 35ee6140bae..8e327c2d1b7 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -46,6 +46,35 @@ def assert_statements(st1, st2): f'{st2} {type_name(st2)}') +class TestStatementFromTokens(unittest.TestCase): + + def test_keyword_call_with_assignment(self): + tokens = [Token(Token.SEPARATOR, ' '), + Token(Token.ASSIGN, '${var}'), + Token(Token.SEPARATOR, ' '), + Token(Token.KEYWORD, 'Keyword'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'arg'), + Token(Token.EOL)] + assert_statements(Statement.from_tokens(tokens), KeywordCall(tokens)) + + def test_inline_if_with_assignment(self): + tokens = [Token(Token.SEPARATOR, ' '), + Token(Token.ASSIGN, '${var}'), + Token(Token.SEPARATOR, ' '), + Token(Token.INLINE_IF, 'IF'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'True'), + Token(Token.EOL)] + assert_statements(Statement.from_tokens(tokens), InlineIfHeader(tokens)) + + def test_assign_only(self): + tokens = [Token(Token.SEPARATOR, ' '), + Token(Token.ASSIGN, '${var}'), + Token(Token.EOL)] + assert_statements(Statement.from_tokens(tokens), KeywordCall(tokens)) + + class TestCreateStatementsFromParams(unittest.TestCase): def test_Statement(self): From 10fabf460310aaebe65e181b98a383233608869f Mon Sep 17 00:00:00 2001 From: Robin Matz <76647407+robinmatz@users.noreply.github.com> Date: Mon, 4 Jul 2022 11:49:23 +0200 Subject: [PATCH 0067/1592] Keyword visibility modifiers for resource files (#430) (#4345) Add support for marking user keywords private with `robot:private` tag. --- atest/robot/libdoc/html_output.robot | 7 ++ atest/robot/libdoc/json_output.robot | 6 ++ atest/robot/libdoc/resource_file.robot | 2 +- atest/robot/running/private.robot | 67 +++++++++++++++++++ atest/testdata/libdoc/resource.robot | 3 + atest/testdata/running/private.resource | 26 +++++++ atest/testdata/running/private.robot | 45 +++++++++++++ atest/testdata/running/private2.resource | 19 ++++++ atest/testdata/running/private3.resource | 4 ++ .../CreatingTestData/CreatingUserKeywords.rst | 26 +++++++ src/robot/libdocpkg/htmlwriter.py | 2 +- src/robot/libdocpkg/model.py | 13 ++-- src/robot/running/context.py | 7 ++ src/robot/running/namespace.py | 37 ++++++++++ src/robot/running/userkeywordrunner.py | 12 ++++ 15 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 atest/robot/running/private.robot create mode 100644 atest/testdata/running/private.resource create mode 100644 atest/testdata/running/private.robot create mode 100644 atest/testdata/running/private2.resource create mode 100644 atest/testdata/running/private3.resource diff --git a/atest/robot/libdoc/html_output.robot b/atest/robot/libdoc/html_output.robot index af428c9679a..dd83cad9c23 100644 --- a/atest/robot/libdoc/html_output.robot +++ b/atest/robot/libdoc/html_output.robot @@ -105,6 +105,13 @@ User keyword documentation formatting ... </tr> ... </table> +Private keyword should be excluded + [Setup] Run Libdoc And Parse Model From HTML ${TESTDATADIR}/resource.robot + [Template] None + FOR ${keyword} IN @{MODEL}[keywords] + Should Not Be Equal ${keyword}[name] Private + END + *** 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 65460370400..e7bbf486051 100644 --- a/atest/robot/libdoc/json_output.robot +++ b/atest/robot/libdoc/json_output.robot @@ -105,6 +105,12 @@ User keyword documentation formatting ... </tr> ... </table> +Private user keyword should be included + [Setup] Run Libdoc And Parse Model From JSON ${TESTDATADIR}/resource.robot + [Template] Should Be Equal As Strings + ${MODEL}[keywords][-1][name] Private + ${MODEL}[keywords][-1][tags] ['robot:private'] + *** Keywords *** Verify Argument Models [Arguments] ${arg_models} @{expected_reprs} diff --git a/atest/robot/libdoc/resource_file.robot b/atest/robot/libdoc/resource_file.robot index 35e06f8def1..fd5d5a3ddde 100644 --- a/atest/robot/libdoc/resource_file.robot +++ b/atest/robot/libdoc/resource_file.robot @@ -43,7 +43,7 @@ Spec version Resource Tags Specfile Tags Should Be \${3} ?!?!?? a b bar dar - ... foo Has kw4 tags + ... foo Has kw4 robot:private tags Resource Has No Inits Should Have No Init diff --git a/atest/robot/running/private.robot b/atest/robot/running/private.robot new file mode 100644 index 00000000000..1b8d9f33f40 --- /dev/null +++ b/atest/robot/running/private.robot @@ -0,0 +1,67 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/private.robot +Resource atest_resource.robot + +*** Test Cases *** +Valid Usage With Local Keyword + ${tc}= Check Test Case ${TESTNAME} + Length Should Be ${tc.body[0].body} 1 + +Invalid Usage With Local Keyword + ${tc}= Check Test Case ${TESTNAME} + Private Call Warning Should Be Private Keyword ${tc.body[0].body[0]} ${ERRORS[0]} + Length Should Be ${tc.body[0].body} 2 + +Valid Usage With Resource Keyword + ${tc}= Check Test Case ${TESTNAME} + Length Should Be ${tc.body[0].body} 1 + +Invalid Usage With Resource Keyword + ${tc}= Check Test Case ${TESTNAME} + Private Call Warning Should Be private.Private Keyword In Resource ${tc.body[0].body[0]} ${ERRORS[1]} + Length Should Be ${tc.body[0].body} 2 + +Invalid Usage in Resource File + ${tc}= Check Test Case ${TESTNAME} + Private Call Warning Should Be private2.Private Keyword In Resource 2 ${tc.body[0].body[0].body[0]} ${ERRORS[2]} + Length Should Be ${tc.body[0].body[0].body} 2 + +Keyword With Same Name Should Resolve Public Keyword + ${tc}= Check Test Case ${TESTNAME} + ${warning}= Catenate + ... There were both public and private keyword found with the name 'Same Name', + ... 'private.Same Name' being public and 'private2.Same Name' being private. + ... The public keyword is used. + ... To select explicitly, and to get rid of this warning, + ... use either 'private.Same Name' or 'private2.Same Name'. + Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0]} ${ERRORS[3]} + Length Should Be ${tc.body[0].body} 2 + +If Both Keywords Are Private Raise Multiple Keywords Found + Check Test Case ${TESTNAME} + +If One Keyword Is Public And Multiple Private Keywords Run Public And Warn + ${tc}= Check Test Case ${TESTNAME} + ${warning}= Catenate + ... There were both public and private keyword found with the name 'Possible Keyword', + ... 'private.Possible Keyword' being public and 'private2.Possible Keyword' / 'private3.Possible Keyword' being private. + ... The public keyword is used. + ... To select explicitly, and to get rid of this warning, + ... use either 'private.Possible Keyword' or 'private2.Possible Keyword' / 'private3.Possible Keyword'. + Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0].body[0]} ${ERRORS[4]} + Length Should Be ${tc.body[0].body[0].body} 2 + +*** Keywords *** +Private Call Warning Should Be + [Arguments] ${name} @{messages} + FOR ${message} IN @{messages} + Check Log Message ${message} + ... Keyword '${name}' is private and should only be called by keywords in the same file. + ... WARN + END + +Public And Private Keyword Conflict Warning Should Be + [Arguments] ${warning} @{messages} + FOR ${message} IN @{messages} + Check Log Message ${message} ${warning} WARN + END diff --git a/atest/testdata/libdoc/resource.robot b/atest/testdata/libdoc/resource.robot index 74b98f79457..e6fdac2c6f4 100644 --- a/atest/testdata/libdoc/resource.robot +++ b/atest/testdata/libdoc/resource.robot @@ -69,3 +69,6 @@ non ascii doc Deprecation [Documentation] *DEPRECATED* for some reason. + +Private + [Tags] robot:private diff --git a/atest/testdata/running/private.resource b/atest/testdata/running/private.resource new file mode 100644 index 00000000000..039c13c00a1 --- /dev/null +++ b/atest/testdata/running/private.resource @@ -0,0 +1,26 @@ +*** Settings *** +Resource private2.resource + +*** Keywords *** +Public Keyword In Resource + Private Keyword In Resource + +Private Keyword In Resource + [Tags] robot:private + No Operation + +Call Private Keyword From Private 2 Resource + Private Keyword In Resource 2 + +Same Name + No Operation + +First Public Keyword With Nested Private Keyword + Nested Private Keyword + +Nested Private Keyword + [Tags] robot:private + No Operation + +Possible Keyword + No Operation diff --git a/atest/testdata/running/private.robot b/atest/testdata/running/private.robot new file mode 100644 index 00000000000..a6c95a2a63b --- /dev/null +++ b/atest/testdata/running/private.robot @@ -0,0 +1,45 @@ +*** Settings *** +Resource private.resource +Resource private2.resource +Resource private3.resource + +*** Test Cases *** +Valid Usage With Local Keyword + Public Keyword + +Invalid Usage With Local Keyword + Private Keyword + +Valid Usage With Resource Keyword + Public Keyword In Resource + +Invalid Usage With Resource Keyword + Private Keyword In Resource + +Invalid Usage In Resource file + Call Private Keyword From Private 2 Resource + +Keyword With Same Name Should Resolve Public Keyword + Same Name + +If Both Keywords Are Private Raise Multiple Keywords Found + [Documentation] FAIL Multiple keywords with name 'Nested Private Keyword' found. \ + ... Give the full name of the keyword you want to use: + ... ${SPACE*4}private.Nested Private Keyword + ... ${SPACE*4}private2.Nested Private Keyword + First Public Keyword With Nested Private Keyword + Second Public Keyword With Nested Private Keyword + +If One Keyword Is Public And Multiple Private Keywords Run Public And Warn + Keyword With One Public And Two Private Possible Keywords + +*** Keywords *** +Public Keyword + Private Keyword + +Private Keyword + [Tags] robot:private + No Operation + +Keyword With One Public And Two Private Possible Keywords + Possible Keyword diff --git a/atest/testdata/running/private2.resource b/atest/testdata/running/private2.resource new file mode 100644 index 00000000000..3ff7df4d61a --- /dev/null +++ b/atest/testdata/running/private2.resource @@ -0,0 +1,19 @@ +*** Keywords *** +Private Keyword In Resource 2 + [Tags] robot:private + No Operation + +Same Name + [Tags] robot:private + No Operation + +Second Public Keyword With Nested Private Keyword + Nested Private Keyword + +Nested Private Keyword + [Tags] robot:private + No Operation + +Possible Keyword + [Tags] robot:private + No Operation diff --git a/atest/testdata/running/private3.resource b/atest/testdata/running/private3.resource new file mode 100644 index 00000000000..2886c5bc483 --- /dev/null +++ b/atest/testdata/running/private3.resource @@ -0,0 +1,4 @@ +*** Keywords *** +Possible Keyword + [Tags] robot:private + No Operation diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 267ae0b17f8..58619ba2b10 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -928,3 +928,29 @@ can also be a variable. [Teardown] ${TEARDOWN} __ `test setup and teardown`_ + +Private user keywords +--------------------- + +You can tag User Keywords as private to indicate that they should only +be used in the file where they are created. + +To achieve this, tag them as `robot:private`. + +.. sourcecode:: robotframework + + *** Keywords *** + Public Keyword + Private Keyword + + Private Keyword + [Tags] robot:private + [Documentation] This is a private keyword. + ... It should only be used in keywords within the same file. + No Operation + +If there is both a public and one or more private User Keywords with the same name +in the current scope, Robot Framework will execute the public one. In addition to that, +a warning will be emitted. + +Private user keywords are new since Robot Framework 5.1 diff --git a/src/robot/libdocpkg/htmlwriter.py b/src/robot/libdocpkg/htmlwriter.py index df025a2a8db..e17951d4984 100644 --- a/src/robot/libdocpkg/htmlwriter.py +++ b/src/robot/libdocpkg/htmlwriter.py @@ -33,4 +33,4 @@ def write(self, line): self.output.write('<script type="text/javascript">\n' 'libdoc = %s\n' '</script>\n' - % self.libdoc.to_json()) + % self.libdoc.to_json(include_private=False)) diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 31cd9ef3a89..3eb45e8ac79 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -108,7 +108,7 @@ def convert_docs_to_html(self): type_doc.doc = formatter.html(type_doc.doc) self.doc_format = 'HTML' - def to_dictionary(self): + def to_dictionary(self, include_private=False): return { 'specversion': 1, 'name': self.name, @@ -122,7 +122,8 @@ def to_dictionary(self): 'lineno': self.lineno, 'tags': list(self.all_tags), 'inits': [init.to_dictionary() for init in self.inits], - 'keywords': [kw.to_dictionary() for kw in self.keywords], + 'keywords': [kw.to_dictionary() for kw in self.keywords + if include_private or not kw.private], # 'dataTypes' was deprecated in RF 5, 'typedoc' should be used instead. 'dataTypes': self._get_data_types(self.type_docs), 'typedocs': [t.to_dictionary() for t in sorted(self.type_docs)] @@ -136,8 +137,8 @@ def _get_data_types(self, types): 'typedDicts': [t.to_dictionary(legacy=True) for t in typed_dicts] } - def to_json(self, indent=None): - data = self.to_dictionary() + def to_json(self, indent=None, include_private=True): + data = self.to_dictionary(include_private) return json.dumps(data, indent=indent) @@ -171,6 +172,10 @@ def _doc_to_shortdoc(self): def shortdoc(self, shortdoc): self._shortdoc = shortdoc + @property + def private(self): + return 'robot:private' in self.tags + @property def deprecated(self): return self.doc.startswith('*DEPRECATED') and '*' in self.doc[1:] diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 26d0722e517..0dcf4cada6c 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -108,6 +108,13 @@ def user_keyword(self, handler): self.namespace.end_user_keyword() self.user_keywords.pop() + def warn_on_invalid_private_call(self, handler): + if 'robot:private' in handler.tags: + parent = self.user_keywords[-1] if self.user_keywords else None + if not parent or parent.source != handler.source: + self.warn(f"Keyword '{handler.longname}' is private and should only " + f"be called by keywords in the same file.") + @contextmanager def timeout(self, timeout): self._add_timeout(timeout) diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 5421c33303c..d1be32e11ec 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -326,6 +326,7 @@ def _get_runner_from_resource_files(self, name): return None if len(found) > 1: found = self._get_runner_based_on_search_order(found) + found = self._handle_private_runners(found) if len(found) == 1: return found[0] self._raise_multiple_keywords_found(name, found) @@ -343,6 +344,42 @@ def _get_runner_from_libraries(self, name): return found[0] self._raise_multiple_keywords_found(name, found) + def _handle_private_runners(self, runners): + sum_public = sum("robot:private" not in runner.tags for runner in runners) + sum_private = sum("robot:private" in runner.tags for runner in runners) + + if sum_public == len(runners) or sum_private == len(runners) or sum_public > 1: + return runners + + for runner in runners: + if 'robot:private' not in runner.tags: + public_runner = runner + private_runners = runners.copy() + private_runners.remove(public_runner) + break + + self._public_and_private_keyword_conflict_warning(public_runner, private_runners) + return [public_runner] + + def _public_and_private_keyword_conflict_warning(self, public_runner, private_runners): + private_runners_str = "" + for runner in private_runners: + if runner != private_runners[-1]: + private_runners_str += f"'{runner.longname}' / " + else: + private_runners_str += f"'{runner.longname}'" + warning = Message( + f"There were both public and private keyword found with the name '{public_runner.name}', " + f"'{public_runner.longname}' being public and {private_runners_str} being private. " + f"The public keyword is used. To select explicitly, and to get rid of this warning, " + f"use either '{public_runner.longname}' or {private_runners_str}.", + level='WARN' + ) + if public_runner.pre_run_messages: + public_runner.pre_run_messages.append(warning) + else: + public_runner.pre_run_messages = [warning] + def _get_runner_based_on_search_order(self, runners): for libname in self.search_order: for runner in runners: diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 7516e2f4716..2ee9b6ab2e3 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -33,6 +33,7 @@ class UserKeywordRunner: def __init__(self, handler, name=None): self._handler = handler self.name = name or handler.name + self.pre_run_messages = None @property def longname(self): @@ -43,6 +44,10 @@ def longname(self): def libname(self): return self._handler.libname + @property + def tags(self): + return self._handler.tags + @property def arguments(self): """:rtype: :py:class:`robot.running.arguments.ArgumentSpec`""" @@ -52,6 +57,7 @@ def run(self, kw, context, run=True): assignment = VariableAssignment(kw.assign) result = self._get_result(kw, assignment, context.variables) with StatusReporter(kw, result, context, run): + context.warn_on_invalid_private_call(self._handler) with assignment.assigner(context) as assigner: if run: return_value = self._run(context, kw.args, result) @@ -72,6 +78,9 @@ def _get_result(self, kw, assignment, variables): type=kw.type) def _run(self, context, args, result): + if self.pre_run_messages: + for message in self.pre_run_messages: + context.output.message(message) variables = context.variables args = self._resolve_arguments(args, variables) with context.user_keyword(self._handler): @@ -211,6 +220,9 @@ def dry_run(self, kw, context): self._dry_run(context, kw.args, result) def _dry_run(self, context, args, result): + if self.pre_run_messages: + for message in self.pre_run_messages: + context.output.message(message) self._resolve_arguments(args) with context.user_keyword(self._handler): timeout = self._get_timeout() From a9bc94e690c885e1d80a99a2ee06b0a963f65d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 1 Jul 2022 01:27:51 +0300 Subject: [PATCH 0068/1592] Test Keyword Tags setting with Libdoc --- atest/robot/libdoc/cli.robot | 2 +- atest/robot/libdoc/resource_file.robot | 15 ++++++++++----- atest/testdata/libdoc/resource.resource | 11 +++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/atest/robot/libdoc/cli.robot b/atest/robot/libdoc/cli.robot index 5974c8217dd..bbd9f5ed867 100644 --- a/atest/robot/libdoc/cli.robot +++ b/atest/robot/libdoc/cli.robot @@ -72,7 +72,7 @@ Resource file in PYTHONPATH [Template] NONE Run Libdoc And Parse Output --pythonpath ${DATADIR}/libdoc resource.resource Name Should Be resource - Keyword Name Should Be 0 Yay, I got new extension! + Keyword Name Should Be -1 Yay, I got new extension! Non-existing resource [Template] NONE diff --git a/atest/robot/libdoc/resource_file.robot b/atest/robot/libdoc/resource_file.robot index fd5d5a3ddde..cc1167f3c71 100644 --- a/atest/robot/libdoc/resource_file.robot +++ b/atest/robot/libdoc/resource_file.robot @@ -116,8 +116,13 @@ Keyword Source Info Run Libdoc And Parse Output ${TESTDATADIR}/resource.resource Source Should Be ${TESTDATADIR}/resource.resource Lineno Should Be 1 - Keyword Name Should Be 0 Yay, I got new extension! - Keyword Arguments Should Be 0 Awesome!! - Keyword Doc Should Be 0 Yeah!!! - Keyword Should Not Have Source 0 - Keyword Lineno Should Be 0 2 + Keyword Name Should Be 2 Yay, I got new extension! + Keyword Arguments Should Be 2 Awesome!! + Keyword Doc Should Be 2 Yeah!!! + Keyword Should Not Have Source 2 + Keyword Lineno Should Be 2 5 + +Keyword Tags setting + Keyword Tags Should Be 0 keyword own tags + Keyword Tags Should Be 1 in doc keyword own tags + Keyword Tags Should Be 2 keyword tags diff --git a/atest/testdata/libdoc/resource.resource b/atest/testdata/libdoc/resource.resource index 12f1aef861a..7299dac47c9 100644 --- a/atest/testdata/libdoc/resource.resource +++ b/atest/testdata/libdoc/resource.resource @@ -1,5 +1,16 @@ +*** Settings *** +Keyword Tags keyword tags + *** Keywords *** Yay, I got new extension! [Arguments] ${Awesome!!} [Documentation] Yeah!!! No operation + +Own tags + [Tags] own + No operation + +Tags in documentation + [Documentation] Tags: own, in doc + No operation From 92039b7a1416318a255f5535c8a8ff201b3b6472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 4 Jul 2022 13:07:28 +0300 Subject: [PATCH 0069/1592] Speed up checking is user keyword private. Checking do user keywords have special `robot:private` tag introduced by PR #4345 and issue #430 is a bit slow. This change avoids it in the common case where keywords doesn't have tags at all. Need to stil later check could checking for special tags made faster in general. --- src/robot/running/context.py | 9 ++++----- src/robot/running/userkeyword.py | 9 +++++++++ src/robot/running/userkeywordrunner.py | 3 ++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 0dcf4cada6c..72faabec349 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -109,11 +109,10 @@ def user_keyword(self, handler): self.user_keywords.pop() def warn_on_invalid_private_call(self, handler): - if 'robot:private' in handler.tags: - parent = self.user_keywords[-1] if self.user_keywords else None - if not parent or parent.source != handler.source: - self.warn(f"Keyword '{handler.longname}' is private and should only " - f"be called by keywords in the same file.") + parent = self.user_keywords[-1] if self.user_keywords else None + if not parent or parent.source != handler.source: + self.warn(f"Keyword '{handler.longname}' is private and should only " + f"be called by keywords in the same file.") @contextmanager def timeout(self, timeout): diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index 725aac0096c..1aa49c8c601 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -92,6 +92,15 @@ def longname(self): def shortdoc(self): return getshortdoc(self.doc) + @property + def private(self): + # TODO: Make checking is keyword private faster. + # `'robot:private' in self.tags` is a bit slow. First checking do we + # have tags avoids it in the common case but not if keywords have tags. + # Tags objects probably should make checking special tags like this faster + # in general. That would then speed up other similar usages as well. + return bool(self.tags and 'robot:private' in self.tags) + def create_runner(self, name): return UserKeywordRunner(self) diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 2ee9b6ab2e3..20d5b8d892d 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -57,7 +57,8 @@ def run(self, kw, context, run=True): assignment = VariableAssignment(kw.assign) result = self._get_result(kw, assignment, context.variables) with StatusReporter(kw, result, context, run): - context.warn_on_invalid_private_call(self._handler) + if self._handler.private: + context.warn_on_invalid_private_call(self._handler) with assignment.assigner(context) as assigner: if run: return_value = self._run(context, kw.args, result) From 659ee50c3da30a25d8efab2d3f72ea0fac03146c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 4 Jul 2022 13:51:07 +0300 Subject: [PATCH 0070/1592] \r\n -> \n --- atest/robot/running/private.robot | 134 +++++++++++++++--------------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/atest/robot/running/private.robot b/atest/robot/running/private.robot index 1b8d9f33f40..6709f05a4fb 100644 --- a/atest/robot/running/private.robot +++ b/atest/robot/running/private.robot @@ -1,67 +1,67 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} running/private.robot -Resource atest_resource.robot - -*** Test Cases *** -Valid Usage With Local Keyword - ${tc}= Check Test Case ${TESTNAME} - Length Should Be ${tc.body[0].body} 1 - -Invalid Usage With Local Keyword - ${tc}= Check Test Case ${TESTNAME} - Private Call Warning Should Be Private Keyword ${tc.body[0].body[0]} ${ERRORS[0]} - Length Should Be ${tc.body[0].body} 2 - -Valid Usage With Resource Keyword - ${tc}= Check Test Case ${TESTNAME} - Length Should Be ${tc.body[0].body} 1 - -Invalid Usage With Resource Keyword - ${tc}= Check Test Case ${TESTNAME} - Private Call Warning Should Be private.Private Keyword In Resource ${tc.body[0].body[0]} ${ERRORS[1]} - Length Should Be ${tc.body[0].body} 2 - -Invalid Usage in Resource File - ${tc}= Check Test Case ${TESTNAME} - Private Call Warning Should Be private2.Private Keyword In Resource 2 ${tc.body[0].body[0].body[0]} ${ERRORS[2]} - Length Should Be ${tc.body[0].body[0].body} 2 - -Keyword With Same Name Should Resolve Public Keyword - ${tc}= Check Test Case ${TESTNAME} - ${warning}= Catenate - ... There were both public and private keyword found with the name 'Same Name', - ... 'private.Same Name' being public and 'private2.Same Name' being private. - ... The public keyword is used. - ... To select explicitly, and to get rid of this warning, - ... use either 'private.Same Name' or 'private2.Same Name'. - Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0]} ${ERRORS[3]} - Length Should Be ${tc.body[0].body} 2 - -If Both Keywords Are Private Raise Multiple Keywords Found - Check Test Case ${TESTNAME} - -If One Keyword Is Public And Multiple Private Keywords Run Public And Warn - ${tc}= Check Test Case ${TESTNAME} - ${warning}= Catenate - ... There were both public and private keyword found with the name 'Possible Keyword', - ... 'private.Possible Keyword' being public and 'private2.Possible Keyword' / 'private3.Possible Keyword' being private. - ... The public keyword is used. - ... To select explicitly, and to get rid of this warning, - ... use either 'private.Possible Keyword' or 'private2.Possible Keyword' / 'private3.Possible Keyword'. - Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0].body[0]} ${ERRORS[4]} - Length Should Be ${tc.body[0].body[0].body} 2 - -*** Keywords *** -Private Call Warning Should Be - [Arguments] ${name} @{messages} - FOR ${message} IN @{messages} - Check Log Message ${message} - ... Keyword '${name}' is private and should only be called by keywords in the same file. - ... WARN - END - -Public And Private Keyword Conflict Warning Should Be - [Arguments] ${warning} @{messages} - FOR ${message} IN @{messages} - Check Log Message ${message} ${warning} WARN - END +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/private.robot +Resource atest_resource.robot + +*** Test Cases *** +Valid Usage With Local Keyword + ${tc}= Check Test Case ${TESTNAME} + Length Should Be ${tc.body[0].body} 1 + +Invalid Usage With Local Keyword + ${tc}= Check Test Case ${TESTNAME} + Private Call Warning Should Be Private Keyword ${tc.body[0].body[0]} ${ERRORS[0]} + Length Should Be ${tc.body[0].body} 2 + +Valid Usage With Resource Keyword + ${tc}= Check Test Case ${TESTNAME} + Length Should Be ${tc.body[0].body} 1 + +Invalid Usage With Resource Keyword + ${tc}= Check Test Case ${TESTNAME} + Private Call Warning Should Be private.Private Keyword In Resource ${tc.body[0].body[0]} ${ERRORS[1]} + Length Should Be ${tc.body[0].body} 2 + +Invalid Usage in Resource File + ${tc}= Check Test Case ${TESTNAME} + Private Call Warning Should Be private2.Private Keyword In Resource 2 ${tc.body[0].body[0].body[0]} ${ERRORS[2]} + Length Should Be ${tc.body[0].body[0].body} 2 + +Keyword With Same Name Should Resolve Public Keyword + ${tc}= Check Test Case ${TESTNAME} + ${warning}= Catenate + ... There were both public and private keyword found with the name 'Same Name', + ... 'private.Same Name' being public and 'private2.Same Name' being private. + ... The public keyword is used. + ... To select explicitly, and to get rid of this warning, + ... use either 'private.Same Name' or 'private2.Same Name'. + Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0]} ${ERRORS[3]} + Length Should Be ${tc.body[0].body} 2 + +If Both Keywords Are Private Raise Multiple Keywords Found + Check Test Case ${TESTNAME} + +If One Keyword Is Public And Multiple Private Keywords Run Public And Warn + ${tc}= Check Test Case ${TESTNAME} + ${warning}= Catenate + ... There were both public and private keyword found with the name 'Possible Keyword', + ... 'private.Possible Keyword' being public and 'private2.Possible Keyword' / 'private3.Possible Keyword' being private. + ... The public keyword is used. + ... To select explicitly, and to get rid of this warning, + ... use either 'private.Possible Keyword' or 'private2.Possible Keyword' / 'private3.Possible Keyword'. + Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0].body[0]} ${ERRORS[4]} + Length Should Be ${tc.body[0].body[0].body} 2 + +*** Keywords *** +Private Call Warning Should Be + [Arguments] ${name} @{messages} + FOR ${message} IN @{messages} + Check Log Message ${message} + ... Keyword '${name}' is private and should only be called by keywords in the same file. + ... WARN + END + +Public And Private Keyword Conflict Warning Should Be + [Arguments] ${warning} @{messages} + FOR ${message} IN @{messages} + Check Log Message ${message} ${warning} WARN + END From 0f010672e5960fab716caadfb24a173056474e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 4 Jul 2022 14:16:56 +0300 Subject: [PATCH 0071/1592] Enhance documentation of private user keywords. #430 Also mention `robot:private` in the list of reserved tas. --- .../CreatingTestData/CreatingTestCases.rst | 4 ++++ .../CreatingTestData/CreatingUserKeywords.rst | 22 ++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index ef08bb1c3ad..da5fc869a62 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -721,6 +721,9 @@ to be added in the future. `robot:exclude` Mark test to be `unconditionally excluded`__. +`robot:private` + Mark keyword to be private__. + `robot:no-dry-run` Mark keyword not to be executed in the `dry run`_ mode. @@ -732,6 +735,7 @@ __ `Disabling continue-on-failure using tags`_ __ `Automatically skipping failed tests`_ __ `Skipping before execution`_ __ `By tag names`_ +__ `Private user keywords`_ __ `stopping test execution gracefully`_ As of RobotFramework 4.1, reserved tags are suppressed by default in diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 58619ba2b10..47e62f2e33f 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -932,10 +932,8 @@ __ `test setup and teardown`_ Private user keywords --------------------- -You can tag User Keywords as private to indicate that they should only -be used in the file where they are created. - -To achieve this, tag them as `robot:private`. +User keywords can be tagged__ with a special `robot:private` tag to indicate +that they should only be used in the file where they are created: .. sourcecode:: robotframework @@ -945,12 +943,16 @@ To achieve this, tag them as `robot:private`. Private Keyword [Tags] robot:private - [Documentation] This is a private keyword. - ... It should only be used in keywords within the same file. No Operation -If there is both a public and one or more private User Keywords with the same name -in the current scope, Robot Framework will execute the public one. In addition to that, -a warning will be emitted. +Using the `robot:private` tag does not outright prevent using the keyword +outside the file where it is created, but such usages will cause a warning. +If there is both a public and a private keyword with the same name, +the public one will be used but also this situation causes a warning. + +Private keywords are included in spec files created by Libdoc_ but not in its +HTML output files. -Private user keywords are new since Robot Framework 5.1 +.. note:: Private user keywords are new in Robot Framework 5.1. + +__ `User keyword tags`_ From 2f5c1da83143f8d19ecd496ff3cb793b37695312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 4 Jul 2022 14:19:10 +0300 Subject: [PATCH 0072/1592] Smallish cleanup related to private user keywords. #430 --- atest/robot/running/private.robot | 18 ++++---- src/robot/running/namespace.py | 62 ++++++++++---------------- src/robot/running/userkeywordrunner.py | 6 ++- 3 files changed, 37 insertions(+), 49 deletions(-) diff --git a/atest/robot/running/private.robot b/atest/robot/running/private.robot index 6709f05a4fb..91d7b4dd328 100644 --- a/atest/robot/running/private.robot +++ b/atest/robot/running/private.robot @@ -29,11 +29,10 @@ Invalid Usage in Resource File Keyword With Same Name Should Resolve Public Keyword ${tc}= Check Test Case ${TESTNAME} ${warning}= Catenate - ... There were both public and private keyword found with the name 'Same Name', - ... 'private.Same Name' being public and 'private2.Same Name' being private. - ... The public keyword is used. - ... To select explicitly, and to get rid of this warning, - ... use either 'private.Same Name' or 'private2.Same Name'. + ... Both public and private keywords with name 'Same Name' found. + ... The public keyword 'private.Same Name' is used and + ... private keyword 'private2.Same Name' ignored. + ... To select explicitly, and to get rid of this warning, use the long name of the keyword. Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0]} ${ERRORS[3]} Length Should Be ${tc.body[0].body} 2 @@ -43,11 +42,10 @@ If Both Keywords Are Private Raise Multiple Keywords Found If One Keyword Is Public And Multiple Private Keywords Run Public And Warn ${tc}= Check Test Case ${TESTNAME} ${warning}= Catenate - ... There were both public and private keyword found with the name 'Possible Keyword', - ... 'private.Possible Keyword' being public and 'private2.Possible Keyword' / 'private3.Possible Keyword' being private. - ... The public keyword is used. - ... To select explicitly, and to get rid of this warning, - ... use either 'private.Possible Keyword' or 'private2.Possible Keyword' / 'private3.Possible Keyword'. + ... Both public and private keywords with name 'Possible Keyword' found. + ... The public keyword 'private.Possible Keyword' is used and + ... private keywords 'private2.Possible Keyword' and 'private3.Possible Keyword' ignored. + ... To select explicitly, and to get rid of this warning, use the long name of the keyword. Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0].body[0]} ${ERRORS[4]} Length Should Be ${tc.body[0].body[0].body} 2 diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index d1be32e11ec..05feea0a7e2 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -21,8 +21,8 @@ from robot.errors import DataError, KeywordError from robot.libraries import STDLIBS from robot.output import LOGGER, Message -from robot.utils import (RecommendationFinder, eq, find_file, is_string, - normalize, printable_name, seq2str2) +from robot.utils import (RecommendationFinder, eq, find_file, is_string, normalize, + plural_or_not as s, printable_name, seq2str, seq2str2) from .importer import ImportCache, Importer from .model import Import @@ -326,10 +326,11 @@ def _get_runner_from_resource_files(self, name): return None if len(found) > 1: found = self._get_runner_based_on_search_order(found) - found = self._handle_private_runners(found) + if len(found) > 1: + found = self._handle_private_user_keywords(found, name) if len(found) == 1: return found[0] - self._raise_multiple_keywords_found(name, found) + self._raise_multiple_keywords_found(found, name) def _get_runner_from_libraries(self, name): found = [lib.handlers.create_runner(name) for lib in self.libraries.values() @@ -342,43 +343,28 @@ def _get_runner_from_libraries(self, name): found = self._filter_stdlib_runner(*found) if len(found) == 1: return found[0] - self._raise_multiple_keywords_found(name, found) - - def _handle_private_runners(self, runners): - sum_public = sum("robot:private" not in runner.tags for runner in runners) - sum_private = sum("robot:private" in runner.tags for runner in runners) + self._raise_multiple_keywords_found(found, name) - if sum_public == len(runners) or sum_private == len(runners) or sum_public > 1: + def _handle_private_user_keywords(self, runners, used_as): + public = [r for r in runners if not r.private] + if len(public) != 1: return runners + private = [r for r in runners if r.private] + self._public_and_private_keyword_warning(public[0], private, used_as) + return public - for runner in runners: - if 'robot:private' not in runner.tags: - public_runner = runner - private_runners = runners.copy() - private_runners.remove(public_runner) - break - - self._public_and_private_keyword_conflict_warning(public_runner, private_runners) - return [public_runner] - - def _public_and_private_keyword_conflict_warning(self, public_runner, private_runners): - private_runners_str = "" - for runner in private_runners: - if runner != private_runners[-1]: - private_runners_str += f"'{runner.longname}' / " - else: - private_runners_str += f"'{runner.longname}'" + def _public_and_private_keyword_warning(self, public, private, used_as): warning = Message( - f"There were both public and private keyword found with the name '{public_runner.name}', " - f"'{public_runner.longname}' being public and {private_runners_str} being private. " - f"The public keyword is used. To select explicitly, and to get rid of this warning, " - f"use either '{public_runner.longname}' or {private_runners_str}.", + f"Both public and private keywords with name '{used_as}' found. The public " + f"keyword '{public.longname}' is used and private keyword{s(private)} " + f"{seq2str(p.longname for p in private)} ignored. To select explicitly, " + f"and to get rid of this warning, use the long name of the keyword.", level='WARN' ) - if public_runner.pre_run_messages: - public_runner.pre_run_messages.append(warning) + if public.pre_run_messages: + public.pre_run_messages.append(warning) else: - public_runner.pre_run_messages = [warning] + public.pre_run_messages = [warning] def _get_runner_based_on_search_order(self, runners): for libname in self.search_order: @@ -423,7 +409,7 @@ def _get_explicit_runner(self, name): for owner_name, kw_name in self._yield_owner_and_kw_names(name): found.extend(self._find_keywords(owner_name, kw_name)) if len(found) > 1: - self._raise_multiple_keywords_found(name, found, implicit=False) + self._raise_multiple_keywords_found(found, name, implicit=False) return found[0] if found else None def _yield_owner_and_kw_names(self, full_name): @@ -436,11 +422,11 @@ def _find_keywords(self, owner_name, name): for owner in chain(self.libraries.values(), self.resources.values()) if eq(owner.name, owner_name) and name in owner.handlers] - def _raise_multiple_keywords_found(self, name, found, implicit=True): - error = f"Multiple keywords with name '{name}' found" + def _raise_multiple_keywords_found(self, runners, used_as, implicit=True): + error = f"Multiple keywords with name '{used_as}' found" if implicit: error += ". Give the full name of the keyword you want to use" - names = sorted(runner.longname for runner in found) + names = sorted(r.longname for r in runners) raise KeywordError('\n '.join([error+':'] + names)) diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 20d5b8d892d..e659d9c9ace 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -48,6 +48,10 @@ def libname(self): def tags(self): return self._handler.tags + @property + def private(self): + return self._handler.private + @property def arguments(self): """:rtype: :py:class:`robot.running.arguments.ArgumentSpec`""" @@ -57,7 +61,7 @@ def run(self, kw, context, run=True): assignment = VariableAssignment(kw.assign) result = self._get_result(kw, assignment, context.variables) with StatusReporter(kw, result, context, run): - if self._handler.private: + if self.private: context.warn_on_invalid_private_call(self._handler) with assignment.assigner(context) as assigner: if run: From db5c5c3f1e8eb583bf3ff294ce167d5e3f62751d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 4 Jul 2022 19:46:25 +0300 Subject: [PATCH 0073/1592] Fix information about deprecated keywords in Libdoc spec files - Info written also to JSON spec files. Only written with keywords that are actually deprecated similarly as with XML specs. - Read info from the doc only when building info from Robot keywords. When processing specs, read info directly from them. This avoids problems when doc is convetred to HTML. Fixes #4387. --- atest/robot/libdoc/json_output.robot | 8 +++++++- atest/robot/libdoc/libdoc_resource.robot | 2 +- atest/robot/libdoc/spec_library.robot | 8 ++++++++ atest/testdata/libdoc/DynamicLibrary.json | 1 + atest/testdata/libdoc/ExampleSpec.xml | 4 ++-- doc/schema/libdoc.json | 4 ++++ doc/schema/libdoc_json_schema.py | 1 + src/robot/libdocpkg/jsonbuilder.py | 1 + src/robot/libdocpkg/model.py | 14 +++++++------- src/robot/libdocpkg/robotbuilder.py | 1 + src/robot/libdocpkg/xmlbuilder.py | 3 +-- 11 files changed, 34 insertions(+), 13 deletions(-) diff --git a/atest/robot/libdoc/json_output.robot b/atest/robot/libdoc/json_output.robot index e7bbf486051..a9814c09fdf 100644 --- a/atest/robot/libdoc/json_output.robot +++ b/atest/robot/libdoc/json_output.robot @@ -107,10 +107,16 @@ User keyword documentation formatting Private user keyword should be included [Setup] Run Libdoc And Parse Model From JSON ${TESTDATADIR}/resource.robot - [Template] Should Be Equal As Strings ${MODEL}[keywords][-1][name] Private ${MODEL}[keywords][-1][tags] ['robot:private'] +Deprecation + [Setup] Run Libdoc And Parse Model From JSON ${TESTDATADIR}/Deprecation.py + ${MODEL}[keywords][0][deprecated] True + ${MODEL}[keywords][1][deprecated] True + ${MODEL['keywords'][2].get('deprecated')} None + ${MODEL['keywords'][3].get('deprecated')} None + *** Keywords *** Verify Argument Models [Arguments] ${arg_models} @{expected_reprs} diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index 86f52285ab1..0039563cfdc 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -199,7 +199,7 @@ Keyword Tags Should Be Specfile Tags Should Be [Arguments] @{expected} - ${tags} Get Elements Texts ${LIBDOC} xpath=tags/tag + ${tags}= Get Elements Texts ${LIBDOC} xpath=tags/tag Should Be Equal ${tags} ${expected} Keyword Source Should Be diff --git a/atest/robot/libdoc/spec_library.robot b/atest/robot/libdoc/spec_library.robot index d64dc0137c6..d7aaf2e7c1c 100644 --- a/atest/robot/libdoc/spec_library.robot +++ b/atest/robot/libdoc/spec_library.robot @@ -86,6 +86,11 @@ Keyword Tags Keyword Tags Should Be 1 Keyword Tags Should Be 2 +Keyword Deprecation + Keyword Should Not Be Deprecated 0 + Keyword Should Be Deprecated 1 + Keyword Should Not Be Deprecated 2 + Keyword Source Info Keyword Should Not Have Source 0 Keyword Should Not Have Lineno 0 @@ -138,6 +143,9 @@ Test Everything Keyword Tags Should Be 0 tag1 tag2 Keyword Tags Should Be 1 Keyword Tags Should Be 2 + Keyword Should Not Be Deprecated 0 + Keyword Should Be Deprecated 1 + Keyword Should Not Be Deprecated 2 Keyword Should Not Have Source 0 Keyword Should Not Have Lineno 0 Keyword Should Not Have Source 1 diff --git a/atest/testdata/libdoc/DynamicLibrary.json b/atest/testdata/libdoc/DynamicLibrary.json index 0daf078e59f..d0fcca2cf53 100644 --- a/atest/testdata/libdoc/DynamicLibrary.json +++ b/atest/testdata/libdoc/DynamicLibrary.json @@ -52,6 +52,7 @@ "doc": "<p>Dummy documentation for <a href=\"#0\" class=\"name\">0</a>.</p>\n<p>Neither <a href=\"#Keyword%201\" class=\"name\">Keyword 1</a> or <a href=\"#KW%202\" class=\"name\">KW 2</a> do anything really interesting. They do, however, accept some <span class=\"name\">arguments</span>. Neither <a href=\"#Introduction\" class=\"name\">introduction</a> nor <a href=\"#Importing\" class=\"name\">importing</a> contain any more information.</p>\n<p>Examples:</p>\n<table border=\"1\">\n<tr>\n<td>Keyword 1</td>\n<td>arg</td>\n<td></td>\n</tr>\n<tr>\n<td>KW 2</td>\n<td>arg</td>\n<td>arg 2</td>\n</tr>\n<tr>\n<td>KW 2</td>\n<td>arg</td>\n<td>arg 3</td>\n</tr>\n</table>\n<hr>\n<p><a href=\"http://robotframework.org\">http://robotframework.org</a></p>", "shortdoc": "Dummy documentation for `0`.", "tags": [], + "deprecated": true, "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DynamicLibrary.py", "lineno": -1 }, diff --git a/atest/testdata/libdoc/ExampleSpec.xml b/atest/testdata/libdoc/ExampleSpec.xml index da167fd71c0..17780effc90 100644 --- a/atest/testdata/libdoc/ExampleSpec.xml +++ b/atest/testdata/libdoc/ExampleSpec.xml @@ -35,7 +35,7 @@ This library is only used in an example and it doesn't do anything useful.</doc> </init> </inits> <keywords> -<kw name="Keyword"> +<kw name="Keyword" deprecated="false"> <arguments repr="arg"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="arg"> <name>arg</name> @@ -54,7 +54,7 @@ See `My Keyword` for no more information.</doc> <tag>tag2</tag> </tags> </kw> -<kw name="My Keyword" lineno="42"> +<kw name="My Keyword" lineno="42" deprecated="true"> <arguments repr=""> </arguments> <doc>Does nothing & <doc> has "stuff" to 'escape'!! and ignored indentation diff --git a/doc/schema/libdoc.json b/doc/schema/libdoc.json index 403113cf98d..43c4840d281 100644 --- a/doc/schema/libdoc.json +++ b/doc/schema/libdoc.json @@ -228,6 +228,10 @@ "type": "string" } }, + "deprecated": { + "title": "Deprecated", + "type": "boolean" + }, "source": { "title": "Source", "type": "string", diff --git a/doc/schema/libdoc_json_schema.py b/doc/schema/libdoc_json_schema.py index ae8b0953e8e..5d57b94122a 100755 --- a/doc/schema/libdoc_json_schema.py +++ b/doc/schema/libdoc_json_schema.py @@ -77,6 +77,7 @@ class Keyword(BaseModel): doc: str shortdoc: str tags: List[str] + deprecated: Optional[bool] source: Path lineno: int diff --git a/src/robot/libdocpkg/jsonbuilder.py b/src/robot/libdocpkg/jsonbuilder.py index 71be70aa29e..7ab3f45388d 100644 --- a/src/robot/libdocpkg/jsonbuilder.py +++ b/src/robot/libdocpkg/jsonbuilder.py @@ -59,6 +59,7 @@ def _create_keyword(self, data): doc=data['doc'], shortdoc=data['shortdoc'], tags=data['tags'], + deprecated=data.get('deprecated', False), source=data['source'], lineno=int(data.get('lineno', -1))) self._create_arguments(data['args'], kw) diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 3eb45e8ac79..723f0a33426 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -144,13 +144,14 @@ def to_json(self, indent=None, include_private=True): class KeywordDoc(Sortable): - def __init__(self, name='', args=None, doc='', shortdoc='', tags=(), source=None, - lineno=-1, parent=None): + def __init__(self, name='', args=None, doc='', shortdoc='', tags=(), + deprecated=False, source=None, lineno=-1, parent=None): self.name = name self.args = args or ArgumentSpec() self.doc = doc self._shortdoc = shortdoc self.tags = Tags(tags) + self.deprecated = deprecated self.source = source self.lineno = lineno self.parent = parent @@ -176,16 +177,12 @@ def shortdoc(self, shortdoc): def private(self): return 'robot:private' in self.tags - @property - def deprecated(self): - return self.doc.startswith('*DEPRECATED') and '*' in self.doc[1:] - @property def _sort_key(self): return self.name.lower() def to_dictionary(self): - return { + data = { 'name': self.name, 'args': [self._arg_to_dict(arg) for arg in self.args], 'doc': self.doc, @@ -194,6 +191,9 @@ def to_dictionary(self): 'source': self.source, 'lineno': self.lineno } + if self.deprecated: + data['deprecated'] = True + return data def _arg_to_dict(self, arg): return { diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index 580d047173c..8c96a68041d 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -129,6 +129,7 @@ def build_keyword(self, kw): args=kw.arguments, doc=doc, tags=tags, + deprecated=doc.startswith('*DEPRECATED') and '*' in doc[1:], source=kw.source, lineno=kw.lineno) diff --git a/src/robot/libdocpkg/xmlbuilder.py b/src/robot/libdocpkg/xmlbuilder.py index bdc23f07fcd..435b703b83e 100644 --- a/src/robot/libdocpkg/xmlbuilder.py +++ b/src/robot/libdocpkg/xmlbuilder.py @@ -61,12 +61,11 @@ def _create_keywords(self, spec, path, lib_source): return [self._create_keyword(elem, lib_source) for elem in spec.findall(path)] def _create_keyword(self, elem, lib_source): - # "deprecated" attribute isn't read because it is read from the doc - # automatically. That should probably be changed at some point. kw = KeywordDoc(name=elem.get('name', ''), doc=elem.find('doc').text or '', shortdoc=elem.find('shortdoc').text or '', tags=[t.text for t in elem.findall('tags/tag')], + deprecated=elem.get('deprecated', 'false') == 'true', source=elem.get('source') or lib_source, lineno=int(elem.get('lineno', -1))) self._create_arguments(elem, kw) From 063c61cde3856c5086f67603ee132bfb86f4c871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 4 Jul 2022 20:09:23 +0300 Subject: [PATCH 0074/1592] Add `private` attribute to Libdoc spec files. Private user keywords were added by #430 and now this info is directly stored in Libdoc spec files as well. This makes it easier for external tools to check are keywords private. This is also consistent with specs containing `deprecated` info. --- atest/robot/libdoc/json_output.robot | 6 ++++-- atest/robot/libdoc/libdoc_resource.robot | 10 ++++++++++ atest/robot/libdoc/spec_library.robot | 8 ++++++++ atest/testdata/libdoc/DynamicLibrary.json | 1 + atest/testdata/libdoc/ExampleSpec.xml | 4 ++-- doc/schema/libdoc.json | 4 ++++ doc/schema/libdoc.xsd | 2 +- doc/schema/libdoc_json_schema.py | 1 + src/robot/libdocpkg/jsonbuilder.py | 1 + src/robot/libdocpkg/model.py | 9 ++++----- src/robot/libdocpkg/robotbuilder.py | 1 + src/robot/libdocpkg/xmlbuilder.py | 1 + src/robot/libdocpkg/xmlwriter.py | 2 ++ 13 files changed, 40 insertions(+), 10 deletions(-) diff --git a/atest/robot/libdoc/json_output.robot b/atest/robot/libdoc/json_output.robot index a9814c09fdf..95b7d32af18 100644 --- a/atest/robot/libdoc/json_output.robot +++ b/atest/robot/libdoc/json_output.robot @@ -107,8 +107,10 @@ 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][name] Private + ${MODEL}[keywords][-1][tags] ['robot:private'] + ${MODEL}[keywords][-1][private] True + ${MODEL['keywords'][0].get('private')} None Deprecation [Setup] Run Libdoc And Parse Model From JSON ${TESTDATADIR}/Deprecation.py diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index 0039563cfdc..f83ac199c77 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -223,6 +223,16 @@ Keyword Should Not Have Lineno ${kws}= Get Elements ${LIBDOC} xpath=${xpath} Element Should Not Have Attribute ${kws}[${index}] lineno +Keyword Should Be Private + [Arguments] ${index} + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + Element Attribute Should be ${kws}[${index}] private true + +Keyword Should Not Be Private + [Arguments] ${index} + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + Element Should Not Have Attribute ${kws}[${index}] private + Keyword Should Be Deprecated [Arguments] ${index} ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw diff --git a/atest/robot/libdoc/spec_library.robot b/atest/robot/libdoc/spec_library.robot index d7aaf2e7c1c..5bfa0089ef2 100644 --- a/atest/robot/libdoc/spec_library.robot +++ b/atest/robot/libdoc/spec_library.robot @@ -86,6 +86,11 @@ Keyword Tags Keyword Tags Should Be 1 Keyword Tags Should Be 2 +Private Keywords + Keyword Should Not Be Private 0 + Keyword Should Be Private 1 + Keyword Should Not Be Private 2 + Keyword Deprecation Keyword Should Not Be Deprecated 0 Keyword Should Be Deprecated 1 @@ -143,6 +148,9 @@ Test Everything Keyword Tags Should Be 0 tag1 tag2 Keyword Tags Should Be 1 Keyword Tags Should Be 2 + Keyword Should Not Be Private 0 + Keyword Should Be Private 1 + Keyword Should Not Be Private 2 Keyword Should Not Be Deprecated 0 Keyword Should Be Deprecated 1 Keyword Should Not Be Deprecated 2 diff --git a/atest/testdata/libdoc/DynamicLibrary.json b/atest/testdata/libdoc/DynamicLibrary.json index d0fcca2cf53..ba8a52e60ed 100644 --- a/atest/testdata/libdoc/DynamicLibrary.json +++ b/atest/testdata/libdoc/DynamicLibrary.json @@ -52,6 +52,7 @@ "doc": "<p>Dummy documentation for <a href=\"#0\" class=\"name\">0</a>.</p>\n<p>Neither <a href=\"#Keyword%201\" class=\"name\">Keyword 1</a> or <a href=\"#KW%202\" class=\"name\">KW 2</a> do anything really interesting. They do, however, accept some <span class=\"name\">arguments</span>. Neither <a href=\"#Introduction\" class=\"name\">introduction</a> nor <a href=\"#Importing\" class=\"name\">importing</a> contain any more information.</p>\n<p>Examples:</p>\n<table border=\"1\">\n<tr>\n<td>Keyword 1</td>\n<td>arg</td>\n<td></td>\n</tr>\n<tr>\n<td>KW 2</td>\n<td>arg</td>\n<td>arg 2</td>\n</tr>\n<tr>\n<td>KW 2</td>\n<td>arg</td>\n<td>arg 3</td>\n</tr>\n</table>\n<hr>\n<p><a href=\"http://robotframework.org\">http://robotframework.org</a></p>", "shortdoc": "Dummy documentation for `0`.", "tags": [], + "private": true, "deprecated": true, "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DynamicLibrary.py", "lineno": -1 diff --git a/atest/testdata/libdoc/ExampleSpec.xml b/atest/testdata/libdoc/ExampleSpec.xml index 17780effc90..351be0a5ce3 100644 --- a/atest/testdata/libdoc/ExampleSpec.xml +++ b/atest/testdata/libdoc/ExampleSpec.xml @@ -35,7 +35,7 @@ This library is only used in an example and it doesn't do anything useful.</doc> </init> </inits> <keywords> -<kw name="Keyword" deprecated="false"> +<kw name="Keyword" deprecated="false" private="false"> <arguments repr="arg"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="arg"> <name>arg</name> @@ -54,7 +54,7 @@ See `My Keyword` for no more information.</doc> <tag>tag2</tag> </tags> </kw> -<kw name="My Keyword" lineno="42" deprecated="true"> +<kw name="My Keyword" lineno="42" deprecated="true" private="true"> <arguments repr=""> </arguments> <doc>Does nothing & <doc> has "stuff" to 'escape'!! and ignored indentation diff --git a/doc/schema/libdoc.json b/doc/schema/libdoc.json index 43c4840d281..4399a37c7a3 100644 --- a/doc/schema/libdoc.json +++ b/doc/schema/libdoc.json @@ -228,6 +228,10 @@ "type": "string" } }, + "private": { + "title": "Private", + "type": "boolean" + }, "deprecated": { "title": "Deprecated", "type": "boolean" diff --git a/doc/schema/libdoc.xsd b/doc/schema/libdoc.xsd index 91d38a3fc09..00ce4e6750d 100644 --- a/doc/schema/libdoc.xsd +++ b/doc/schema/libdoc.xsd @@ -43,7 +43,6 @@ <xs:element name="shortdoc" type="xs:string" /> </xs:sequence> <xs:attribute name="name" type="xs:string" use="optional" /> - <xs:attribute name="deprecated" type="xs:boolean" use="optional" /> <!-- See the KeywordSpec level comment about "source" and "lineno". --> <xs:attribute name="source" type="xs:string" use="optional" /> <xs:attribute name="lineno" type="xs:positiveInteger" use="optional" /> @@ -56,6 +55,7 @@ <xs:element name="tags" type="Tags" minOccurs="0" /> </xs:sequence> <xs:attribute name="name" type="xs:string" use="required" /> + <xs:attribute name="private" type="xs:boolean" use="optional" /> <xs:attribute name="deprecated" type="xs:boolean" use="optional" /> <!-- See the KeywordSpec level comment about "source" and "lineno". --> <xs:attribute name="source" type="xs:string" use="optional" /> diff --git a/doc/schema/libdoc_json_schema.py b/doc/schema/libdoc_json_schema.py index 5d57b94122a..4f91b29061b 100755 --- a/doc/schema/libdoc_json_schema.py +++ b/doc/schema/libdoc_json_schema.py @@ -77,6 +77,7 @@ class Keyword(BaseModel): doc: str shortdoc: str tags: List[str] + private: Optional[bool] deprecated: Optional[bool] source: Path lineno: int diff --git a/src/robot/libdocpkg/jsonbuilder.py b/src/robot/libdocpkg/jsonbuilder.py index 7ab3f45388d..2a8657862f5 100644 --- a/src/robot/libdocpkg/jsonbuilder.py +++ b/src/robot/libdocpkg/jsonbuilder.py @@ -59,6 +59,7 @@ def _create_keyword(self, data): doc=data['doc'], shortdoc=data['shortdoc'], tags=data['tags'], + private=data.get('private', False), deprecated=data.get('deprecated', False), source=data['source'], lineno=int(data.get('lineno', -1))) diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 723f0a33426..3f435b82f82 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -144,13 +144,14 @@ def to_json(self, indent=None, include_private=True): class KeywordDoc(Sortable): - def __init__(self, name='', args=None, doc='', shortdoc='', tags=(), + def __init__(self, name='', args=None, doc='', shortdoc='', tags=(), private=False, deprecated=False, source=None, lineno=-1, parent=None): self.name = name self.args = args or ArgumentSpec() self.doc = doc self._shortdoc = shortdoc self.tags = Tags(tags) + self.private = private self.deprecated = deprecated self.source = source self.lineno = lineno @@ -173,10 +174,6 @@ def _doc_to_shortdoc(self): def shortdoc(self, shortdoc): self._shortdoc = shortdoc - @property - def private(self): - return 'robot:private' in self.tags - @property def _sort_key(self): return self.name.lower() @@ -191,6 +188,8 @@ def to_dictionary(self): 'source': self.source, 'lineno': self.lineno } + if self.private: + data['private'] = True if self.deprecated: data['deprecated'] = True return data diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index 8c96a68041d..0fc2b794a46 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -129,6 +129,7 @@ def build_keyword(self, kw): args=kw.arguments, doc=doc, tags=tags, + private='robot:private' in tags, deprecated=doc.startswith('*DEPRECATED') and '*' in doc[1:], source=kw.source, lineno=kw.lineno) diff --git a/src/robot/libdocpkg/xmlbuilder.py b/src/robot/libdocpkg/xmlbuilder.py index 435b703b83e..c39d20a46ea 100644 --- a/src/robot/libdocpkg/xmlbuilder.py +++ b/src/robot/libdocpkg/xmlbuilder.py @@ -65,6 +65,7 @@ def _create_keyword(self, elem, lib_source): doc=elem.find('doc').text or '', shortdoc=elem.find('shortdoc').text or '', tags=[t.text for t in elem.findall('tags/tag')], + private=elem.get('private', 'false') == 'true', deprecated=elem.get('deprecated', 'false') == 'true', source=elem.get('source') or lib_source, lineno=int(elem.get('lineno', -1))) diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index 1242059fceb..5e0c352266b 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -91,6 +91,8 @@ def _write_arguments(self, kw, writer): def _get_start_attrs(self, kw, lib_source): attrs = {'name': kw.name} + if kw.private: + attrs['private'] = 'true' if kw.deprecated: attrs['deprecated'] = 'true' self._add_source_info(attrs, kw, lib_source) From 4e8d3e3c1b0dc9cad55e8591b2bbcd651a339382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 4 Jul 2022 21:14:31 +0300 Subject: [PATCH 0075/1592] Speed up checking special `robot:<name>` tags. Fixes #4388. --- src/robot/libdocpkg/robotbuilder.py | 2 +- src/robot/model/tags.py | 30 ++++++++++++++++++-------- src/robot/output/console/dotted.py | 4 ++-- src/robot/running/context.py | 8 +++---- src/robot/running/status.py | 5 +++-- src/robot/running/suiterunner.py | 4 ++-- src/robot/running/userkeyword.py | 7 +----- src/robot/running/userkeywordrunner.py | 2 +- 8 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index 0fc2b794a46..c8de00a3836 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -129,7 +129,7 @@ def build_keyword(self, kw): args=kw.arguments, doc=doc, tags=tags, - private='robot:private' in tags, + private=tags.robot('private'), deprecated=doc.startswith('*DEPRECATED') and '*' in doc[1:], source=kw.source, lineno=kw.lineno) diff --git a/src/robot/model/tags.py b/src/robot/model/tags.py index eb12dfc00f1..4cb61b735b7 100644 --- a/src/robot/model/tags.py +++ b/src/robot/model/tags.py @@ -17,31 +17,41 @@ class Tags: - __slots__ = ['_tags'] + __slots__ = ['_tags', '_reserved'] def __init__(self, tags=None): - self._tags = self._init_tags(tags) + self._tags, self._reserved = self._init_tags(tags) + + def robot(self, name): + """Check do tags contain a special tag in format `robot:<name>`. + + This is same as `'robot:<name>' in tags` but considerably faster. + """ + return name in self._reserved def _init_tags(self, tags): if not tags: - return () + return (), () if is_string(tags): tags = (tags,) return self._normalize(tags) def _normalize(self, tags): normalized = NormalizedDict([(str(t), None) for t in tags], ignore='_') - for remove in '', 'NONE': - if remove in normalized: - normalized.pop(remove) - return tuple(normalized) + if '' in normalized: + del normalized[''] + if 'NONE' in normalized: + del normalized['NONE'] + reserved = tuple(tag.split(':')[1] for tag in normalized._keys + if tag[:6] == 'robot:') + return tuple(normalized), reserved def add(self, tags): - self._tags = self._normalize(tuple(self) + tuple(Tags(tags))) + self.__init__(tuple(self) + tuple(Tags(tags))) def remove(self, tags): tags = TagPatterns(tags) - self._tags = tuple([t for t in self if not tags.match(t)]) + self.__init__([t for t in self if not tags.match(t)]) def match(self, tags): return TagPatterns(tags).match(self) @@ -82,6 +92,8 @@ def __init__(self, patterns): self._patterns = tuple(TagPattern(p) for p in Tags(patterns)) def match(self, tags): + if not self._patterns: + return False tags = tags if isinstance(tags, Tags) else Tags(tags) return any(p.match(tags) for p in self._patterns) diff --git a/src/robot/output/console/dotted.py b/src/robot/output/console/dotted.py index 3a72f688326..9ea4ac8b577 100644 --- a/src/robot/output/console/dotted.py +++ b/src/robot/output/console/dotted.py @@ -46,7 +46,7 @@ def end_test(self, test): self._stdout.write('.') elif test.skipped: self._stdout.highlight('s', 'SKIP') - elif 'robot:exit' in test.tags: + elif test.tags.robot('exit'): self._stdout.write('x') else: self._stdout.highlight('F', 'FAIL') @@ -83,7 +83,7 @@ def report(self, suite): self._stream.write('\n%s\n' % stats.message) def visit_test(self, test): - if test.failed and 'robot:exit' not in test.tags: + if test.failed and not test.tags.robot('exit'): self._stream.write('-' * self._width + '\n') self._stream.highlight('FAIL') self._stream.write(': %s\n%s\n' % (test.longname, diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 72faabec349..f0104669a3d 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -135,11 +135,11 @@ def variables(self): def continue_on_failure(self, default=False): parents = ([self.test] if self.test else []) + self.user_keywords for index, parent in enumerate(reversed(parents)): - if ('robot:recursive-stop-on-failure' in parent.tags - or index == 0 and 'robot:stop-on-failure' in parent.tags): + if (parent.tags.robot('recursive-stop-on-failure') + or index == 0 and parent.tags.robot('stop-on-failure')): return False - if ('robot:recursive-continue-on-failure' in parent.tags - or index == 0 and 'robot:continue-on-failure' in parent.tags): + if (parent.tags.robot('recursive-continue-on-failure') + or index == 0 and parent.tags.robot('continue-on-failure')): return True return default or self.in_teardown diff --git a/src/robot/running/status.py b/src/robot/running/status.py index 41d1e43c185..72c7bcede0a 100644 --- a/src/robot/running/status.py +++ b/src/robot/running/status.py @@ -198,8 +198,9 @@ def skip_on_failure_after_tag_changes(self): return False def _skip_on_failure(self): - tags = list(self._skip_on_failure_tags or []) + ['robot:skip-on-failure'] - return TagPatterns(tags).match(self._test.tags) + return (self._test.tags.robot('skip-on-failure') + or self._skip_on_failure_tags + and TagPatterns(self._skip_on_failure_tags).match(self._test.tags)) def _skip_on_fail_msg(self, msg): return test_or_task( diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index 1e579b0bffc..6492a860753 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -113,7 +113,7 @@ def end_suite(self, suite): def visit_test(self, test): settings = self._settings - if TagPatterns("robot:exclude").match(test.tags): + if test.tags.robot('exclude'): return if test.name in self._executed[-1]: self._output.warn( @@ -140,7 +140,7 @@ def visit_test(self, test): elif not test.body: status.test_failed( test_or_task('{Test} contains no keywords.', settings.rpa)) - elif TagPatterns('robot:skip').match(test.tags): + elif test.tags.robot('skip'): status.test_skipped( test_or_task("{Test} skipped using 'robot:skip' tag.", settings.rpa)) diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index 1aa49c8c601..2f58e0c9809 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -94,12 +94,7 @@ def shortdoc(self): @property def private(self): - # TODO: Make checking is keyword private faster. - # `'robot:private' in self.tags` is a bit slow. First checking do we - # have tags avoids it in the common case but not if keywords have tags. - # Tags objects probably should make checking special tags like this faster - # in general. That would then speed up other similar usages as well. - return bool(self.tags and 'robot:private' in self.tags) + return bool(self.tags and self.tags.robot('private')) def create_runner(self, name): return UserKeywordRunner(self) diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index e659d9c9ace..b49fef87865 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -161,7 +161,7 @@ def _execute(self, context): handler = self._handler if not (handler.body or handler.return_value): raise DataError("User keyword '%s' contains no keywords." % self.name) - if context.dry_run and 'robot:no-dry-run' in handler.tags: + if context.dry_run and handler.tags.robot('no-dry-run'): return None, None error = return_ = pass_ = None try: From 0de16a842e3dd5fbf56848d8aa6211670e461370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 5 Jul 2022 16:18:08 +0300 Subject: [PATCH 0076/1592] CRLF -> LF --- atest/testdata/running/private.resource | 52 +++++++------- atest/testdata/running/private.robot | 90 ++++++++++++------------ atest/testdata/running/private2.resource | 38 +++++----- 3 files changed, 90 insertions(+), 90 deletions(-) diff --git a/atest/testdata/running/private.resource b/atest/testdata/running/private.resource index 039c13c00a1..d53f5cc2691 100644 --- a/atest/testdata/running/private.resource +++ b/atest/testdata/running/private.resource @@ -1,26 +1,26 @@ -*** Settings *** -Resource private2.resource - -*** Keywords *** -Public Keyword In Resource - Private Keyword In Resource - -Private Keyword In Resource - [Tags] robot:private - No Operation - -Call Private Keyword From Private 2 Resource - Private Keyword In Resource 2 - -Same Name - No Operation - -First Public Keyword With Nested Private Keyword - Nested Private Keyword - -Nested Private Keyword - [Tags] robot:private - No Operation - -Possible Keyword - No Operation +*** Settings *** +Resource private2.resource + +*** Keywords *** +Public Keyword In Resource + Private Keyword In Resource + +Private Keyword In Resource + [Tags] robot:private + No Operation + +Call Private Keyword From Private 2 Resource + Private Keyword In Resource 2 + +Same Name + No Operation + +First Public Keyword With Nested Private Keyword + Nested Private Keyword + +Nested Private Keyword + [Tags] robot:private + No Operation + +Possible Keyword + No Operation diff --git a/atest/testdata/running/private.robot b/atest/testdata/running/private.robot index a6c95a2a63b..d941b74e763 100644 --- a/atest/testdata/running/private.robot +++ b/atest/testdata/running/private.robot @@ -1,45 +1,45 @@ -*** Settings *** -Resource private.resource -Resource private2.resource -Resource private3.resource - -*** Test Cases *** -Valid Usage With Local Keyword - Public Keyword - -Invalid Usage With Local Keyword - Private Keyword - -Valid Usage With Resource Keyword - Public Keyword In Resource - -Invalid Usage With Resource Keyword - Private Keyword In Resource - -Invalid Usage In Resource file - Call Private Keyword From Private 2 Resource - -Keyword With Same Name Should Resolve Public Keyword - Same Name - -If Both Keywords Are Private Raise Multiple Keywords Found - [Documentation] FAIL Multiple keywords with name 'Nested Private Keyword' found. \ - ... Give the full name of the keyword you want to use: - ... ${SPACE*4}private.Nested Private Keyword - ... ${SPACE*4}private2.Nested Private Keyword - First Public Keyword With Nested Private Keyword - Second Public Keyword With Nested Private Keyword - -If One Keyword Is Public And Multiple Private Keywords Run Public And Warn - Keyword With One Public And Two Private Possible Keywords - -*** Keywords *** -Public Keyword - Private Keyword - -Private Keyword - [Tags] robot:private - No Operation - -Keyword With One Public And Two Private Possible Keywords - Possible Keyword +*** Settings *** +Resource private.resource +Resource private2.resource +Resource private3.resource + +*** Test Cases *** +Valid Usage With Local Keyword + Public Keyword + +Invalid Usage With Local Keyword + Private Keyword + +Valid Usage With Resource Keyword + Public Keyword In Resource + +Invalid Usage With Resource Keyword + Private Keyword In Resource + +Invalid Usage In Resource file + Call Private Keyword From Private 2 Resource + +Keyword With Same Name Should Resolve Public Keyword + Same Name + +If Both Keywords Are Private Raise Multiple Keywords Found + [Documentation] FAIL Multiple keywords with name 'Nested Private Keyword' found. \ + ... Give the full name of the keyword you want to use: + ... ${SPACE*4}private.Nested Private Keyword + ... ${SPACE*4}private2.Nested Private Keyword + First Public Keyword With Nested Private Keyword + Second Public Keyword With Nested Private Keyword + +If One Keyword Is Public And Multiple Private Keywords Run Public And Warn + Keyword With One Public And Two Private Possible Keywords + +*** Keywords *** +Public Keyword + Private Keyword + +Private Keyword + [Tags] robot:private + No Operation + +Keyword With One Public And Two Private Possible Keywords + Possible Keyword diff --git a/atest/testdata/running/private2.resource b/atest/testdata/running/private2.resource index 3ff7df4d61a..197059ce745 100644 --- a/atest/testdata/running/private2.resource +++ b/atest/testdata/running/private2.resource @@ -1,19 +1,19 @@ -*** Keywords *** -Private Keyword In Resource 2 - [Tags] robot:private - No Operation - -Same Name - [Tags] robot:private - No Operation - -Second Public Keyword With Nested Private Keyword - Nested Private Keyword - -Nested Private Keyword - [Tags] robot:private - No Operation - -Possible Keyword - [Tags] robot:private - No Operation +*** Keywords *** +Private Keyword In Resource 2 + [Tags] robot:private + No Operation + +Same Name + [Tags] robot:private + No Operation + +Second Public Keyword With Nested Private Keyword + Nested Private Keyword + +Nested Private Keyword + [Tags] robot:private + No Operation + +Possible Keyword + [Tags] robot:private + No Operation From 94a1d7785c6a861eb6296843612aaccd2c465098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 5 Jul 2022 16:22:07 +0300 Subject: [PATCH 0077/1592] Move tests related to private keywords to better place. 'keywords' is a bit better place than 'running' for these tests in general, but more importantly it's good to have these close to tests related to keywords matching multiple implementations. --- atest/robot/{running => keywords}/private.robot | 2 +- atest/testdata/{running => keywords}/private.resource | 0 atest/testdata/{running => keywords}/private.robot | 0 atest/testdata/{running => keywords}/private2.resource | 0 atest/testdata/{running => keywords}/private3.resource | 0 5 files changed, 1 insertion(+), 1 deletion(-) rename atest/robot/{running => keywords}/private.robot (97%) rename atest/testdata/{running => keywords}/private.resource (100%) rename atest/testdata/{running => keywords}/private.robot (100%) rename atest/testdata/{running => keywords}/private2.resource (100%) rename atest/testdata/{running => keywords}/private3.resource (100%) diff --git a/atest/robot/running/private.robot b/atest/robot/keywords/private.robot similarity index 97% rename from atest/robot/running/private.robot rename to atest/robot/keywords/private.robot index 91d7b4dd328..ba1af72ca1d 100644 --- a/atest/robot/running/private.robot +++ b/atest/robot/keywords/private.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} running/private.robot +Suite Setup Run Tests ${EMPTY} keywords/private.robot Resource atest_resource.robot *** Test Cases *** diff --git a/atest/testdata/running/private.resource b/atest/testdata/keywords/private.resource similarity index 100% rename from atest/testdata/running/private.resource rename to atest/testdata/keywords/private.resource diff --git a/atest/testdata/running/private.robot b/atest/testdata/keywords/private.robot similarity index 100% rename from atest/testdata/running/private.robot rename to atest/testdata/keywords/private.robot diff --git a/atest/testdata/running/private2.resource b/atest/testdata/keywords/private2.resource similarity index 100% rename from atest/testdata/running/private2.resource rename to atest/testdata/keywords/private2.resource diff --git a/atest/testdata/running/private3.resource b/atest/testdata/keywords/private3.resource similarity index 100% rename from atest/testdata/running/private3.resource rename to atest/testdata/keywords/private3.resource From b9439d4936df3d999834df8314f1d0dce27c3b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 5 Jul 2022 16:59:42 +0300 Subject: [PATCH 0078/1592] Give local keywords in resource precedence over imported keywords. This applies also to local private keywords. Part of #4366 and to some extend also related to #430. --- atest/robot/keywords/keyword_namespaces.robot | 5 +++++ atest/robot/keywords/private.robot | 16 +++++++++----- .../keywords/keyword_namespaces.robot | 4 ++++ atest/testdata/keywords/private.resource | 22 ++++++++++--------- atest/testdata/keywords/private.robot | 18 +++++++-------- atest/testdata/keywords/private2.resource | 15 +++++-------- atest/testdata/keywords/private3.resource | 7 ++++-- .../keywords/resources/my_resource_1.robot | 3 +++ .../keywords/resources/my_resource_2.robot | 3 +++ src/robot/running/namespace.py | 13 +++++++++++ src/robot/running/userkeywordrunner.py | 4 ++++ 11 files changed, 75 insertions(+), 35 deletions(-) diff --git a/atest/robot/keywords/keyword_namespaces.robot b/atest/robot/keywords/keyword_namespaces.robot index 032890ad28f..30a4b99e26e 100644 --- a/atest/robot/keywords/keyword_namespaces.robot +++ b/atest/robot/keywords/keyword_namespaces.robot @@ -25,6 +25,11 @@ Keyword From Test Case File Overrides Keywords From Resources And Libraries Keyword From Resource Overrides Keywords From Libraries Check Test Case ${TEST NAME} +Local keyword in resource file has precedence over keywords in other resource files + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} Keyword in resource 1 + Check Log Message ${tc.body[1].body[0].body[0].msgs[0]} Keyword in resource 2 + Keyword From Custom Library Overrides Keywords From Standard Library ${tc} = Check Test Case ${TEST NAME} Verify Override Message ${ERRORS[1]} ${tc.kws[0].msgs[0]} Comment BuiltIn diff --git a/atest/robot/keywords/private.robot b/atest/robot/keywords/private.robot index ba1af72ca1d..226b72cda83 100644 --- a/atest/robot/keywords/private.robot +++ b/atest/robot/keywords/private.robot @@ -26,6 +26,11 @@ Invalid Usage in Resource File Private Call Warning Should Be private2.Private Keyword In Resource 2 ${tc.body[0].body[0].body[0]} ${ERRORS[2]} Length Should Be ${tc.body[0].body[0].body} 2 +Local Private Keyword In Resource File Has Precedence Over Keywords In Another Resource + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} private.resource + Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} private.resource + Keyword With Same Name Should Resolve Public Keyword ${tc}= Check Test Case ${TESTNAME} ${warning}= Catenate @@ -42,12 +47,13 @@ If Both Keywords Are Private Raise Multiple Keywords Found If One Keyword Is Public And Multiple Private Keywords Run Public And Warn ${tc}= Check Test Case ${TESTNAME} ${warning}= Catenate - ... Both public and private keywords with name 'Possible Keyword' found. - ... The public keyword 'private.Possible Keyword' is used and - ... private keywords 'private2.Possible Keyword' and 'private3.Possible Keyword' ignored. + ... Both public and private keywords with name 'Private In Two Resources And Public In One' found. + ... The public keyword 'private3.Private In Two Resources And Public In One' is + ... used and private keywords 'private.Private In Two Resources And Public In One' + ... and 'private2.Private In Two Resources And Public In One' ignored. ... To select explicitly, and to get rid of this warning, use the long name of the keyword. - Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0].body[0]} ${ERRORS[4]} - Length Should Be ${tc.body[0].body[0].body} 2 + Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0]} ${ERRORS[4]} + Length Should Be ${tc.body[0].body} 2 *** Keywords *** Private Call Warning Should Be diff --git a/atest/testdata/keywords/keyword_namespaces.robot b/atest/testdata/keywords/keyword_namespaces.robot index 9a69abc6424..4e9cc9ba74a 100644 --- a/atest/testdata/keywords/keyword_namespaces.robot +++ b/atest/testdata/keywords/keyword_namespaces.robot @@ -56,6 +56,10 @@ Keyword From Test Case File Overrides Keywords From Resources And Libraries Keyword From Resource Overrides Keywords From Libraries Keyword In Resource Overrides Libraries +Local keyword in resource file has precedence over keywords in other resource files + Use local keyword that exists also in another resource 1 + Use local keyword that exists also in another resource 2 + Keyword From Custom Library Overrides Keywords From Standard Library Comment Copy Directory diff --git a/atest/testdata/keywords/private.resource b/atest/testdata/keywords/private.resource index d53f5cc2691..a9b5c0639cf 100644 --- a/atest/testdata/keywords/private.resource +++ b/atest/testdata/keywords/private.resource @@ -9,18 +9,20 @@ Private Keyword In Resource [Tags] robot:private No Operation -Call Private Keyword From Private 2 Resource - Private Keyword In Resource 2 +Use Local Private Keyword Instead Keywords From Other Resources + Private Keyword In All Resources + Private In Two Resources And Public In One -Same Name - No Operation +Private Keyword In All Resources + [Tags] ROBOT: private + Log private.resource -First Public Keyword With Nested Private Keyword - Nested Private Keyword +Private In Two Resources And Public In One + [Tags] RoBoT:PrIvAtE + Log private.resource -Nested Private Keyword - [Tags] robot:private - No Operation +Call Private Keyword From Private 2 Resource + Private Keyword In Resource 2 -Possible Keyword +Same Name No Operation diff --git a/atest/testdata/keywords/private.robot b/atest/testdata/keywords/private.robot index d941b74e763..ad62d3973d7 100644 --- a/atest/testdata/keywords/private.robot +++ b/atest/testdata/keywords/private.robot @@ -19,19 +19,22 @@ Invalid Usage With Resource Keyword Invalid Usage In Resource file Call Private Keyword From Private 2 Resource +Local Private Keyword In Resource File Has Precedence Over Keywords In Another Resource + Use Local Private Keyword Instead Keywords From Other Resources + Keyword With Same Name Should Resolve Public Keyword Same Name If Both Keywords Are Private Raise Multiple Keywords Found - [Documentation] FAIL Multiple keywords with name 'Nested Private Keyword' found. \ + [Documentation] FAIL Multiple keywords with name 'Private Keyword In All Resources' found. \ ... Give the full name of the keyword you want to use: - ... ${SPACE*4}private.Nested Private Keyword - ... ${SPACE*4}private2.Nested Private Keyword - First Public Keyword With Nested Private Keyword - Second Public Keyword With Nested Private Keyword + ... ${SPACE*4}private.Private Keyword In All Resources + ... ${SPACE*4}private2.Private Keyword In All Resources + ... ${SPACE*4}private3.Private Keyword In All Resources + Private Keyword In All Resources If One Keyword Is Public And Multiple Private Keywords Run Public And Warn - Keyword With One Public And Two Private Possible Keywords + Private In Two Resources And Public In One *** Keywords *** Public Keyword @@ -40,6 +43,3 @@ Public Keyword Private Keyword [Tags] robot:private No Operation - -Keyword With One Public And Two Private Possible Keywords - Possible Keyword diff --git a/atest/testdata/keywords/private2.resource b/atest/testdata/keywords/private2.resource index 197059ce745..ae2dc26be2a 100644 --- a/atest/testdata/keywords/private2.resource +++ b/atest/testdata/keywords/private2.resource @@ -3,17 +3,14 @@ Private Keyword In Resource 2 [Tags] robot:private No Operation -Same Name - [Tags] robot:private +Private Keyword In All Resources + [Tags] robot: private No Operation -Second Public Keyword With Nested Private Keyword - Nested Private Keyword - -Nested Private Keyword - [Tags] robot:private - No Operation +Private In Two Resources And Public In One + [Tags] robot: private + Log private2.resource -Possible Keyword +Same Name [Tags] robot:private No Operation diff --git a/atest/testdata/keywords/private3.resource b/atest/testdata/keywords/private3.resource index 2886c5bc483..37f463c90f9 100644 --- a/atest/testdata/keywords/private3.resource +++ b/atest/testdata/keywords/private3.resource @@ -1,4 +1,7 @@ *** Keywords *** -Possible Keyword - [Tags] robot:private +Private In Two Resources And Public In One + Log private3.resource + +Private Keyword In All Resources + [Tags] ROBOT: PRIVATE No Operation diff --git a/atest/testdata/keywords/resources/my_resource_1.robot b/atest/testdata/keywords/resources/my_resource_1.robot index 50c4a36fc6b..73b16df9d51 100644 --- a/atest/testdata/keywords/resources/my_resource_1.robot +++ b/atest/testdata/keywords/resources/my_resource_1.robot @@ -2,6 +2,9 @@ Keyword Only In Resource 1 Log Keyword in resource 1 +Use local keyword that exists also in another resource 1 + Keyword In Both Resources + Keyword In Both Resources Log Keyword in resource 1 diff --git a/atest/testdata/keywords/resources/my_resource_2.robot b/atest/testdata/keywords/resources/my_resource_2.robot index 9d87a229efc..5a1c74361bd 100644 --- a/atest/testdata/keywords/resources/my_resource_2.robot +++ b/atest/testdata/keywords/resources/my_resource_2.robot @@ -2,6 +2,9 @@ Keyword Only In Resource 2 Log Keyword in resource 2 +Use local keyword that exists also in another resource 2 + Keyword In Both Resources + Keyword In Both Resources Log Keyword in resource 2 diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 05feea0a7e2..e94c81ebe6f 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -24,6 +24,7 @@ from robot.utils import (RecommendationFinder, eq, find_file, is_string, normalize, plural_or_not as s, printable_name, seq2str, seq2str2) +from .context import EXECUTION_CONTEXTS from .importer import ImportCache, Importer from .model import Import from .runkwregister import RUN_KW_REGISTER @@ -326,6 +327,8 @@ def _get_runner_from_resource_files(self, name): return None if len(found) > 1: found = self._get_runner_based_on_search_order(found) + if len(found) > 1: + found = self._get_runner_from_same_resource_file(found) if len(found) > 1: found = self._handle_private_user_keywords(found, name) if len(found) == 1: @@ -345,6 +348,16 @@ def _get_runner_from_libraries(self, name): return found[0] self._raise_multiple_keywords_found(found, name) + def _get_runner_from_same_resource_file(self, found): + user_keywords = EXECUTION_CONTEXTS.current.user_keywords + if not user_keywords: + return found + parent_source = user_keywords[-1].source + for runner in found: + if runner.source == parent_source: + return [runner] + return found + def _handle_private_user_keywords(self, runners, used_as): public = [r for r in runners if not r.private] if len(public) != 1: diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index b49fef87865..2e7590b6aaf 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -48,6 +48,10 @@ def libname(self): def tags(self): return self._handler.tags + @property + def source(self): + return self._handler.source + @property def private(self): return self._handler.private From 87c2ca322cf65707ca30195565d3efa98a3e1ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 5 Jul 2022 17:17:58 +0300 Subject: [PATCH 0079/1592] No warning if there is one public and one or more private keywords. The warning was initially added when the support for private keywords was added (#430). It was needed because we otherwise favored public keywords even when the private keyword was in the same resource file. Now we prefer local keywords over external keywords (#4366) and it's safe to use imported public keywords instead of imported private keywords. --- atest/robot/keywords/private.robot | 31 +++++------------------ atest/testdata/keywords/private.resource | 11 +++++--- atest/testdata/keywords/private.robot | 18 ++++++++----- atest/testdata/keywords/private2.resource | 10 +++++--- atest/testdata/keywords/private3.resource | 9 ++++++- src/robot/running/namespace.py | 23 +++-------------- 6 files changed, 43 insertions(+), 59 deletions(-) diff --git a/atest/robot/keywords/private.robot b/atest/robot/keywords/private.robot index 226b72cda83..91d2b46e45a 100644 --- a/atest/robot/keywords/private.robot +++ b/atest/robot/keywords/private.robot @@ -31,29 +31,16 @@ Local Private Keyword In Resource File Has Precedence Over Keywords In Another R Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} private.resource Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} private.resource -Keyword With Same Name Should Resolve Public Keyword +Imported Public Keyword Has Precedence Over Imported Private Keywords ${tc}= Check Test Case ${TESTNAME} - ${warning}= Catenate - ... Both public and private keywords with name 'Same Name' found. - ... The public keyword 'private.Same Name' is used and - ... private keyword 'private2.Same Name' ignored. - ... To select explicitly, and to get rid of this warning, use the long name of the keyword. - Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0]} ${ERRORS[3]} - Length Should Be ${tc.body[0].body} 2 + Check Log Message ${tc.body[0].body[0].msgs[0]} private2.resource + Check Log Message ${tc.body[1].body[0].body[0].msgs[0]} private2.resource -If Both Keywords Are Private Raise Multiple Keywords Found +If All Keywords Are Private Raise Multiple Keywords Found Check Test Case ${TESTNAME} -If One Keyword Is Public And Multiple Private Keywords Run Public And Warn - ${tc}= Check Test Case ${TESTNAME} - ${warning}= Catenate - ... Both public and private keywords with name 'Private In Two Resources And Public In One' found. - ... The public keyword 'private3.Private In Two Resources And Public In One' is - ... used and private keywords 'private.Private In Two Resources And Public In One' - ... and 'private2.Private In Two Resources And Public In One' ignored. - ... To select explicitly, and to get rid of this warning, use the long name of the keyword. - Public And Private Keyword Conflict Warning Should Be ${warning} ${tc.body[0].body[0]} ${ERRORS[4]} - Length Should Be ${tc.body[0].body} 2 +If More Than Two Keywords Are Public Raise Multiple Keywords Found + Check Test Case ${TESTNAME} *** Keywords *** Private Call Warning Should Be @@ -63,9 +50,3 @@ Private Call Warning Should Be ... Keyword '${name}' is private and should only be called by keywords in the same file. ... WARN END - -Public And Private Keyword Conflict Warning Should Be - [Arguments] ${warning} @{messages} - FOR ${message} IN @{messages} - Check Log Message ${message} ${warning} WARN - END diff --git a/atest/testdata/keywords/private.resource b/atest/testdata/keywords/private.resource index a9b5c0639cf..42121464728 100644 --- a/atest/testdata/keywords/private.resource +++ b/atest/testdata/keywords/private.resource @@ -1,5 +1,6 @@ *** Settings *** Resource private2.resource +Resource private3.resource *** Keywords *** Public Keyword In Resource @@ -9,7 +10,7 @@ Private Keyword In Resource [Tags] robot:private No Operation -Use Local Private Keyword Instead Keywords From Other Resources +Use Local Private Keyword Instead Of Keywords From Other Resources Private Keyword In All Resources Private In Two Resources And Public In One @@ -21,8 +22,12 @@ Private In Two Resources And Public In One [Tags] RoBoT:PrIvAtE Log private.resource +Use Imported Public Keyword Instead Instead Of Imported Private Keyword + Private In One Resource And Public In Another + Call Private Keyword From Private 2 Resource Private Keyword In Resource 2 -Same Name - No Operation +Private In One Resource And Public In Two + [Tags] robot:private + Fail Not executed diff --git a/atest/testdata/keywords/private.robot b/atest/testdata/keywords/private.robot index ad62d3973d7..22ebc79d36b 100644 --- a/atest/testdata/keywords/private.robot +++ b/atest/testdata/keywords/private.robot @@ -20,12 +20,13 @@ Invalid Usage In Resource file Call Private Keyword From Private 2 Resource Local Private Keyword In Resource File Has Precedence Over Keywords In Another Resource - Use Local Private Keyword Instead Keywords From Other Resources + Use Local Private Keyword Instead Of Keywords From Other Resources -Keyword With Same Name Should Resolve Public Keyword - Same Name +Imported Public Keyword Has Precedence Over Imported Private Keywords + Private In One Resource And Public In Another + Use Imported Public Keyword Instead Instead Of Imported Private Keyword -If Both Keywords Are Private Raise Multiple Keywords Found +If All Keywords Are Private Raise Multiple Keywords Found [Documentation] FAIL Multiple keywords with name 'Private Keyword In All Resources' found. \ ... Give the full name of the keyword you want to use: ... ${SPACE*4}private.Private Keyword In All Resources @@ -33,8 +34,13 @@ If Both Keywords Are Private Raise Multiple Keywords Found ... ${SPACE*4}private3.Private Keyword In All Resources Private Keyword In All Resources -If One Keyword Is Public And Multiple Private Keywords Run Public And Warn - Private In Two Resources And Public In One +If More Than Two Keywords Are Public Raise Multiple Keywords Found + [Documentation] FAIL Multiple keywords with name 'Private In One Resource And Public In Two' found. \ + ... Give the full name of the keyword you want to use: + ... ${SPACE*4}private.Private In One Resource And Public In Two + ... ${SPACE*4}private2.Private In One Resource And Public In Two + ... ${SPACE*4}private3.Private In One Resource And Public In Two + Private In One Resource And Public In Two *** Keywords *** Public Keyword diff --git a/atest/testdata/keywords/private2.resource b/atest/testdata/keywords/private2.resource index ae2dc26be2a..847eeb43f30 100644 --- a/atest/testdata/keywords/private2.resource +++ b/atest/testdata/keywords/private2.resource @@ -5,12 +5,14 @@ Private Keyword In Resource 2 Private Keyword In All Resources [Tags] robot: private - No Operation + Fail Not executed Private In Two Resources And Public In One [Tags] robot: private Log private2.resource -Same Name - [Tags] robot:private - No Operation +Private In One Resource And Public In Another + Log private2.resource + +Private In One Resource And Public In Two + Fail Not executed diff --git a/atest/testdata/keywords/private3.resource b/atest/testdata/keywords/private3.resource index 37f463c90f9..b2462bc90c1 100644 --- a/atest/testdata/keywords/private3.resource +++ b/atest/testdata/keywords/private3.resource @@ -4,4 +4,11 @@ Private In Two Resources And Public In One Private Keyword In All Resources [Tags] ROBOT: PRIVATE - No Operation + Fail Not executed + +Private In One Resource And Public In Another + [Tags] ROBOT: PRIVATE + Fail Not executed + +Private In One Resource And Public In Two + Fail Not executed diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index e94c81ebe6f..a1a56a82992 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -330,7 +330,7 @@ def _get_runner_from_resource_files(self, name): if len(found) > 1: found = self._get_runner_from_same_resource_file(found) if len(found) > 1: - found = self._handle_private_user_keywords(found, name) + found = self._handle_private_user_keywords(found) if len(found) == 1: return found[0] self._raise_multiple_keywords_found(found, name) @@ -358,26 +358,9 @@ def _get_runner_from_same_resource_file(self, found): return [runner] return found - def _handle_private_user_keywords(self, runners, used_as): + def _handle_private_user_keywords(self, runners): public = [r for r in runners if not r.private] - if len(public) != 1: - return runners - private = [r for r in runners if r.private] - self._public_and_private_keyword_warning(public[0], private, used_as) - return public - - def _public_and_private_keyword_warning(self, public, private, used_as): - warning = Message( - f"Both public and private keywords with name '{used_as}' found. The public " - f"keyword '{public.longname}' is used and private keyword{s(private)} " - f"{seq2str(p.longname for p in private)} ignored. To select explicitly, " - f"and to get rid of this warning, use the long name of the keyword.", - level='WARN' - ) - if public.pre_run_messages: - public.pre_run_messages.append(warning) - else: - public.pre_run_messages = [warning] + return public if len(public) == 1 else runners def _get_runner_based_on_search_order(self, runners): for libname in self.search_order: From 9e1760f4505b89504c4f56078e7f6702bb4f02ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 6 Jul 2022 13:28:37 +0300 Subject: [PATCH 0080/1592] Update tranlations after recent tag updates #4390. - Remove support to translate deprecated Force Tags and Default Tags and add suport for new Test Tags (#4368) - Add support for new Keyword Tags (#4373) - Not really related tags, but remove support to translate deprecated Return seting. - Also enhance reporting error when a valid setting is used in an invalid file. --- atest/robot/parsing/translations.robot | 7 +++--- atest/robot/rpa/task_aliases.robot | 2 +- atest/testdata/parsing/custom-lang.py | 7 +++--- atest/testdata/parsing/custom.robot | 20 +++++++-------- atest/testdata/parsing/finnish.robot | 34 ++++++++++++-------------- src/robot/conf/languages.py | 22 +++++++---------- src/robot/parsing/lexer/settings.py | 18 ++++++++------ utest/parsing/test_lexer.py | 10 +++++--- 8 files changed, 59 insertions(+), 61 deletions(-) diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index 8b94aa431e7..49f65d3c66b 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -19,20 +19,21 @@ Validate Translations Should Be Equal ${SUITE.status} PASS ${tc} = Check Test Case Test without settings Should Be Equal ${tc.doc} ${EMPTY} - Should Be Equal ${tc.tags} ${{['forced tag', 'default tag']}} + Should Be Equal ${tc.tags} ${{['test', 'tags']}} Should Be Equal ${tc.timeout} 1 minute Should Be Equal ${tc.setup.name} Test Setup Should Be Equal ${tc.teardown.name} Test Teardown Should Be Equal ${tc.body[0].name} Test Template + Should Be Equal ${tc.body[0].tags} ${{['keyword', 'tags']}} ${tc} = Check Test Case Test with settings Should Be Equal ${tc.doc} Test documentation. - Should Be Equal ${tc.tags} ${{['forced tag', 'own tag']}} + Should Be Equal ${tc.tags} ${{['test', 'tags', 'own tag']}} Should Be Equal ${tc.timeout} ${NONE} Should Be Equal ${tc.setup.name} ${NONE} Should Be Equal ${tc.teardown.name} ${NONE} Should Be Equal ${tc.body[0].name} Keyword Should Be Equal ${tc.body[0].doc} Keyword documentation. - Should Be Equal ${tc.body[0].tags} ${{['kw tag']}} + Should Be Equal ${tc.body[0].tags} ${{['keyword', 'tags', 'own tag']}} Should Be Equal ${tc.body[0].timeout} 1 hour Should Be Equal ${tc.body[0].teardown.name} BuiltIn.No Operation diff --git a/atest/robot/rpa/task_aliases.robot b/atest/robot/rpa/task_aliases.robot index 70d856b23f1..4a62e6aad09 100644 --- a/atest/robot/rpa/task_aliases.robot +++ b/atest/robot/rpa/task_aliases.robot @@ -65,4 +65,4 @@ Validate invalid setting error [Arguments] ${index} ${lineno} ${setting} Error In File ... ${index} rpa/resource_with_invalid_task_settings.robot ${lineno} - ... Non-existing setting '${setting}'. + ... Setting '${setting}' is not allowed in resource file. diff --git a/atest/testdata/parsing/custom-lang.py b/atest/testdata/parsing/custom-lang.py index df2f33a5add..944e47d55af 100644 --- a/atest/testdata/parsing/custom-lang.py +++ b/atest/testdata/parsing/custom-lang.py @@ -18,14 +18,13 @@ class Custom(Language): test_setup = 'S 5' test_teardown = 'S 6' test_template = 'S 7' - force_tags = 'S 8' - default_tags = 'S 9' - test_timeout = 'S 10' + test_timeout = 'S 8' + test_tags = 'S 9' + keyword_tags = 'S 10' setup = 'S 11' teardown = 'S 12' template = 'S 13' tags = 'S 14' timeout = 'S 15' arguments = 'S 16' - return_ = 'S 17' bdd_prefixes = {} diff --git a/atest/testdata/parsing/custom.robot b/atest/testdata/parsing/custom.robot index 44ea200fb3f..ba2b635d790 100644 --- a/atest/testdata/parsing/custom.robot +++ b/atest/testdata/parsing/custom.robot @@ -6,9 +6,9 @@ S 4 Suite Teardown S 5 Test Setup S 6 Test Teardown S 7 Test Template -S 8 forced tag -S 9 default tag -S 10 1 minute +S 8 1 minute +S 9 test tags +S 10 keyword tags L OperatingSystem R custom.resource V variables.py @@ -23,12 +23,11 @@ Test without settings Test with settings [S 1] Test documentation. [S 14] own tag - [S 11] NONE - [S 12] NONE - [S 13] NONE - [S 15] NONE - ${result} = Keyword ${VARIABLE} - Should Be Equal ${result} To be deprecated + [S 11] NONE + [S 12] NONE + [S 13] NONE + [S 15] NONE + Keyword ${VARIABLE} *** H 5 *** Suite Setup @@ -52,11 +51,10 @@ Test Template Keyword [S 1] Keyword documentation. [S 16] ${arg} - [S 14] kw tag + [S 14] own tag [S 15] 1h Should Be Equal ${arg} ${VARIABLE} [S 12] No Operation - [S 17] To be deprecated *** H 6 *** Ignored comments. diff --git a/atest/testdata/parsing/finnish.robot b/atest/testdata/parsing/finnish.robot index 18569310a0c..c09f7db55be 100644 --- a/atest/testdata/parsing/finnish.robot +++ b/atest/testdata/parsing/finnish.robot @@ -1,20 +1,20 @@ *** Asetukset *** -Dokumentaatio Suite documentation. -Metadata Metadata Value -Setin Alustus Suite Setup -Setin Purku Suite Teardown -Testin Alustus Test Setup -Testin Purku Test Teardown -Testin Malli Test Template -Testin Tagit forced tag -Oletus Tagit default tag -Testin Aikaraja 1 minute -Kirjasto OperatingSystem -Resurssi finnish.resource -Muuttujat variables.py +Dokumentaatio Suite documentation. +Metadata Metadata Value +Setin Alustus Suite Setup +Setin Purku Suite Teardown +Testin Alustus Test Setup +Testin Purku Test Teardown +Testin Malli Test Template +Testin Aikaraja 1 minute +Testin Tagit test tags +Avainsanan Tagit keyword tags +Kirjasto OperatingSystem +Resurssi finnish.resource +Muuttujat variables.py *** Muuttujat *** -${VARIABLE} variable value +${VARIABLE} variable value *** Testit *** Test without settings @@ -27,8 +27,7 @@ Test with settings [Purku] NONE [Malli] NONE [Aikaraja] NONE - ${result} = Keyword ${VARIABLE} - Should Be Equal ${result} To be deprecated + Keyword ${VARIABLE} *** Avainsanat *** Suite Setup @@ -52,11 +51,10 @@ Test Template Keyword [Dokumentaatio] Keyword documentation. [Argumentit] ${arg} - [Tagit] kw tag + [Tagit] own tag [Aikaraja] 1h Should Be Equal ${arg} ${VARIABLE} [Purku] No Operation - [Paluuarvo] To be deprecated *** Kommentit *** Ignored comments. diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 569459b637c..55c30770782 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -77,16 +77,15 @@ class Language: test_teardown = None test_template = None test_timeout = None - force_tags = None - default_tags = None + test_tags = None + keyword_tags = None tags = None setup = None teardown = None template = None timeout = None arguments = None - return_ = None - bdd_prefixes = set() # These are not used yet + bdd_prefixes = set() @property def settings(self): @@ -102,15 +101,14 @@ def settings(self): self.test_teardown: En.test_teardown, self.test_template: En.test_template, self.test_timeout: En.test_timeout, - self.force_tags: En.force_tags, - self.default_tags: En.default_tags, + self.test_tags: En.test_tags, + self.keyword_tags: En.keyword_tags, self.tags: En.tags, self.setup: En.setup, self.teardown: En.teardown, self.template: En.template, self.timeout: En.timeout, self.arguments: En.arguments, - self.return_: En.return_ } settings.pop(None, None) return settings @@ -133,16 +131,15 @@ class En(Language): test_setup = 'Test Setup' test_teardown = 'Test Teardown' test_template = 'Test Template' - force_tags = 'Force Tags' - default_tags = 'Default Tags' test_timeout = 'Test Timeout' + test_tags = 'Test Tags' + keyword_tags = 'Keyword Tags' setup = 'Setup' teardown = 'Teardown' template = 'Template' tags = 'Tags' timeout = 'Timeout' arguments = 'Arguments' - return_ = 'Return' bdd_prefixes = {'Given', 'When', 'Then', 'And', 'But'} @@ -165,14 +162,13 @@ class Fi(Language): test_setup = 'Testin Alustus' test_teardown = 'Testin Purku' test_template = 'Testin Malli' - force_tags = 'Testin Tagit' - default_tags = 'Oletus Tagit' test_timeout = 'Testin Aikaraja' + test_tags = 'Testin Tagit' + keyword_tags = 'Avainsanan Tagit' setup = 'Alustus' teardown = 'Purku' template = 'Malli' tags = 'Tagit' timeout = 'Aikaraja' arguments = 'Argumentit' - return_ = 'Paluuarvo' bdd_prefixes = {'Oletetaan', 'Kun', 'Niin', 'Ja', 'Mutta'} diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 1a1ccc82b81..8cc3787b319 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -84,7 +84,8 @@ def _validate(self, orig, name, statement): % (orig, len(statement) - 1)) def _get_non_existing_setting_message(self, name, normalized): - if normalized in TestCaseFileSettings.names: + if normalized in (set(TestCaseFileSettings.names) | + set(TestCaseFileSettings.aliases)): is_resource = isinstance(self, ResourceFileSettings) return "Setting '%s' is not allowed in %s file." % ( name, 'resource' if is_resource else 'suite initialization' @@ -102,7 +103,8 @@ def _lex_error(self, setting, values, error): def _lex_setting(self, setting, values, name): self.settings[name] = values - setting.type = name.upper() + # TODO: Change token type from 'FORCE TAGS' to 'TEST TAGS' in RF 5.2. + setting.type = name.upper() if name != 'Test Tags' else 'FORCE TAGS' if name in self.name_and_arguments: self._lex_name_and_arguments(values) elif name in self.name_arguments_and_with_name: @@ -137,7 +139,7 @@ class TestCaseFileSettings(Settings): 'Test Teardown', 'Test Template', 'Test Timeout', - 'Force Tags', + 'Test Tags', 'Default Tags', 'Keyword Tags', 'Library', @@ -145,8 +147,8 @@ class TestCaseFileSettings(Settings): 'Variables' ) aliases = { - 'Test Tags': 'Force Tags', - 'Task Tags': 'Force Tags', + 'Force Tags': 'Test Tags', + 'Task Tags': 'Test Tags', 'Task Setup': 'Test Setup', 'Task Teardown': 'Test Teardown', 'Task Template': 'Test Template', @@ -163,15 +165,15 @@ class InitFileSettings(Settings): 'Test Setup', 'Test Teardown', 'Test Timeout', - 'Force Tags', + 'Test Tags', 'Keyword Tags', 'Library', 'Resource', 'Variables' ) aliases = { - 'Test Tags': 'Force Tags', - 'Task Tags': 'Force Tags', + 'Force Tags': 'Test Tags', + 'Task Tags': 'Test Tags', 'Task Setup': 'Test Setup', 'Task Teardown': 'Test Teardown', 'Task Timeout': 'Test Timeout', diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index a2f67786461..e4a08a76e84 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -140,6 +140,7 @@ def test_suite_settings_not_allowed_in_resource_file(self): Test Timeout 1 day Force Tags foo bar Default Tags zap +Task Tags quux Documentation Valid in all data files. ''' # Values of invalid settings are ignored with `data_only=True`. @@ -173,9 +174,12 @@ def test_suite_settings_not_allowed_in_resource_file(self): (T.ERROR, 'Default Tags', 10, 0, "Setting 'Default Tags' is not allowed in resource file."), (T.EOS, '', 10, 12), - (T.DOCUMENTATION, 'Documentation', 11, 0), - (T.ARGUMENT, 'Valid in all data files.', 11, 18), - (T.EOS, '', 11, 42) + (T.ERROR, 'Task Tags', 11, 0, + "Setting 'Task Tags' is not allowed in resource file."), + (T.EOS, '', 11, 9), + (T.DOCUMENTATION, 'Documentation', 12, 0), + (T.ARGUMENT, 'Valid in all data files.', 12, 18), + (T.EOS, '', 12, 42) ] assert_tokens(data, expected, get_resource_tokens, data_only=True) From 0e3fc01a24f327841ac2c3196e7a2eba8b6ae1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 6 Jul 2022 17:53:23 +0300 Subject: [PATCH 0081/1592] Better error handling with --language files. #4096 #519 --- atest/robot/parsing/translations.robot | 10 ++++++++++ src/robot/conf/languages.py | 7 +++---- src/robot/conf/settings.py | 7 +++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index 49f65d3c66b..20bb1ded95d 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -10,6 +10,16 @@ Custom language Run Tests --lang ${DATADIR}/parsing/custom-lang.py parsing/custom.robot Validate Translations +Invalid + ${result} = Run Tests Without Processing Output --lang bad parsing/finnish.robot + Should Be Equal ${result.rc} ${252} + Should Be Empty ${result.stdout} + ${error} = Catenate SEPARATOR=\n + ... Invalid value for option '--language': Importing language file 'bad' failed: ModuleNotFoundError: No module named 'bad' + ... Traceback \\(most recent call last\\): + ... .*${USAGE TIP} + Should Match Regexp ${result.stderr} (?s)^\\[ ERROR \\] ${error}$ + *** Keywords *** Validate Translations Should Be Equal ${SUITE.doc} Suite documentation. diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 55c30770782..f191d8fef98 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -45,15 +45,14 @@ def _resolve_languages(self, languages): return languages def _import_languages(self, lang): - def find_subclass(member): + def is_language(member): return (inspect.isclass(member) and issubclass(member, Language) and member is not Language) - # FIXME: error handling if os.path.exists(lang): lang = os.path.abspath(lang) - module = Importer().import_module(lang) - return [value for _, value in inspect.getmembers(module, find_subclass)] + module = Importer('language file').import_module(lang) + return [value for _, value in inspect.getmembers(module, is_language)] def __iter__(self): return iter(self.languages) diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index f6ed53ba341..e02b6b24b30 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -505,8 +505,11 @@ def debug_file(self): @property def languages(self): - if not self._languages: - self._languages = Languages(self['Language']) + if self._languages is None: + try: + self._languages = Languages(self['Language']) + except DataError as err: + self._raise_invalid('Language', err) return self._languages @property From 00b30ee5dde531940cebafe4c4e34c2b96a6cf7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 6 Jul 2022 19:10:54 +0300 Subject: [PATCH 0082/1592] Remove unnecessary indirection. --- src/robot/parsing/lexer/context.py | 82 ++++++++++++++++------ src/robot/parsing/lexer/sections.py | 101 ---------------------------- 2 files changed, 60 insertions(+), 123 deletions(-) delete mode 100644 src/robot/parsing/lexer/sections.py diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 44da7636689..27b41935055 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -13,11 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from robot.utils import normalize_whitespace + from .markers import Markers -from .sections import (InitFileSections, TestCaseFileSections, - ResourceFileSections) -from .settings import (InitFileSettings, TestCaseFileSettings, - ResourceFileSettings, TestCaseSettings, KeywordSettings) +from .settings import (InitFileSettings, TestCaseFileSettings, ResourceFileSettings, + TestCaseSettings, KeywordSettings) +from .tokens import Token class LexingContext: @@ -36,56 +37,93 @@ def lex_setting(self, statement): class FileContext(LexingContext): - sections_class = None def __init__(self, settings=None, lang=None): super().__init__(settings, lang) - # TODO: should .sections be removed as unnecessary indirection? - self.sections = self.sections_class(self.markers) + + def keyword_context(self): + return KeywordContext(settings=KeywordSettings(self.markers)) def setting_section(self, statement): - return self.sections.setting(statement) + return self._handles_section(statement, self.markers.setting_section) def variable_section(self, statement): - return self.sections.variable(statement) + return self._handles_section(statement, self.markers.variable_section) def test_case_section(self, statement): - return self.sections.test_case(statement) + return False def task_section(self, statement): - return self.sections.task(statement) + return False def keyword_section(self, statement): - return self.sections.keyword(statement) + return self._handles_section(statement, self.markers.keyword_section) def comment_section(self, statement): - return self.sections.comment(statement) - - def keyword_context(self): - return KeywordContext(settings=KeywordSettings(self.markers)) + return self._handles_section(statement, self.markers.comment_section) def lex_invalid_section(self, statement): - self.sections.lex_invalid(statement) + message, fatal = self._get_invalid_section_error(statement[0].value) + statement[0].set_error(message, fatal) + for token in statement[1:]: + token.type = Token.COMMENT + + def _get_invalid_section_error(self, header): + raise NotImplementedError + + def _handles_section(self, statement, validator): + marker = statement[0].value + return marker.startswith('*') and validator(self._normalize(marker)) + + def _normalize(self, marker): + return normalize_whitespace(marker).strip('* ').title() class TestCaseFileContext(FileContext): - sections_class = TestCaseFileSections settings_class = TestCaseFileSettings def test_case_context(self): - return TestCaseContext(settings=TestCaseSettings(self.settings, - self.markers)) + return TestCaseContext(settings=TestCaseSettings(self.settings, self.markers)) + + def test_case_section(self, statement): + return self._handles_section(statement, self.markers.test_case_section) + + def task_section(self, statement): + return self._handles_section(statement, self.markers.task_section) + + def _get_invalid_section_error(self, header): + return (f"Unrecognized section header '{header}'. Valid sections: " + f"'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' " + f"and 'Comments'."), False class ResourceFileContext(FileContext): - sections_class = ResourceFileSections settings_class = ResourceFileSettings + def _get_invalid_section_error(self, header): + name = self._normalize(header) + if self.markers.test_case_section(name) or self.markers.task_section(name): + message = f"Resource file with '{name}' section is invalid." + fatal = True + else: + message = (f"Unrecognized section header '{header}'. Valid sections: " + f"'Settings', 'Variables', 'Keywords' and 'Comments'.") + fatal = False + return message, fatal + class InitFileContext(FileContext): - sections_class = InitFileSections settings_class = InitFileSettings + def _get_invalid_section_error(self, header): + name = self._normalize(header) + if self.markers.test_case_section(name) or self.markers.task_section(name): + message = f"'{name}' section is not allowed in suite initialization file." + else: + message = (f"Unrecognized section header '{header}'. Valid sections: " + f"'Settings', 'Variables', 'Keywords' and 'Comments'.") + return message, False + class TestCaseContext(LexingContext): diff --git a/src/robot/parsing/lexer/sections.py b/src/robot/parsing/lexer/sections.py deleted file mode 100644 index da8fe5fdda4..00000000000 --- a/src/robot/parsing/lexer/sections.py +++ /dev/null @@ -1,101 +0,0 @@ -# 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 normalize_whitespace - -from .tokens import Token - - -class Sections: - - def __init__(self, markers): - self.markers = markers - - def setting(self, statement): - return self._handles(statement, self.markers.setting_section) - - def variable(self, statement): - return self._handles(statement, self.markers.variable_section) - - def test_case(self, statement): - return False - - def task(self, statement): - return False - - def keyword(self, statement): - return self._handles(statement, self.markers.keyword_section) - - def comment(self, statement): - return self._handles(statement, self.markers.comment_section) - - def _handles(self, statement, validator): - marker = statement[0].value - return marker.startswith('*') and validator(self._normalize(marker)) - - def _normalize(self, marker): - return normalize_whitespace(marker).strip('* ').title() - - def lex_invalid(self, statement): - message, fatal = self._get_invalid_section_error(statement[0].value) - statement[0].set_error(message, fatal) - for token in statement[1:]: - token.type = Token.COMMENT - - def _get_invalid_section_error(self, header): - raise NotImplementedError - - -class TestCaseFileSections(Sections): - - def test_case(self, statement): - return self._handles(statement, self.markers.test_case_section) - - def task(self, statement): - return self._handles(statement, self.markers.task_section) - - def _get_invalid_section_error(self, header): - return ("Unrecognized section header '%s'. Valid sections: " - "'Settings', 'Variables', 'Test Cases', 'Tasks', " - "'Keywords' and 'Comments'." % header), False - - -class ResourceFileSections(Sections): - - def _get_invalid_section_error(self, header): - name = self._normalize(header) - if self.markers.test_case_section(name) or self.markers.task_section(name): - message = "Resource file with '%s' section is invalid." % name - fatal = True - else: - message = ("Unrecognized section header '%s'. Valid sections: " - "'Settings', 'Variables', 'Keywords' and 'Comments'." - % header) - fatal = False - return message, fatal - - -class InitFileSections(Sections): - - def _get_invalid_section_error(self, header): - name = self._normalize(header) - if self.markers.test_case_section(name) or self.markers.task_section(name): - message = ("'%s' section is not allowed in suite initialization " - "file." % name) - else: - message = ("Unrecognized section header '%s'. Valid sections: " - "'Settings', 'Variables', 'Keywords' and 'Comments'." - % header) - return message, False From 3819a7c4774a6166909e39a7d9d163f3cf08ca7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 6 Jul 2022 19:47:14 +0300 Subject: [PATCH 0083/1592] Remove more unnecessary indirection --- atest/testdata/parsing/custom-lang.py | 2 +- src/robot/conf/languages.py | 20 +++++++++ src/robot/parsing/lexer/context.py | 33 +++++++------- src/robot/parsing/lexer/markers.py | 63 --------------------------- src/robot/parsing/lexer/settings.py | 6 +-- src/robot/running/namespace.py | 11 +++-- 6 files changed, 46 insertions(+), 89 deletions(-) delete mode 100644 src/robot/parsing/lexer/markers.py diff --git a/atest/testdata/parsing/custom-lang.py b/atest/testdata/parsing/custom-lang.py index 944e47d55af..571b1ce8b69 100644 --- a/atest/testdata/parsing/custom-lang.py +++ b/atest/testdata/parsing/custom-lang.py @@ -27,4 +27,4 @@ class Custom(Language): tags = 'S 14' timeout = 'S 15' arguments = 'S 16' - bdd_prefixes = {} + bdd_prefixes = set() diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index f191d8fef98..f57db0e7ae9 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -23,6 +23,23 @@ class Languages: def __init__(self, languages): self.languages = self._get_languages(languages) + self.setting_headers = set() + self.variable_headers = set() + self.test_case_headers = set() + self.task_headers = set() + self.keyword_headers = set() + self.comment_headers = set() + self.settings = {} + self.bdd_prefixes = set() + for lang in self.languages: + self.setting_headers |= lang.setting_headers + self.variable_headers |= lang.variable_headers + self.test_case_headers |= lang.test_case_headers + self.task_headers |= lang.task_headers + self.keyword_headers |= lang.keyword_headers + self.comment_headers |= lang.comment_headers + self.settings.update(lang.settings) + self.bdd_prefixes |= lang.bdd_prefixes def _get_languages(self, languages): languages = self._resolve_languages(languages) @@ -54,6 +71,9 @@ def is_language(member): module = Importer('language file').import_module(lang) return [value for _, value in inspect.getmembers(module, is_language)] + def translate_setting(self, name): + return self.settings.get(name, name) + def __iter__(self): return iter(self.languages) diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 27b41935055..872daf14cd9 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -13,9 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from robot.conf import Languages from robot.utils import normalize_whitespace -from .markers import Markers from .settings import (InitFileSettings, TestCaseFileSettings, ResourceFileSettings, TestCaseSettings, KeywordSettings) from .tokens import Token @@ -26,10 +26,11 @@ class LexingContext: def __init__(self, settings=None, lang=None): if not settings: - self.markers = Markers(lang) - self.settings = self.settings_class(self.markers) + # FIXME: Add unit test + self.languages = lang if isinstance(lang, Languages) else Languages(lang) + self.settings = self.settings_class(self.languages) else: - self.markers = settings.markers + self.languages = settings.languages self.settings = settings def lex_setting(self, statement): @@ -42,13 +43,13 @@ def __init__(self, settings=None, lang=None): super().__init__(settings, lang) def keyword_context(self): - return KeywordContext(settings=KeywordSettings(self.markers)) + return KeywordContext(settings=KeywordSettings(self.languages)) def setting_section(self, statement): - return self._handles_section(statement, self.markers.setting_section) + return self._handles_section(statement, self.languages.setting_headers) def variable_section(self, statement): - return self._handles_section(statement, self.markers.variable_section) + return self._handles_section(statement, self.languages.variable_headers) def test_case_section(self, statement): return False @@ -57,10 +58,10 @@ def task_section(self, statement): return False def keyword_section(self, statement): - return self._handles_section(statement, self.markers.keyword_section) + return self._handles_section(statement, self.languages.keyword_headers) def comment_section(self, statement): - return self._handles_section(statement, self.markers.comment_section) + return self._handles_section(statement, self.languages.comment_headers) def lex_invalid_section(self, statement): message, fatal = self._get_invalid_section_error(statement[0].value) @@ -71,9 +72,9 @@ def lex_invalid_section(self, statement): def _get_invalid_section_error(self, header): raise NotImplementedError - def _handles_section(self, statement, validator): + def _handles_section(self, statement, headers): marker = statement[0].value - return marker.startswith('*') and validator(self._normalize(marker)) + return marker.startswith('*') and self._normalize(marker) in headers def _normalize(self, marker): return normalize_whitespace(marker).strip('* ').title() @@ -83,13 +84,13 @@ class TestCaseFileContext(FileContext): settings_class = TestCaseFileSettings def test_case_context(self): - return TestCaseContext(settings=TestCaseSettings(self.settings, self.markers)) + return TestCaseContext(settings=TestCaseSettings(self.settings, self.languages)) def test_case_section(self, statement): - return self._handles_section(statement, self.markers.test_case_section) + return self._handles_section(statement, self.languages.test_case_headers) def task_section(self, statement): - return self._handles_section(statement, self.markers.task_section) + return self._handles_section(statement, self.languages.task_headers) def _get_invalid_section_error(self, header): return (f"Unrecognized section header '{header}'. Valid sections: " @@ -102,7 +103,7 @@ class ResourceFileContext(FileContext): def _get_invalid_section_error(self, header): name = self._normalize(header) - if self.markers.test_case_section(name) or self.markers.task_section(name): + if name in self.languages.test_case_headers | self.languages.task_headers: message = f"Resource file with '{name}' section is invalid." fatal = True else: @@ -117,7 +118,7 @@ class InitFileContext(FileContext): def _get_invalid_section_error(self, header): name = self._normalize(header) - if self.markers.test_case_section(name) or self.markers.task_section(name): + if name in self.languages.test_case_headers | self.languages.task_headers: message = f"'{name}' section is not allowed in suite initialization file." else: message = (f"Unrecognized section header '{header}'. Valid sections: " diff --git a/src/robot/parsing/lexer/markers.py b/src/robot/parsing/lexer/markers.py deleted file mode 100644 index 2f16690be13..00000000000 --- a/src/robot/parsing/lexer/markers.py +++ /dev/null @@ -1,63 +0,0 @@ -# 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.conf import Languages - - -class Markers: - # FIXME: should this be merged with conf.Languages - - def __init__(self, languages): - if not isinstance(languages, Languages): - # FIXME: add unit test - languages = Languages(languages) - self.setting_headers = set() - self.variable_headers = set() - self.test_case_headers = set() - self.task_headers = set() - self.keyword_headers = set() - self.comment_headers = set() - self.settings = {} - for lang in languages: - self.setting_headers |= lang.setting_headers - self.variable_headers |= lang.variable_headers - self.test_case_headers |= lang.test_case_headers - self.task_headers |= lang.task_headers - self.keyword_headers |= lang.keyword_headers - self.comment_headers |= lang.comment_headers - self.settings.update(lang.settings) - - def setting_section(self, marker): - return marker in self.setting_headers - - def variable_section(self, marker): - return marker in self.variable_headers - - def test_case_section(self, marker): - return marker in self.test_case_headers - - def task_section(self, marker): - return marker in self.task_headers - - def keyword_section(self, marker): - return marker in self.keyword_headers - - def comment_section(self, marker): - return marker in self.comment_headers - - def translate(self, value): - if value in self.settings: - return self.settings[value] - return value diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 8cc3787b319..8d774f863d7 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -51,15 +51,15 @@ class Settings: 'Library', ) - def __init__(self, markers): + def __init__(self, languages): self.settings = {n: None for n in self.names} - self.markers = markers + self.languages = languages def lex(self, statement): setting = statement[0] orig = self._format_name(setting.value) name = normalize_whitespace(orig).title() - name = self.markers.translate(name) + name = self.languages.translate_setting(name) if name in self.aliases: name = self.aliases[name] try: diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index a1a56a82992..8c0e6a6cd86 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -300,12 +300,11 @@ def _get_bdd_style_runner(self, name): if len(parts) < 2: return None prefix, keyword = parts - for lang in self.languages: - if prefix.title() in lang.bdd_prefixes: - runner = self._get_runner(keyword) - if runner: - runner = copy.copy(runner) - runner.name = name + if prefix.title() in self.languages.bdd_prefixes: + runner = self._get_runner(keyword) + if runner: + runner = copy.copy(runner) + runner.name = name return runner return None From 0a691b8b6477f250c5a16767da94efdce264b373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 6 Jul 2022 19:48:13 +0300 Subject: [PATCH 0084/1592] Add unit test for parsing API language config. --- src/robot/parsing/lexer/context.py | 1 - utest/parsing/test_lexer.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 872daf14cd9..eb69b1e3671 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -26,7 +26,6 @@ class LexingContext: def __init__(self, settings=None, lang=None): if not settings: - # FIXME: Add unit test self.languages = lang if isinstance(lang, Languages) else Languages(lang) self.settings = self.settings_class(self.languages) else: diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index e4a08a76e84..eec1a6b39ea 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -4,6 +4,7 @@ from io import StringIO from pathlib import Path +from robot.conf import Languages from robot.utils.asserts import assert_equal from robot.parsing import get_tokens, get_init_tokens, get_resource_tokens, Token @@ -2193,5 +2194,36 @@ def _verify(self, data, expected, test=False): assert_tokens(data, expected, data_only=True) +class TestLanguageConfig(unittest.TestCase): + + def test_lang_as_string(self): + self._test('fi') + + def test_lang_as_list(self): + self._test(['fi']) + + def test_lang_as_Languages(self): + self._test(Languages('fi')) + + def _test(self, lang): + data = '''\ +*** Asetukset *** +Dokumentaatio Documentation +''' + expected = [ + (T.SETTING_HEADER, '*** Asetukset ***', 1, 0), + (T.EOL, '\n', 1, 17), + (T.EOS, '', 1, 18), + (T.DOCUMENTATION, 'Dokumentaatio', 2, 0), + (T.SEPARATOR, ' ', 2, 13), + (T.ARGUMENT, 'Documentation', 2, 17), + (T.EOL, '\n', 2, 30), + (T.EOS, '', 2, 31), + ] + assert_tokens(data, expected, get_tokens, lang=lang) + assert_tokens(data, expected, get_init_tokens, lang=lang) + assert_tokens(data, expected, get_resource_tokens, lang=lang) + + if __name__ == '__main__': unittest.main() From 9f6042d9f209a13567e69ea8b7d8795e71cca6aa Mon Sep 17 00:00:00 2001 From: Andrii Oriekhov <andriyorehov@gmail.com> Date: Wed, 6 Jul 2022 23:30:30 +0300 Subject: [PATCH 0085/1592] add GitHub URL for PyPi (#4248) --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index 61f0daaa6bd..172ab646c37 100755 --- a/setup.py +++ b/setup.py @@ -49,6 +49,9 @@ author = 'Pekka Kl\xe4rck', author_email = 'peke@eliga.fi', url = 'https://robotframework.org/', + project_urls = { + 'Source': 'https://github.com/robotframework/robotframework', + }, download_url = 'https://pypi.org/project/robotframework/', license = 'Apache License 2.0', description = DESCRIPTION, From bade11bb7f7b301e31d6232590694a5d6033b004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 6 Jul 2022 23:52:59 +0300 Subject: [PATCH 0086/1592] Add Tracker and Twitter to PyPI project links --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 172ab646c37..8e932efa48f 100755 --- a/setup.py +++ b/setup.py @@ -51,6 +51,8 @@ url = 'https://robotframework.org/', project_urls = { 'Source': 'https://github.com/robotframework/robotframework', + 'Tracker': 'https://github.com/robotframework/robotframework/issues', + 'Twitter': 'https://twitter.com/robotframework/' }, download_url = 'https://pypi.org/project/robotframework/', license = 'Apache License 2.0', From 501e2cab46f95c202aabdba107582a39533afad6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Jul 2022 19:52:09 +0300 Subject: [PATCH 0087/1592] Bump octokit/request-action from 2.1.4 to 2.1.6 (#4393) Bumps [octokit/request-action](https://github.com/octokit/request-action) from 2.1.4 to 2.1.6. - [Release notes](https://github.com/octokit/request-action/releases) - [Commits](https://github.com/octokit/request-action/compare/971ad48f9c40ed001c41c2671b1e6e8e8165d5af...8509fdb30e17659bffb27878bb307fceb3ee2a64) --- updated-dependencies: - dependency-name: octokit/request-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/acceptance_tests_cpython_pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 80f8fa24db8..82f1b2d7a7e 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -123,7 +123,7 @@ jobs: echo "JOB_STATUS=$(python -c "print('${{ job.status }}'.lower())")" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: always() && job.status == 'failure' && runner.os == 'Windows' - - uses: octokit/request-action@971ad48f9c40ed001c41c2671b1e6e8e8165d5af + - uses: octokit/request-action@8509fdb30e17659bffb27878bb307fceb3ee2a64 name: Update status with Github Status API id: update_status with: diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 00f0eb268c7..6570d32ac4d 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -111,7 +111,7 @@ jobs: echo "JOB_STATUS=$(python -c "print('${{ job.status }}'.lower())")" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: always() && job.status == 'failure' && runner.os == 'Windows' - - uses: octokit/request-action@971ad48f9c40ed001c41c2671b1e6e8e8165d5af + - uses: octokit/request-action@8509fdb30e17659bffb27878bb307fceb3ee2a64 name: Update status with Github Status API id: update_status with: From 3ad387ce15d1ba0623af74e49069cdb4a4939789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 11 Jul 2022 20:35:12 +0300 Subject: [PATCH 0088/1592] Deprecate test case file kws having precedence over local resource kws. Fixes #4366. --- atest/robot/keywords/keyword_namespaces.robot | 15 ++++++-- .../keywords/keyword_namespaces.robot | 3 ++ .../keywords/resources/my_resource_1.robot | 3 ++ src/robot/running/librarykeywordrunner.py | 2 +- src/robot/running/namespace.py | 34 ++++++++++++++----- src/robot/running/userkeywordrunner.py | 2 +- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/atest/robot/keywords/keyword_namespaces.robot b/atest/robot/keywords/keyword_namespaces.robot index 30a4b99e26e..fbe4058866b 100644 --- a/atest/robot/keywords/keyword_namespaces.robot +++ b/atest/robot/keywords/keyword_namespaces.robot @@ -25,6 +25,15 @@ Keyword From Test Case File Overrides Keywords From Resources And Libraries Keyword From Resource Overrides Keywords From Libraries Check Test Case ${TEST NAME} +Keyword From Test Case File Overriding Local Keyword In Resource File Is Deprecated + ${tc} = Check Test Case ${TEST NAME} + ${message} = Catenate + ... Keyword 'my_resource_1.Use test case file keyword even when local keyword with same name exists' called + ... keyword 'Keyword Everywhere' that exist both in the same resource file and in the test case file using + ... that resource. The keyword in the test case file is used now, but this will change in Robot Framework 6.0. + Check Log Message ${tc.body[0].body[0].msgs[0]} ${message} WARN + Check Log Message ${ERRORS}[1] ${message} WARN + Local keyword in resource file has precedence over keywords in other resource files ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} Keyword in resource 1 @@ -32,12 +41,12 @@ Local keyword in resource file has precedence over keywords in other resource fi Keyword From Custom Library Overrides Keywords From Standard Library ${tc} = Check Test Case ${TEST NAME} - Verify Override Message ${ERRORS[1]} ${tc.kws[0].msgs[0]} Comment BuiltIn - Verify Override Message ${ERRORS[2]} ${tc.kws[1].msgs[0]} Copy Directory OperatingSystem + Verify Override Message ${ERRORS}[2] ${tc.kws[0].msgs[0]} Comment BuiltIn + Verify Override Message ${ERRORS}[3] ${tc.kws[1].msgs[0]} Copy Directory OperatingSystem Keyword From Custom Library Overrides Keywords From Standard Library Even When Std Lib Imported With Different Name ${tc} = Check Test Case ${TEST NAME} - Verify Override Message ${ERRORS[3]} ${tc.kws[0].msgs[0]} Replace String + Verify Override Message ${ERRORS}[4] ${tc.kws[0].msgs[0]} Replace String ... String MyLibrary2 Std With Name My With Name No Warning When Custom Library Keyword Is Registered As RunKeyword Variant And It Has Same Name As Std Keyword diff --git a/atest/testdata/keywords/keyword_namespaces.robot b/atest/testdata/keywords/keyword_namespaces.robot index 4e9cc9ba74a..4473d735fcf 100644 --- a/atest/testdata/keywords/keyword_namespaces.robot +++ b/atest/testdata/keywords/keyword_namespaces.robot @@ -56,6 +56,9 @@ Keyword From Test Case File Overrides Keywords From Resources And Libraries Keyword From Resource Overrides Keywords From Libraries Keyword In Resource Overrides Libraries +Keyword From Test Case File Overriding Local Keyword In Resource File Is Deprecated + Use test case file keyword even when local keyword with same name exists + Local keyword in resource file has precedence over keywords in other resource files Use local keyword that exists also in another resource 1 Use local keyword that exists also in another resource 2 diff --git a/atest/testdata/keywords/resources/my_resource_1.robot b/atest/testdata/keywords/resources/my_resource_1.robot index 73b16df9d51..ea4f96ea32e 100644 --- a/atest/testdata/keywords/resources/my_resource_1.robot +++ b/atest/testdata/keywords/resources/my_resource_1.robot @@ -14,6 +14,9 @@ Keyword In All Resources And Libraries Keyword In Resource 1 And Libraries Log Keyword in resource 1 +Use test case file keyword even when local keyword with same name exists + Keyword Everywhere + Keyword Everywhere Log Keyword in resource 1 diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index 1097e9c75c3..ed9540c8f09 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -31,7 +31,7 @@ class LibraryKeywordRunner: def __init__(self, handler, name=None): self._handler = handler self.name = name or handler.name - self.pre_run_messages = None + self.pre_run_messages = () @property def library(self): diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 8c0e6a6cd86..ea2bab0f279 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -315,8 +315,28 @@ def _get_implicit_runner(self, name): return runner def _get_runner_from_test_case_file(self, name): - if name in self.user_keywords.handlers: - return self.user_keywords.handlers.create_runner(name) + if name not in self.user_keywords.handlers: + return None + runner = self.user_keywords.handlers.create_runner(name) + ctx = EXECUTION_CONTEXTS.current + caller = ctx.user_keywords[-1] if ctx.user_keywords else ctx.test + if caller and runner.source != caller.source: + local_runner = self._get_runner_from_resource_file(name, caller.source) + if local_runner: + message = ( + f"Keyword '{caller.longname}' called keyword '{name}' that " + f"exist both in the same resource file and in the test case " + f"file using that resource. The keyword in the test case file " + f"is used now, but this will change in Robot Framework 6.0." + ) + runner.pre_run_messages += Message(message, level='WARN'), + return runner + + def _get_runner_from_resource_file(self, name, source): + for resource in self.resources.values(): + if resource.source == source and name in resource.handlers: + return resource.handlers.create_runner(name) + return None def _get_runner_from_resource_files(self, name): found = [lib.handlers.create_runner(name) @@ -386,18 +406,14 @@ def _custom_and_standard_keyword_conflict_warning(self, custom, standard): custom_with_name = f" imported as '{custom.library.name}'" if standard.library.name != standard.library.orig_name: standard_with_name = f" imported as '{standard.library.name}'" - warning = Message( + message = ( f"Keyword '{standard.name}' found both from a custom library " f"'{custom.library.orig_name}'{custom_with_name} and a standard library " f"'{standard.library.orig_name}'{standard_with_name}. The custom keyword " f"is used. To select explicitly, and to get rid of this warning, use " - f"either '{custom.longname}' or '{standard.longname}'.", - level='WARN' + f"either '{custom.longname}' or '{standard.longname}'." ) - if custom.pre_run_messages: - custom.pre_run_messages.append(warning) - else: - custom.pre_run_messages = [warning] + custom.pre_run_messages += Message(message, level='WARN'), def _get_explicit_runner(self, name): found = [] diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 2e7590b6aaf..47096695b22 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -33,7 +33,7 @@ class UserKeywordRunner: def __init__(self, handler, name=None): self._handler = handler self.name = name or handler.name - self.pre_run_messages = None + self.pre_run_messages = () @property def longname(self): From 272f3f7664ddd5604c71fcc3a7c6610ec388df64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 11 Jul 2022 21:09:50 +0300 Subject: [PATCH 0089/1592] Update projects URLs to be listed at PyPI (#4312) - Change Tracker to Issue Tracker - Add Documentation - Add Release Notes --- setup.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 8e932efa48f..ddc20290955 100755 --- a/setup.py +++ b/setup.py @@ -48,13 +48,15 @@ version = VERSION, author = 'Pekka Kl\xe4rck', author_email = 'peke@eliga.fi', - url = 'https://robotframework.org/', + url = 'https://robotframework.org', project_urls = { 'Source': 'https://github.com/robotframework/robotframework', - 'Tracker': 'https://github.com/robotframework/robotframework/issues', - 'Twitter': 'https://twitter.com/robotframework/' + 'Issue Tracker': 'https://github.com/robotframework/robotframework/issues', + 'Documentation': 'https://robotframework.org/robotframework', + 'Release Notes': f'https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-{VERSION}.rst', + 'Twitter': 'https://twitter.com/robotframework', }, - download_url = 'https://pypi.org/project/robotframework/', + download_url = 'https://pypi.org/project/robotframework', license = 'Apache License 2.0', description = DESCRIPTION, long_description = LONG_DESCRIPTION, From b9f697af6254c5adc9682f5d7292bc59a413dc9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 14:51:07 +0300 Subject: [PATCH 0090/1592] Fix --doc/--metadata when value matches existing directory. Fixes #4394. --- .../cli/rebot/suite_name_doc_and_metadata.robot | 12 +++++++++--- .../cli/runner/suite_name_doc_and_metadata.robot | 12 +++++++++--- src/robot/conf/settings.py | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/atest/robot/cli/rebot/suite_name_doc_and_metadata.robot b/atest/robot/cli/rebot/suite_name_doc_and_metadata.robot index 6fc5b0e9f6a..861dddf3cfe 100644 --- a/atest/robot/cli/rebot/suite_name_doc_and_metadata.robot +++ b/atest/robot/cli/rebot/suite_name_doc_and_metadata.robot @@ -34,14 +34,20 @@ Documentation and metadata from external file Check All Names ${SUITE} Normal Should Be Equal ${SUITE.doc} ${value.rstrip()} Should Be Equal ${SUITE.metadata['name']} ${value.rstrip()} - Run Rebot --doc " ${path}" --metadata "name: ${path}" ${INPUT FILE} + Run Rebot --doc " ${path}" --metadata "name: ${path}" -M dir:. ${INPUT FILE} Check All Names ${SUITE} Normal Should Be Equal ${SUITE.doc} ${path} Should Be Equal ${SUITE.metadata['name']} ${path} + Should Be Equal ${SUITE.metadata['dir']} . Invalid external file - Run Rebot Without Processing Output --doc . ${INPUT FILE} - Stderr Should Match [[] ERROR []] Invalid value for option '--doc': Reading documentation from '.' failed: *${USAGE TIP}\n + [Tags] no-windows + ${path} = Normalize Path %{TEMPDIR}/file.txt + Create File ${path} + Evaluate os.chmod('${path}', 0) + Run Rebot Without Processing Output --doc ${path} ${INPUT FILE} + Stderr Should Match [[] ERROR []] Invalid value for option '--doc': Reading documentation from '${path}' failed: *${USAGE TIP}\n + [Teardown] Remove File ${path} *** Keywords *** Check All Names diff --git a/atest/robot/cli/runner/suite_name_doc_and_metadata.robot b/atest/robot/cli/runner/suite_name_doc_and_metadata.robot index c1377bb3935..4a300f7248a 100644 --- a/atest/robot/cli/runner/suite_name_doc_and_metadata.robot +++ b/atest/robot/cli/runner/suite_name_doc_and_metadata.robot @@ -35,14 +35,20 @@ Documentation and metadata from external file Check All Names ${SUITE} Normal Should Be Equal ${SUITE.doc} ${value.rstrip()} Should Be Equal ${SUITE.metadata['name']} ${value.rstrip()} - Run Tests --doc " ${path}" --metadata "name: ${path}" ${TEST FILE} + Run Tests --doc " ${path}" --metadata "name: ${path}" -M dir:%{TEMPDIR} ${TEST FILE} Check All Names ${SUITE} Normal Should Be Equal ${SUITE.doc} ${path} Should Be Equal ${SUITE.metadata['name']} ${path} + Should Be Equal ${SUITE.metadata['dir']} %{TEMPDIR} Invalid external file - Run Tests Without Processing Output --doc . ${TEST FILE} - Stderr Should Match [[] ERROR []] Invalid value for option '--doc': Reading documentation from '.' failed: *${USAGE TIP}\n + [Tags] no-windows + ${path} = Normalize Path %{TEMPDIR}/file.txt + Create File ${path} + Evaluate os.chmod('${path}', 0) + Run Tests Without Processing Output --doc ${path} ${TEST FILE} + Stderr Should Match [[] ERROR []] Invalid value for option '--doc': Reading documentation from '${path}' failed: *${USAGE TIP}\n + [Teardown] Remove File ${path} *** Keywords *** Check All Names diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index e02b6b24b30..59c2371d319 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -142,7 +142,7 @@ def _process_value(self, name, value): return value def _process_doc(self, value): - if isinstance(value, Path) or (os.path.exists(value) and value.strip() == value): + if isinstance(value, Path) or (os.path.isfile(value) and value.strip() == value): try: with open(value) as f: value = f.read() From e60897e88efd2375aed38a03515bc8de8b6d9dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 22:05:36 +0300 Subject: [PATCH 0091/1592] Stricter flag usage in regexp patterns. Part of Python 3.11 compatibility. #4401 --- atest/robot/cli/runner/debugfile.robot | 4 ++-- src/robot/libraries/String.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/atest/robot/cli/runner/debugfile.robot b/atest/robot/cli/runner/debugfile.robot index 984e3e8773b..0e366d6978b 100644 --- a/atest/robot/cli/runner/debugfile.robot +++ b/atest/robot/cli/runner/debugfile.robot @@ -23,8 +23,8 @@ Debugfile Debug file should contain ${content} + END SUITE: Normal Syslog Should Contain DebugFile: DeBug.TXT ${path} = Set Variable [:.\\w /\\\\~+-]*DeBug\\.TXT - Stdout Should Match Regexp (?s).*Debug: {3}${path}.* - Syslog Should Match Regexp (?s).*Debug: ${path}.* + Stdout Should Match Regexp .*Debug: {3}${path}.* + Syslog Should Match Regexp .*Debug: ${path}.* Debugfile Log Level Should Always Be Debug [Documentation] --loglevel option should not affect what's written to debugfile diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index 79be6ffcd13..3835a1bb04b 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -368,9 +368,11 @@ def get_lines_matching_regexp(self, string, pattern, partial_match=False): String` if you do not need full regular expression powers (and complexity). """ - if not is_truthy(partial_match): - pattern = '^%s$' % pattern - return self._get_matching_lines(string, re.compile(pattern).search) + if is_truthy(partial_match): + match = re.compile(pattern).search + else: + match = re.compile(pattern + '$').match + return self._get_matching_lines(string, match) def _get_matching_lines(self, string, matches): lines = string.splitlines() From b10ca9040b91c0adaeac13db14054182d4d677eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 22:06:57 +0300 Subject: [PATCH 0092/1592] Fix traceback related test on Python 3.11. #4401 Filter away new `^^^` lines showing where the error occurred. --- utest/utils/test_error.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utest/utils/test_error.py b/utest/utils/test_error.py index 38cad78c058..9db4c40bb72 100644 --- a/utest/utils/test_error.py +++ b/utest/utils/test_error.py @@ -98,6 +98,8 @@ def _verify_traceback(self, expected, method, *args): tb = ErrorDetails(error).traceback else: raise AssertionError + # Remove lines indicating error location with `^^^^` used by Python 3.11+. + tb = '\n'.join(line for line in tb.splitlines() if line.strip('^ ')) if not re.match(expected, tb): raise AssertionError('\nExpected:\n%s\n\nActual:\n%s' % (expected, tb)) From e52c98fc32d4b333c5b7352cb5f58c3a786a7488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 22:08:35 +0300 Subject: [PATCH 0093/1592] Adjust code and tests to new `typing.get_type_hints` semantics. Python 3.11 changed `typing.get_type_hints` so that `None` isn't anymore added to returned types automatically if an argument has `None` as a default value. That changed our argument conversion logic so that if you have an argument like `arg: str = None` and pass it a `None` object, it isn't passed as-is but instead converted to a string `None`. Argument conversion depending on Python versions wouldn't be great, so this commit changes the logic so that `None` is never converted if an argument has `None` as a default. The same change also changed how types are shown by Libdoc. Earlier `arg: str = None` was shown as `arg: str | None = None`, but nowadays it's just `arg: str = None`. I prefer the new formatting and just updated tests so that they expect that if Python 3.11+. This is part of Python 3.11 compatibility. #4401 --- atest/robot/libdoc/libdoc_resource.robot | 3 +++ src/robot/running/arguments/argumentconverter.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index f83ac199c77..83e9572955a 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -139,6 +139,9 @@ Verify Arguments Structure ${kws}= Get Elements ${LIBDOC} xpath=${xpath} ${arg_elems}= Get Elements ${kws}[${index}] xpath=arguments/arg FOR ${arg_elem} ${exp_repr} IN ZIP ${arg_elems} ${expected} + IF $INTERPRETER.version_info >= (3, 11) + ${exp_repr} = Replace String ${exp_repr} | None = None = None + END ${kind}= Get Element Attribute ${arg_elem} kind ${required}= Get Element Attribute ${arg_elem} required ${repr}= Get Element Attribute ${arg_elem} repr diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index b7d241cf04f..5a361e58ab8 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -50,6 +50,12 @@ def _convert(self, name, value): or self._dry_run and contains_variable(value, identifiers='$@&%')): return value conversion_error = None + # Don't convert None if argument has None as a default value. + # Python < 3.11 adds None to type hints automatically when using None as + # a default value which preserves None automatically. This code keeps + # the same behavior also with newer Python versions. + if value is None and name in spec.defaults and spec.defaults[name] is None: + return value if name in spec.types: converter = TypeConverter.converter_for(spec.types[name], self._converters) if converter: From b6e27e2183017150de4df544aae95e3080726b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 22:17:39 +0300 Subject: [PATCH 0094/1592] Add Python 3.11 support to project metadata. #4401 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index ddc20290955..9fb84ed032e 100755 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 +Programming Language :: Python :: 3.11 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy Topic :: Software Development :: Testing From 6d02c2bf3afab5924f214517e80a73bd3c78849e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 22:22:05 +0300 Subject: [PATCH 0095/1592] Adjust test runs on CI. - Run PRs against Python 3.6 and 3.10 instead of Python 3.6 and 3.9. - Use Python 3.10 as the "runner side" Python with acceptance tests. - Run tests also against Python 3.11. #4401 --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 82f1b2d7a7e..162ce3f3566 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.8' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 @@ -37,7 +37,7 @@ jobs: - name: Setup python for starting the tests uses: actions/setup-python@v4.0.0 with: - python-version: 3.6 + python-version: 3.10 architecture: 'x64' - name: Get test starter Python at Windows diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 6570d32ac4d..1b93b8627ec 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.9' ] + python-version: [ '3.6', '3.10' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 @@ -31,7 +31,7 @@ jobs: - name: Setup python for starting the tests uses: actions/setup-python@v4.0.0 with: - python-version: 3.6 + python-version: 3.10 architecture: 'x64' - name: Get test starter Python at Windows diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index c5b4b52901a..244aa83a5be 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.8' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] exclude: - os: windows-latest python-version: 'pypy-3.8' diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml index ba465324cf4..0f734f1c6c3 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.9' ] + python-version: [ '3.6', '3.10' ] runs-on: ${{ matrix.os }} From 8162463926492fe3f2e60d6e401a17c7185cee99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 22:27:27 +0300 Subject: [PATCH 0096/1592] Apparently `3.10` isn't a valid Python version. Hopefully `'3.10'` is. --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/acceptance_tests_cpython_pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 162ce3f3566..fdcf4de1959 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -37,7 +37,7 @@ jobs: - name: Setup python for starting the tests uses: actions/setup-python@v4.0.0 with: - python-version: 3.10 + python-version: '3.10' architecture: 'x64' - name: Get test starter Python at Windows diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 1b93b8627ec..9875682c061 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -31,7 +31,7 @@ jobs: - name: Setup python for starting the tests uses: actions/setup-python@v4.0.0 with: - python-version: 3.10 + python-version: '3.10' architecture: 'x64' - name: Get test starter Python at Windows From 08e0758c1412a4796bb32cba1c1cc8df7fc49084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 22:34:43 +0300 Subject: [PATCH 0097/1592] New attempt to configure Python 3.11 run on CI. #4401 --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/unit_tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index fdcf4de1959..0696ecb9ba7 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', '3.11.0-beta - 3.11', 'pypy-3.8' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 244aa83a5be..e708cde51fb 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', '3.11.0-beta - 3.11', 'pypy-3.8' ] exclude: - os: windows-latest python-version: 'pypy-3.8' From 005e1243f123bce927efcc6cc7c89ab402633920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 22:43:44 +0300 Subject: [PATCH 0098/1592] Try to fix test that fails on CI with Python 3.11 but not locally --- utest/utils/test_importer_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utest/utils/test_importer_util.py b/utest/utils/test_importer_util.py index 6a6c4ae633c..35b787b7589 100644 --- a/utest/utils/test_importer_util.py +++ b/utest/utils/test_importer_util.py @@ -373,7 +373,7 @@ def _block(self, error, start, end=None): return if line == start: include = True - if include: + if include and line.strip('^ '): yield line From cbb57657ad4f5d01cb44945cabe66316cc05af70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Jul 2022 22:48:27 +0300 Subject: [PATCH 0099/1592] Bump actions/setup-python from 4.0.0 to 4.1.0 (#4397) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.0.0...v4.1.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 0696ecb9ba7..865ee6b7d98 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.0.0 + uses: actions/setup-python@v4.1.0 with: python-version: '3.10' architecture: 'x64' @@ -49,7 +49,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.0.0 + uses: actions/setup-python@v4.1.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 9875682c061..b8d590a97d7 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.0.0 + uses: actions/setup-python@v4.1.0 with: python-version: '3.10' 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@v4.0.0 + uses: actions/setup-python@v4.1.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e708cde51fb..550141a0d65 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.0.0 + uses: actions/setup-python@v4.1.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 0f734f1c6c3..4231f4c3152 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.0.0 + uses: actions/setup-python@v4.1.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From 28cb9af4af3e77a305fee1dc5ad118f4f7b4ffcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 23:14:11 +0300 Subject: [PATCH 0100/1592] Try to fix atests failing on CI but not locally. These fail on CI with Python 3.11 due to `^^^` lines it adds to tracebacks. It's strange same tests pass locally but hopefully filtering those lines fixes tests on CI as well. Part of #4401. --- atest/resources/TestCheckerLibrary.py | 8 ++++++-- atest/robot/running/timeouts.robot | 4 ++-- atest/robot/test_libraries/error_msg_and_details.robot | 8 +++----- atest/robot/test_libraries/logging_with_logging.robot | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index 0cb3b1058cb..eff18e34ffa 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -316,10 +316,14 @@ def test_should_have_correct_keywords(self, *kw_names, **config): self.should_contain_keywords(test.body[kw_index], *kw_names) return test - def check_log_message(self, item, msg, level='INFO', html=False, pattern=False): + def check_log_message(self, item, expected, level='INFO', html=False, pattern=False, traceback=False): + message = item.message.rstrip() + if traceback: + # Remove `^^^` lines added by Python 3.11+. + message = '\n'.join(line for line in message.splitlines() if line.strip('^ ')) b = BuiltIn() matcher = b.should_match if pattern else b.should_be_equal - matcher(item.message.rstrip(), msg.rstrip(), 'Wrong log message') + matcher(message, expected.rstrip(), 'Wrong log message') b.should_be_equal(item.level, 'INFO' if level == 'HTML' else level, 'Wrong log level') b.should_be_equal(str(item.html), str(html or level == 'HTML'), 'Wrong HTML status') diff --git a/atest/robot/running/timeouts.robot b/atest/robot/running/timeouts.robot index 6a0b6edbb57..d668abd95da 100644 --- a/atest/robot/running/timeouts.robot +++ b/atest/robot/running/timeouts.robot @@ -16,14 +16,14 @@ Timeouted Test Passes Timeouted Test Fails Before Timeout Check Test Case Failing Before Timeout -Show Correct Trace Back When Failing Before Timeout +Show Correct Traceback When Failing Before Timeout ${tc} = Check Test Case ${TEST NAME} ${expected} = Catenate SEPARATOR=\n ... Traceback (most recent call last): ... ${SPACE*2}File "*", line *, in exception ... ${SPACE*4}raise exception(msg) ... RuntimeError: Failure before timeout - Check Log Message ${tc.kws[0].msgs[-1]} ${expected} pattern=yes level=DEBUG + Check Log Message ${tc.kws[0].msgs[-1]} ${expected} DEBUG pattern=True traceback=True Timeouted Test Timeouts Check Test Case Sleeping And Timeouting diff --git a/atest/robot/test_libraries/error_msg_and_details.robot b/atest/robot/test_libraries/error_msg_and_details.robot index a28bebb2093..be08488526f 100644 --- a/atest/robot/test_libraries/error_msg_and_details.robot +++ b/atest/robot/test_libraries/error_msg_and_details.robot @@ -104,11 +104,9 @@ Verify Test Case, Error In Log And No Details Verify Traceback [Arguments] ${msg} @{entries} ${error} - ${exp} = Set Variable Traceback \\(most recent call last\\): + ${exp} = Set Variable Traceback (most recent call last): FOR ${path} ${func} ${text} IN @{entries} ${path} = Normalize Path ${DATADIR}/${path} - ${path} ${func} ${text} = Regexp Escape ${path} ${func} ${text} - ${exp} = Set Variable ${exp}\n\\s+File ".*${path}.*", line \\d+, in ${func}\n\\s+${text} + ${exp} = Set Variable ${exp}\n${SPACE*2}File "${path}", line *, in ${func}\n${SPACE*4}${text} END - Should Match Regexp ${msg.message} ^${exp}\n${error}$ - Should Be Equal ${msg.level} DEBUG + Check Log Message ${msg} ${exp}\n${error} DEBUG pattern=True traceback=True diff --git a/atest/robot/test_libraries/logging_with_logging.robot b/atest/robot/test_libraries/logging_with_logging.robot index ad8771c9bed..18ebcda9528 100644 --- a/atest/robot/test_libraries/logging_with_logging.robot +++ b/atest/robot/test_libraries/logging_with_logging.robot @@ -36,7 +36,7 @@ Log exception ... ${SPACE*2}File "*", line 56, in log_exception ... ${SPACE*4}raise ValueError('Bang!') ... ValueError: Bang! - Check log message ${tc.kws[0].msgs[0]} ${message} ERROR pattern=True + Check log message ${tc.kws[0].msgs[0]} ${message} ERROR pattern=True traceback=True Messages below threshold level are ignored fully ${tc}= Check test case ${TEST NAME} From a685c99ec3ecfef836701c619cb7d0e8958af157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Jul 2022 23:38:38 +0300 Subject: [PATCH 0101/1592] Try to fix final atests failing on CI but not locally. This test fails on CI with Python 3.11 due to `^^^` lines it adds to tracebacks. It's strange it passes locally, but hopefully filtering those lines fixes it on CI like it has fixed other similar tests. Part of #4401. --- atest/robot/test_libraries/error_msg_and_details.robot | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/atest/robot/test_libraries/error_msg_and_details.robot b/atest/robot/test_libraries/error_msg_and_details.robot index be08488526f..007184a970a 100644 --- a/atest/robot/test_libraries/error_msg_and_details.robot +++ b/atest/robot/test_libraries/error_msg_and_details.robot @@ -84,10 +84,12 @@ Include internal traces when ROBOT_INTERNAL_TRACE is set Set Environment Variable ROBOT_INTERNAL_TRACES show, please Run Tests -L DEBUG -t "Generic Failure" test_libraries/error_msg_and_details.robot ${tc} = Check Test Case Generic Failure - ${tb} = Set Variable ${tc.kws[0].msgs[1].message} + # Remove '^^^' lines added by Python 3.11+. + ${tb} = Evaluate '\\n'.join(line for line in $tc.kws[0].msgs[1].message.splitlines() if line.strip('^ ')) Should Start With ${tb} Traceback (most recent call last): - Should End With ${tb} raise exception(msg)\nAssertionError: foo != bar - Should Be True len($tb.splitlines()) > 5 + Should Contain ${tb} librarykeywordrunner.py + Should End With ${tb} raise exception(msg)\nAssertionError: foo != bar + Should Be True len($tb.splitlines()) > 5 [Teardown] Remove Environment Variable ROBOT_INTERNAL_TRACES *** Keyword *** From a417b5fd45f78452980a6ef2418ff533786b39fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 13 Jul 2022 00:12:45 +0300 Subject: [PATCH 0102/1592] Don't run tests on CI with Python 3.11 on Windows. #4401 Too many dependencies we need seem to be unavailable at the moment. --- .github/workflows/acceptance_tests_cpython.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 865ee6b7d98..ed21d1ca967 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -26,7 +26,7 @@ jobs: set_codepage: chcp 850 exclude: - os: windows-latest - python-version: 'pypy-3.8' + python-version: [ 'pypy-3.8', '3.11.0-beta - 3.11' ] runs-on: ${{ matrix.os }} From 46fd8eb063517573a333386d3bf45dbd5e963daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 13 Jul 2022 01:16:58 +0300 Subject: [PATCH 0103/1592] New attempt to not run tests wilt Python 3.11 on Windows on CI. #4401 --- .github/workflows/acceptance_tests_cpython.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index ed21d1ca967..1c830159153 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -26,7 +26,9 @@ jobs: set_codepage: chcp 850 exclude: - os: windows-latest - python-version: [ 'pypy-3.8', '3.11.0-beta - 3.11' ] + python-version: 'pypy-3.8' + - os: windows-latest + python-version: '3.11.0-beta - 3.11' runs-on: ${{ matrix.os }} From 0a8c3ff2a6283e9da0caaf7c153ae4f1fdd9b5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 13 Jul 2022 15:08:53 +0300 Subject: [PATCH 0104/1592] Change tests to ease adding more tests. --- atest/robot/parsing/translations.robot | 17 +++++++++-------- .../parsing/{ => finnish}/finnish.resource | 0 .../{finnish.robot => finnish/tests.robot} | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) rename atest/testdata/parsing/{ => finnish}/finnish.resource (100%) rename atest/testdata/parsing/{finnish.robot => finnish/tests.robot} (97%) diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index 20bb1ded95d..c9fbbae1ad3 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -3,12 +3,12 @@ Resource atest_resource.robot *** Test Cases *** Built-in language - Run Tests --lang fi parsing/finnish.robot - Validate Translations + Run Tests --lang fi parsing/finnish + Validate Translations ${SUITE.suites[0]} Custom language Run Tests --lang ${DATADIR}/parsing/custom-lang.py parsing/custom.robot - Validate Translations + Validate Translations ${SUITE} Invalid ${result} = Run Tests Without Processing Output --lang bad parsing/finnish.robot @@ -22,11 +22,12 @@ Invalid *** Keywords *** Validate Translations - Should Be Equal ${SUITE.doc} Suite documentation. - Should Be Equal ${SUITE.metadata}[Metadata] Value - Should Be Equal ${SUITE.setup.name} Suite Setup - Should Be Equal ${SUITE.teardown.name} Suite Teardown - Should Be Equal ${SUITE.status} PASS + [Arguments] ${suite} + Should Be Equal ${suite.doc} Suite documentation. + Should Be Equal ${suite.metadata}[Metadata] Value + Should Be Equal ${suite.setup.name} Suite Setup + Should Be Equal ${suite.teardown.name} Suite Teardown + Should Be Equal ${suite.status} PASS ${tc} = Check Test Case Test without settings Should Be Equal ${tc.doc} ${EMPTY} Should Be Equal ${tc.tags} ${{['test', 'tags']}} diff --git a/atest/testdata/parsing/finnish.resource b/atest/testdata/parsing/finnish/finnish.resource similarity index 100% rename from atest/testdata/parsing/finnish.resource rename to atest/testdata/parsing/finnish/finnish.resource diff --git a/atest/testdata/parsing/finnish.robot b/atest/testdata/parsing/finnish/tests.robot similarity index 97% rename from atest/testdata/parsing/finnish.robot rename to atest/testdata/parsing/finnish/tests.robot index c09f7db55be..63b1be87b01 100644 --- a/atest/testdata/parsing/finnish.robot +++ b/atest/testdata/parsing/finnish/tests.robot @@ -11,7 +11,7 @@ Testin Tagit test tags Avainsanan Tagit keyword tags Kirjasto OperatingSystem Resurssi finnish.resource -Muuttujat variables.py +Muuttujat ../variables.py *** Muuttujat *** ${VARIABLE} variable value From 3d4b9a1996f87d4d3c4795d6e8a14ca38b1ebb72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 13 Jul 2022 15:27:07 +0300 Subject: [PATCH 0105/1592] Add translation support for task related setting aliases. This was missing from #4096. --- atest/robot/parsing/translations.robot | 37 ++++++++++++++++------ atest/testdata/parsing/finnish/tasks.robot | 30 ++++++++++++++++++ src/robot/conf/languages.py | 20 ++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 atest/testdata/parsing/finnish/tasks.robot diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index c9fbbae1ad3..88d05b1cbfa 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -3,12 +3,17 @@ Resource atest_resource.robot *** Test Cases *** Built-in language - Run Tests --lang fi parsing/finnish - Validate Translations ${SUITE.suites[0]} + Run Tests --language fi parsing/finnish/tests.robot + Validate Translations Custom language Run Tests --lang ${DATADIR}/parsing/custom-lang.py parsing/custom.robot - Validate Translations ${SUITE} + Validate Translations + +Task translations + [Documentation] Also test that '--language' works when running a directory. + Run Tests --language fi --rpa parsing/finnish + Validate Task Translations Invalid ${result} = Run Tests Without Processing Output --lang bad parsing/finnish.robot @@ -22,12 +27,11 @@ Invalid *** Keywords *** Validate Translations - [Arguments] ${suite} - Should Be Equal ${suite.doc} Suite documentation. - Should Be Equal ${suite.metadata}[Metadata] Value - Should Be Equal ${suite.setup.name} Suite Setup - Should Be Equal ${suite.teardown.name} Suite Teardown - Should Be Equal ${suite.status} PASS + Should Be Equal ${SUITE.doc} Suite documentation. + Should Be Equal ${SUITE.metadata}[Metadata] Value + Should Be Equal ${SUITE.setup.name} Suite Setup + Should Be Equal ${SUITE.teardown.name} Suite Teardown + Should Be Equal ${SUITE.status} PASS ${tc} = Check Test Case Test without settings Should Be Equal ${tc.doc} ${EMPTY} Should Be Equal ${tc.tags} ${{['test', 'tags']}} @@ -48,3 +52,18 @@ Validate Translations Should Be Equal ${tc.body[0].timeout} 1 hour Should Be Equal ${tc.body[0].teardown.name} BuiltIn.No Operation +Validate Task Translations + ${tc} = Check Test Case Task without settings + Should Be Equal ${tc.doc} ${EMPTY} + Should Be Equal ${tc.tags} ${{['task', 'tags']}} + Should Be Equal ${tc.timeout} 1 minute + Should Be Equal ${tc.setup.name} Task Setup + Should Be Equal ${tc.teardown.name} Task Teardown + Should Be Equal ${tc.body[0].name} Task Template + ${tc} = Check Test Case Task with settings + Should Be Equal ${tc.doc} Task documentation. + Should Be Equal ${tc.tags} ${{['task', 'tags', 'own tag']}} + Should Be Equal ${tc.timeout} ${NONE} + Should Be Equal ${tc.setup.name} ${NONE} + Should Be Equal ${tc.teardown.name} ${NONE} + Should Be Equal ${tc.body[0].name} BuiltIn.Log diff --git a/atest/testdata/parsing/finnish/tasks.robot b/atest/testdata/parsing/finnish/tasks.robot new file mode 100644 index 00000000000..3e9dc1551ac --- /dev/null +++ b/atest/testdata/parsing/finnish/tasks.robot @@ -0,0 +1,30 @@ +*** Asetukset *** +Tehtävän Alustus Task Setup +Tehtävän Purku Task Teardown +Tehtävän Malli Task Template +Tehtävän Aikaraja 1 minute +Tehtävän Tagit task tags + +*** Tehtävät *** +Task without settings + Nothing to see here + +Task with settings + [Dokumentaatio] Task documentation. + [Tagit] own tag + [Alustus] NONE + [Purku] NONE + [Malli] NONE + [Aikaraja] NONE + Log Nothing to see here + +*** Avainsana *** +Task Setup + No Operation + +Task Teardown + No Operation + +Task Template + [Arguments] ${msg} + Log ${msg} diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index f57db0e7ae9..8d83a04aafd 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -93,10 +93,15 @@ class Language: suite_setup = None suite_teardown = None test_setup = None + task_setup = None test_teardown = None + task_teardown = None test_template = None + task_template = None test_timeout = None + task_timeout = None test_tags = None + task_tags = None keyword_tags = None tags = None setup = None @@ -117,10 +122,15 @@ def settings(self): self.suite_setup: En.suite_setup, self.suite_teardown: En.suite_teardown, self.test_setup: En.test_setup, + self.task_setup: En.task_setup, self.test_teardown: En.test_teardown, + self.task_teardown: En.task_teardown, self.test_template: En.test_template, + self.task_template: En.task_template, self.test_timeout: En.test_timeout, + self.task_timeout: En.task_timeout, self.test_tags: En.test_tags, + self.task_tags: En.task_tags, self.keyword_tags: En.keyword_tags, self.tags: En.tags, self.setup: En.setup, @@ -148,10 +158,15 @@ class En(Language): suite_setup = 'Suite Setup' suite_teardown = 'Suite Teardown' test_setup = 'Test Setup' + task_setup = 'Task Setup' test_teardown = 'Test Teardown' + task_teardown = 'Task Teardown' test_template = 'Test Template' + task_template = 'Task Template' test_timeout = 'Test Timeout' + task_timeout = 'Task Timeout' test_tags = 'Test Tags' + task_tags = 'Task Tags' keyword_tags = 'Keyword Tags' setup = 'Setup' teardown = 'Teardown' @@ -179,10 +194,15 @@ class Fi(Language): suite_setup = 'Setin Alustus' suite_teardown = 'Setin Purku' test_setup = 'Testin Alustus' + task_setup = 'Tehtävän Alustus' test_teardown = 'Testin Purku' + task_teardown = 'Tehtävän Purku' test_template = 'Testin Malli' + task_template = 'Tehtävän Malli' test_timeout = 'Testin Aikaraja' + task_timeout = 'Tehtävän Aikaraja' test_tags = 'Testin Tagit' + task_tags = 'Tehtävän Tagit' keyword_tags = 'Avainsanan Tagit' setup = 'Alustus' teardown = 'Purku' From 2237b86546d0cebc075274c9bcb512badb9b2c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 13 Jul 2022 15:53:17 +0300 Subject: [PATCH 0106/1592] test data tuning --- atest/testdata/parsing/custom-lang.py | 32 +++++++++--------- atest/testdata/parsing/custom.resource | 2 +- atest/testdata/parsing/custom.robot | 46 +++++++++++++------------- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/atest/testdata/parsing/custom-lang.py b/atest/testdata/parsing/custom-lang.py index 571b1ce8b69..6c7e98a5714 100644 --- a/atest/testdata/parsing/custom-lang.py +++ b/atest/testdata/parsing/custom-lang.py @@ -11,20 +11,20 @@ class Custom(Language): library = 'L' resource = 'R' variables = 'V' - documentation = 'S 1' - metadata = 'S 2' - suite_setup = 'S 3' - suite_teardown = 'S 4' - test_setup = 'S 5' - test_teardown = 'S 6' - test_template = 'S 7' - test_timeout = 'S 8' - test_tags = 'S 9' - keyword_tags = 'S 10' - setup = 'S 11' - teardown = 'S 12' - template = 'S 13' - tags = 'S 14' - timeout = 'S 15' - arguments = 'S 16' + documentation = 'D' + metadata = 'M' + suite_setup = 'S S' + suite_teardown = 'S T' + test_setup = 'T S' + test_teardown = 'T Tea' + test_template = 'T Tem' + test_timeout = 'T Ti' + test_tags = 'T Ta' + keyword_tags = 'K T' + setup = 'S' + teardown = 'Tea' + template = 'Tem' + tags = 'Ta' + timeout = 'Ti' + arguments = 'A' bdd_prefixes = set() diff --git a/atest/testdata/parsing/custom.resource b/atest/testdata/parsing/custom.resource index af1ffb804c5..6fb0ac03388 100644 --- a/atest/testdata/parsing/custom.resource +++ b/atest/testdata/parsing/custom.resource @@ -1,5 +1,5 @@ *** h 1 *** -S 1 Example documentation. +D Example documentation. *** h 2 *** ${RESOURCE FILE} variable in resource file diff --git a/atest/testdata/parsing/custom.robot b/atest/testdata/parsing/custom.robot index ba2b635d790..d2d8c3cc62b 100644 --- a/atest/testdata/parsing/custom.robot +++ b/atest/testdata/parsing/custom.robot @@ -1,14 +1,14 @@ *** H 1 *** -S 1 Suite documentation. -S 2 Metadata Value -S 3 Suite Setup -S 4 Suite Teardown -S 5 Test Setup -S 6 Test Teardown -S 7 Test Template -S 8 1 minute -S 9 test tags -S 10 keyword tags +D Suite documentation. +M Metadata Value +S S Suite Setup +S T Suite Teardown +T S Test Setup +T Tea Test Teardown +t tem Test Template +T ti 1 minute +t Ta test tags +k T keyword tags L OperatingSystem R custom.resource V variables.py @@ -21,13 +21,13 @@ Test without settings Nothing to see here Test with settings - [S 1] Test documentation. - [S 14] own tag - [S 11] NONE - [S 12] NONE - [S 13] NONE - [S 15] NONE - Keyword ${VARIABLE} + [D] Test documentation. + [Ta] own tag + [S] NONE + [tea] NONE + [tEm] NONE + [ti] NONE + Keyword ${VARIABLE} *** H 5 *** Suite Setup @@ -45,16 +45,16 @@ Test Teardown No Operation Test Template - [S 16] ${message} + [A] ${message} Log ${message} Keyword - [S 1] Keyword documentation. - [S 16] ${arg} - [S 14] own tag - [S 15] 1h + [d] Keyword documentation. + [a] ${arg} + [ta] own tag + [tI] 1h Should Be Equal ${arg} ${VARIABLE} - [S 12] No Operation + [TEA] No Operation *** H 6 *** Ignored comments. From d20dd1c2208e0d539c1e5a075bb4074c14ac30ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 13 Jul 2022 15:55:27 +0300 Subject: [PATCH 0107/1592] Allow translations in non-title case format. Makes it possible to have translation like `Casos de Teste` instead of requiring apparently improper `Casos De Teste`. See PR #4396. Using the proper format in the original language definition makes it possible for other tools to use that format as well. --- atest/testdata/parsing/custom-lang.py | 12 ++++++------ src/robot/conf/languages.py | 3 +-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/atest/testdata/parsing/custom-lang.py b/atest/testdata/parsing/custom-lang.py index 6c7e98a5714..cc6546ea226 100644 --- a/atest/testdata/parsing/custom-lang.py +++ b/atest/testdata/parsing/custom-lang.py @@ -15,16 +15,16 @@ class Custom(Language): metadata = 'M' suite_setup = 'S S' suite_teardown = 'S T' - test_setup = 'T S' - test_teardown = 'T Tea' - test_template = 'T Tem' - test_timeout = 'T Ti' + test_setup = 't s' + test_teardown = 'T tea' + test_template = 'T TEM' + test_timeout = 't ti' test_tags = 'T Ta' keyword_tags = 'K T' setup = 'S' - teardown = 'Tea' + teardown = 'TeA' template = 'Tem' tags = 'Ta' - timeout = 'Ti' + timeout = 'ti' arguments = 'A' bdd_prefixes = set() diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 8d83a04aafd..1a4a4e16e78 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -139,8 +139,7 @@ def settings(self): self.timeout: En.timeout, self.arguments: En.arguments, } - settings.pop(None, None) - return settings + return {name.title(): settings[name] for name in settings if name} class En(Language): From e633b6c8adc788db3c239c8a2b30ce9e7c54b066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 14 Jul 2022 00:27:06 +0300 Subject: [PATCH 0108/1592] Enhance translation tests --- atest/robot/parsing/translations.robot | 18 ++++++----- .../custom/custom.py} | 5 ++++ .../custom/resource.resource} | 0 .../parsing/translations/custom/tasks.robot | 30 +++++++++++++++++++ .../custom/tests.robot} | 4 +-- .../finnish/resource.resource} | 0 .../{ => translations}/finnish/tasks.robot | 2 +- .../{ => translations}/finnish/tests.robot | 4 +-- 8 files changed, 51 insertions(+), 12 deletions(-) rename atest/testdata/parsing/{custom-lang.py => translations/custom/custom.py} (83%) rename atest/testdata/parsing/{custom.resource => translations/custom/resource.resource} (100%) create mode 100644 atest/testdata/parsing/translations/custom/tasks.robot rename atest/testdata/parsing/{custom.robot => translations/custom/tests.robot} (94%) rename atest/testdata/parsing/{finnish/finnish.resource => translations/finnish/resource.resource} (100%) rename atest/testdata/parsing/{ => translations}/finnish/tasks.robot (95%) rename atest/testdata/parsing/{ => translations}/finnish/tests.robot (94%) diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index 88d05b1cbfa..357d659566a 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -2,17 +2,21 @@ Resource atest_resource.robot *** Test Cases *** -Built-in language - Run Tests --language fi parsing/finnish/tests.robot +Finnish + Run Tests --language fi parsing/translations/finnish/tests.robot Validate Translations -Custom language - Run Tests --lang ${DATADIR}/parsing/custom-lang.py parsing/custom.robot +Finnish task aliases + [Documentation] Also test that '--language' works when running a directory. + Run Tests --language fi --rpa parsing/translations/finnish + Validate Task Translations + +Custom + Run Tests --lang ${DATADIR}/parsing/translations/custom/custom.py parsing/translations/custom/tests.robot Validate Translations -Task translations - [Documentation] Also test that '--language' works when running a directory. - Run Tests --language fi --rpa parsing/finnish +Custom task aliases + Run Tests --lang ${DATADIR}/parsing/translations/custom/custom.py --rpa parsing/translations/custom Validate Task Translations Invalid diff --git a/atest/testdata/parsing/custom-lang.py b/atest/testdata/parsing/translations/custom/custom.py similarity index 83% rename from atest/testdata/parsing/custom-lang.py rename to atest/testdata/parsing/translations/custom/custom.py index cc6546ea226..e52d4e92b0f 100644 --- a/atest/testdata/parsing/custom-lang.py +++ b/atest/testdata/parsing/translations/custom/custom.py @@ -16,10 +16,15 @@ class Custom(Language): suite_setup = 'S S' suite_teardown = 'S T' test_setup = 't s' + task_setup = 'ta s' test_teardown = 'T tea' + task_teardown = 'TA tea' test_template = 'T TEM' + task_template = 'TA TEM' test_timeout = 't ti' + task_timeout = 'ta ti' test_tags = 'T Ta' + task_tags = 'Ta Ta' keyword_tags = 'K T' setup = 'S' teardown = 'TeA' diff --git a/atest/testdata/parsing/custom.resource b/atest/testdata/parsing/translations/custom/resource.resource similarity index 100% rename from atest/testdata/parsing/custom.resource rename to atest/testdata/parsing/translations/custom/resource.resource diff --git a/atest/testdata/parsing/translations/custom/tasks.robot b/atest/testdata/parsing/translations/custom/tasks.robot new file mode 100644 index 00000000000..a905c830087 --- /dev/null +++ b/atest/testdata/parsing/translations/custom/tasks.robot @@ -0,0 +1,30 @@ +*** H 1 *** +Ta S Task Setup +Ta Tea Task Teardown +ta tem Task Template +TA TI 1 minute +Ta Ta task tags + +*** h 4 *** +Task without settings + Nothing to see here + +Task with settings + [D] Task documentation. + [Ta] own tag + [S] NONE + [Tea] NONE + [Tem] NONE + [Ti] NONE + Log Nothing to see here + +*** H 5 *** +Task Setup + No Operation + +Task Teardown + No Operation + +Task Template + [A] ${msg} + Log ${msg} diff --git a/atest/testdata/parsing/custom.robot b/atest/testdata/parsing/translations/custom/tests.robot similarity index 94% rename from atest/testdata/parsing/custom.robot rename to atest/testdata/parsing/translations/custom/tests.robot index d2d8c3cc62b..c9f7c672a85 100644 --- a/atest/testdata/parsing/custom.robot +++ b/atest/testdata/parsing/translations/custom/tests.robot @@ -10,8 +10,8 @@ T ti 1 minute t Ta test tags k T keyword tags L OperatingSystem -R custom.resource -V variables.py +R resource.resource +V ../../variables.py *** H 2 *** ${VARIABLE} variable value diff --git a/atest/testdata/parsing/finnish/finnish.resource b/atest/testdata/parsing/translations/finnish/resource.resource similarity index 100% rename from atest/testdata/parsing/finnish/finnish.resource rename to atest/testdata/parsing/translations/finnish/resource.resource diff --git a/atest/testdata/parsing/finnish/tasks.robot b/atest/testdata/parsing/translations/finnish/tasks.robot similarity index 95% rename from atest/testdata/parsing/finnish/tasks.robot rename to atest/testdata/parsing/translations/finnish/tasks.robot index 3e9dc1551ac..9561976eee4 100644 --- a/atest/testdata/parsing/finnish/tasks.robot +++ b/atest/testdata/parsing/translations/finnish/tasks.robot @@ -26,5 +26,5 @@ Task Teardown No Operation Task Template - [Arguments] ${msg} + [Argumentit] ${msg} Log ${msg} diff --git a/atest/testdata/parsing/finnish/tests.robot b/atest/testdata/parsing/translations/finnish/tests.robot similarity index 94% rename from atest/testdata/parsing/finnish/tests.robot rename to atest/testdata/parsing/translations/finnish/tests.robot index 63b1be87b01..26063ae14ca 100644 --- a/atest/testdata/parsing/finnish/tests.robot +++ b/atest/testdata/parsing/translations/finnish/tests.robot @@ -10,8 +10,8 @@ Testin Aikaraja 1 minute Testin Tagit test tags Avainsanan Tagit keyword tags Kirjasto OperatingSystem -Resurssi finnish.resource -Muuttujat ../variables.py +Resurssi resource.resource +Muuttujat ../../variables.py *** Muuttujat *** ${VARIABLE} variable value From 64e4fa8af4116318fc93a6fe4f0f2fefd6e87c04 Mon Sep 17 00:00:00 2001 From: "Johnny.H" <jnhyperion@gmail.com> Date: Thu, 14 Jul 2022 22:19:45 +0800 Subject: [PATCH 0109/1592] Fix incorrect try/except syntax in user guide (#4399) --- doc/userguide/src/CreatingTestData/ControlStructures.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index 11ab288335e..128d4fa48a3 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -670,6 +670,7 @@ keyword called in the loop body is invalid. ${value} = Do Something EXCEPT CONTINUE + END Do something with value ${value} BREAK END From 0497a7e336fde6e5c35334d3a308aa0d1ff4602f Mon Sep 17 00:00:00 2001 From: Oliver Boehmer <oli@spine.de> Date: Thu, 14 Jul 2022 16:22:10 +0200 Subject: [PATCH 0110/1592] Add optional default argument to Get From Dictionary (#4405) Fixes #4398 --- .../standard_libraries/collections/dictionary.robot | 3 +++ .../standard_libraries/collections/dictionary.robot | 7 +++++++ src/robot/libraries/Collections.py | 10 +++++++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/atest/robot/standard_libraries/collections/dictionary.robot b/atest/robot/standard_libraries/collections/dictionary.robot index c49cd0f96da..8456ec0f5a7 100644 --- a/atest/robot/standard_libraries/collections/dictionary.robot +++ b/atest/robot/standard_libraries/collections/dictionary.robot @@ -62,6 +62,9 @@ Get From Dictionary With Invalid Key Check Test Case ${TEST NAME} 1 Check Test Case ${TEST NAME} 2 +Get From Dictionary With Default + Check Test Case ${TEST NAME} + Dictionary Should Contain Key Check Test Case ${TEST NAME} diff --git a/atest/testdata/standard_libraries/collections/dictionary.robot b/atest/testdata/standard_libraries/collections/dictionary.robot index 5504de9867f..d7a041195fc 100644 --- a/atest/testdata/standard_libraries/collections/dictionary.robot +++ b/atest/testdata/standard_libraries/collections/dictionary.robot @@ -103,6 +103,13 @@ Get From Dictionary With Invalid Key 2 [Documentation] FAIL Dictionary does not contain key '(1, 2)'. Get From Dictionary ${D3} ${TUPLE} +Get From Dictionary With Default + ${dict} = Create Dictionary a=a b=b + ${value} = Get From Dictionary ${dict} x default_value + Should Be Equal ${value} default_value + ${value} = Get From Dictionary ${dict} a default_value + Should Be Equal ${value} a + Dictionary Should Contain Key Dictionary Should Contain Key ${D3} a diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 85197479bde..590b5e5de0f 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -671,11 +671,12 @@ def get_dictionary_items(self, dictionary, sort_keys=True): keys = self.get_dictionary_keys(dictionary, sort_keys=sort_keys) return [i for key in keys for i in (key, dictionary[key])] - def get_from_dictionary(self, dictionary, key): + def get_from_dictionary(self, dictionary, key, default=NOT_SET): """Returns a value from the given ``dictionary`` based on the given ``key``. If the given ``key`` cannot be found from the ``dictionary``, this - keyword fails. + keyword fails. If optional ``default`` value is given, it will be + returned instead of failing. The given dictionary is never altered by this keyword. @@ -688,7 +689,10 @@ def get_from_dictionary(self, dictionary, key): try: return dictionary[key] except KeyError: - raise RuntimeError("Dictionary does not contain key '%s'." % (key,)) + if default is NOT_SET: + raise RuntimeError("Dictionary does not contain key '%s'." % (key,)) + else: + return default def dictionary_should_contain_key(self, dictionary, key, msg=None): """Fails if ``key`` is not found from ``dictionary``. From dbffeb0525f2026adca244eb5e7df26c44b2e66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 14 Jul 2022 18:13:53 +0300 Subject: [PATCH 0111/1592] Collections tuning: - f-strings - Note to Get From Dictionary that `default` is new in RF 5.1. - FIXME for a bug --- src/robot/libraries/Collections.py | 147 +++++++++++++++-------------- 1 file changed, 77 insertions(+), 70 deletions(-) diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 590b5e5de0f..4a10041c8df 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -16,8 +16,8 @@ import copy from robot.api import logger -from robot.utils import (is_dict_like, is_list_like, is_number, is_string, is_truthy, - Matcher, plural_or_not, seq2str, seq2str2, type_name) +from robot.utils import (is_dict_like, is_list_like, is_string, is_truthy, Matcher, + plural_or_not as s, seq2str, seq2str2, type_name) from robot.utils.asserts import assert_equal from robot.version import get_version @@ -168,7 +168,7 @@ def remove_duplicates(self, list_): if item not in ret: ret.append(item) removed = len(list_) - len(ret) - logger.info('%d duplicate%s removed.' % (removed, plural_or_not(removed))) + logger.info(f'{removed} duplicate{s(removed)} removed.') return ret def get_from_list(self, list_, index): @@ -310,8 +310,9 @@ def list_should_contain_value(self, list_, value, msg=None): Use the ``msg`` argument to override the default error message. """ self._validate_list(list_) - default = "%s does not contain value '%s'." % (seq2str2(list_), value) - _verify_condition(value in list_, default, msg) + _verify_condition(value in list_, + f"{seq2str2(list_)} does not contain value '{value}'.", + msg) def list_should_not_contain_value(self, list_, value, msg=None): """Fails if the ``value`` is found from ``list``. @@ -319,8 +320,9 @@ def list_should_not_contain_value(self, list_, value, msg=None): Use the ``msg`` argument to override the default error message. """ self._validate_list(list_) - default = "%s contains value '%s'." % (seq2str2(list_), value) - _verify_condition(value not in list_, default, msg) + _verify_condition(value not in list_, + f"{seq2str2(list_)} contains value '{value}'.", + msg) def list_should_not_contain_duplicates(self, list_, msg=None): """Fails if any element in the ``list`` is found from it more than once. @@ -341,11 +343,10 @@ def list_should_not_contain_duplicates(self, list_, msg=None): if item not in dupes: count = list_.count(item) if count > 1: - logger.info("'%s' found %d times." % (item, count)) + logger.info(f"'{item}' found {count} times.") dupes.append(item) if dupes: - raise AssertionError(msg or - '%s found multiple times.' % seq2str(dupes)) + raise AssertionError(msg or f'{seq2str(dupes)} found multiple times.') def lists_should_be_equal(self, list1, list2, msg=None, values=True, names=None, ignore_order=False): @@ -369,8 +370,8 @@ def lists_should_be_equal(self, list1, list2, msg=None, values=True, The optional ``names`` argument can be used for naming the indices shown in the default error message. It can either be a list of names matching the indices in the lists or a dictionary where keys are - indices that need to be named. It is not necessary to name all of - the indices. When using a dictionary, keys can be either integers + indices that need to be named. It is not necessary to name all indices. + When using a dictionary, keys can be either integers or strings that can be converted to integers. Examples: @@ -395,15 +396,17 @@ def lists_should_be_equal(self, list1, list2, msg=None, values=True, self._validate_lists(list1, list2) len1 = len(list1) len2 = len(list2) - default = 'Lengths are different: %d != %d' % (len1, len2) - _verify_condition(len1 == len2, default, msg, values) + _verify_condition(len1 == len2, + f'Lengths are different: {len1} != {len2}', + msg, values) names = self._get_list_index_name_mapping(names, len1) if ignore_order: list1 = sorted(list1) list2 = sorted(list2) - diffs = list(self._yield_list_diffs(list1, list2, names)) - default = 'Lists are different:\n' + '\n'.join(diffs) - _verify_condition(diffs == [], default, msg, values) + diffs = '\n'.join(self._yield_list_diffs(list1, list2, names)) + _verify_condition(not diffs, + f'Lists are different:\n{diffs}', + msg, values) def _get_list_index_name_mapping(self, names, list_length): if not names: @@ -414,14 +417,14 @@ def _get_list_index_name_mapping(self, names, list_length): def _yield_list_diffs(self, list1, list2, names): for index, (item1, item2) in enumerate(zip(list1, list2)): - name = ' (%s)' % names[index] if index in names else '' + name = f' ({names[index]})' if index in names else '' try: - assert_equal(item1, item2, msg='Index %d%s' % (index, name)) + assert_equal(item1, item2, msg=f'Index {index}{name}') except AssertionError as err: yield str(err) def list_should_contain_sub_list(self, list1, list2, msg=None, values=True): - """Fails if not all of the elements in ``list2`` are found in ``list1``. + """Fails if not all elements in ``list2`` are found in ``list1``. The order of values and the number of values are not taken into account. @@ -431,8 +434,9 @@ def list_should_contain_sub_list(self, list1, list2, msg=None, values=True): """ self._validate_lists(list1, list2) diffs = ', '.join(str(item) for item in list2 if item not in list1) - default = 'Following values were not found from first list: ' + diffs - _verify_condition(not diffs, default, msg, values) + _verify_condition(not diffs, + f'Following values were not found from first list: {diffs}', + msg, values) def log_list(self, list_, level='INFO'): """Logs the length and contents of the ``list`` using given ``level``. @@ -449,11 +453,11 @@ def _log_list(self, list_): if not list_: yield 'List is empty.' elif len(list_) == 1: - yield 'List has one item:\n%s' % (list_[0],) + yield f'List has one item:\n{list_[0]}' else: - yield 'List length is %d and it contains following items:' % len(list_) + yield f'List length is {len(list_)} and it contains following items:' for index, item in enumerate(list_): - yield '%s: %s' % (index, item) + yield f'{index}: {item}' def _index_to_int(self, index, empty_to_zero=False): if empty_to_zero and not index: @@ -461,16 +465,15 @@ def _index_to_int(self, index, empty_to_zero=False): try: return int(index) except ValueError: - raise ValueError("Cannot convert index '%s' to an integer." % index) + raise ValueError(f"Cannot convert index '{index}' to an integer.") def _index_error(self, list_, index): - raise IndexError('Given index %s is out of the range 0-%d.' - % (index, len(list_)-1)) + raise IndexError(f'Given index {index} is out of the range 0-{len(list_)-1}.') def _validate_list(self, list_, position=1): if not is_list_like(list_): - raise TypeError("Expected argument %d to be a list or list-like, " - "got %s instead." % (position, type_name(list_))) + raise TypeError(f"Expected argument {position} to be a list or list-like, " + f"got {type_name(list_)} instead.") def _validate_lists(self, *lists): for index, item in enumerate(lists, start=1): @@ -532,9 +535,9 @@ def remove_from_dictionary(self, dictionary, *keys): for key in keys: if key in dictionary: value = dictionary.pop(key) - logger.info("Removed item with key '%s' and value '%s'." % (key, value)) + logger.info(f"Removed item with key '{key}' and value '{value}'.") else: - logger.info("Key '%s' not found." % (key,)) + logger.info(f"Key '{key}' not found.") def pop_from_dictionary(self, dictionary, key, default=NOT_SET): """Pops the given ``key`` from the ``dictionary`` and returns its value. @@ -684,15 +687,16 @@ def get_from_dictionary(self, dictionary, key, default=NOT_SET): | ${value} = | Get From Dictionary | ${D3} | b | => | ${value} = 2 + + Support for ``default`` is new in Robot Framework 5.1. """ self._validate_dictionary(dictionary) try: return dictionary[key] except KeyError: - if default is NOT_SET: - raise RuntimeError("Dictionary does not contain key '%s'." % (key,)) - else: + if default is not NOT_SET: return default + raise RuntimeError(f"Dictionary does not contain key '{key}'.") def dictionary_should_contain_key(self, dictionary, key, msg=None): """Fails if ``key`` is not found from ``dictionary``. @@ -700,8 +704,9 @@ def dictionary_should_contain_key(self, dictionary, key, msg=None): Use the ``msg`` argument to override the default error message. """ self._validate_dictionary(dictionary) - default = "Dictionary does not contain key '%s'." % (key,) - _verify_condition(key in dictionary, default, msg) + _verify_condition(key in dictionary, + f"Dictionary does not contain key '{key}'.", + msg) def dictionary_should_not_contain_key(self, dictionary, key, msg=None): """Fails if ``key`` is found from ``dictionary``. @@ -709,8 +714,9 @@ def dictionary_should_not_contain_key(self, dictionary, key, msg=None): Use the ``msg`` argument to override the default error message. """ self._validate_dictionary(dictionary) - default = "Dictionary contains key '%s'." % (key,) - _verify_condition(key not in dictionary, default, msg) + _verify_condition(key not in dictionary, + f"Dictionary contains key '{key}'.", + msg) def dictionary_should_contain_item(self, dictionary, key, value, msg=None): """An item of ``key`` / ``value`` must be found in a ``dictionary``. @@ -721,9 +727,11 @@ def dictionary_should_contain_item(self, dictionary, key, value, msg=None): """ self._validate_dictionary(dictionary) self.dictionary_should_contain_key(dictionary, key, msg) - actual, expected = str(dictionary[key]), str(value) - default = "Value of dictionary key '%s' does not match: %s != %s" % (key, actual, expected) - _verify_condition(actual == expected, default, msg) + actual = dictionary[key] + _verify_condition(str(actual) == str(value), # FIXME!! + f"Value of dictionary key '{key}' does not match: " + f"{actual} != {value}", + msg) def dictionary_should_contain_value(self, dictionary, value, msg=None): """Fails if ``value`` is not found from ``dictionary``. @@ -731,8 +739,9 @@ def dictionary_should_contain_value(self, dictionary, value, msg=None): Use the ``msg`` argument to override the default error message. """ self._validate_dictionary(dictionary) - default = "Dictionary does not contain value '%s'." % (value,) - _verify_condition(value in dictionary.values(), default, msg) + _verify_condition(value in dictionary.values(), + f"Dictionary does not contain value '{value}'.", + msg) def dictionary_should_not_contain_value(self, dictionary, value, msg=None): """Fails if ``value`` is found from ``dictionary``. @@ -740,8 +749,9 @@ def dictionary_should_not_contain_value(self, dictionary, value, msg=None): Use the ``msg`` argument to override the default error message. """ self._validate_dictionary(dictionary) - default = "Dictionary contains value '%s'." % (value,) - _verify_condition(not value in dictionary.values(), default, msg) + _verify_condition(value not in dictionary.values(), + f"Dictionary contains value '{value}'.", + msg) def dictionaries_should_be_equal(self, dict1, dict2, msg=None, values=True): """Fails if the given dictionaries are not equal. @@ -769,10 +779,10 @@ def dictionary_should_contain_sub_dictionary(self, dict1, dict2, msg=None, self._validate_dictionary(dict1) self._validate_dictionary(dict2, 2) keys = self.get_dictionary_keys(dict2) - diffs = [str(k) for k in keys if k not in dict1] - default = "Following keys missing from first dictionary: %s" \ - % ', '.join(diffs) - _verify_condition(not diffs, default, msg, values) + diffs = ', '.join(str(k) for k in keys if k not in dict1) + _verify_condition(not diffs, + f"Following keys missing from first dictionary: {diffs}", + msg, values) self._key_values_should_be_equal(keys, dict1, dict2, msg, values) def log_dictionary(self, dictionary, level='INFO'): @@ -792,41 +802,40 @@ def _log_dictionary(self, dictionary): elif len(dictionary) == 1: yield 'Dictionary has one item:' else: - yield 'Dictionary size is %d and it contains following items:' % len(dictionary) + yield f'Dictionary size is {len(dictionary)} and it contains following items:' for key in self.get_dictionary_keys(dictionary): - yield '%s: %s' % (key, dictionary[key]) + yield f'{key}: {dictionary[key]}' def _keys_should_be_equal(self, dict1, dict2, msg, values): keys1 = self.get_dictionary_keys(dict1) keys2 = self.get_dictionary_keys(dict2) - miss1 = [str(k) for k in keys2 if k not in dict1] - miss2 = [str(k) for k in keys1 if k not in dict2] + miss1 = ', '.join(str(k) for k in keys2 if k not in dict1) + miss2 = ', '.join(str(k) for k in keys1 if k not in dict2) error = [] if miss1: - error += ['Following keys missing from first dictionary: %s' - % ', '.join(miss1)] + error += [f'Following keys missing from first dictionary: {miss1}'] if miss2: - error += ['Following keys missing from second dictionary: %s' - % ', '.join(miss2)] + error += [f'Following keys missing from second dictionary: {miss2}'] _verify_condition(not error, '\n'.join(error), msg, values) return keys1 def _key_values_should_be_equal(self, keys, dict1, dict2, msg, values): - diffs = list(self._yield_dict_diffs(keys, dict1, dict2)) - default = 'Following keys have different values:\n' + '\n'.join(diffs) - _verify_condition(not diffs, default, msg, values) + diffs = '\n'.join(self._yield_dict_diffs(keys, dict1, dict2)) + _verify_condition(not diffs, + f'Following keys have different values:\n{diffs}', + msg, values) def _yield_dict_diffs(self, keys, dict1, dict2): for key in keys: try: - assert_equal(dict1[key], dict2[key], msg='Key %s' % (key,)) + assert_equal(dict1[key], dict2[key], msg=f'Key {key}') except AssertionError as err: yield str(err) def _validate_dictionary(self, dictionary, position=1): - if is_string(dictionary) or is_number(dictionary): - raise TypeError("Expected argument %d to be a dictionary or dictionary-like, " - "got %s instead." % (position, type_name(dictionary))) + if not is_dict_like(dictionary): + raise TypeError(f"Expected argument {position} to be a dictionary or " + f"dictionary-like, got {type_name(dictionary)} instead.") class Collections(_List, _Dictionary): @@ -952,8 +961,7 @@ def should_contain_match(self, list, pattern, msg=None, _List._validate_list(self, list) matches = _get_matches_in_iterable(list, pattern, case_insensitive, whitespace_insensitive) - default = "%s does not contain match for pattern '%s'." \ - % (seq2str2(list), pattern) + default = f"{seq2str2(list)} does not contain match for pattern '{pattern}'." _verify_condition(matches, default, msg) def should_not_contain_match(self, list, pattern, msg=None, @@ -967,8 +975,7 @@ def should_not_contain_match(self, list, pattern, msg=None, _List._validate_list(self, list) matches = _get_matches_in_iterable(list, pattern, case_insensitive, whitespace_insensitive) - default = "%s contains match for pattern '%s'." \ - % (seq2str2(list), pattern) + default = f"{seq2str2(list)} contains match for pattern '{pattern}'." _verify_condition(not matches, default, msg) def get_matches(self, list, pattern, case_insensitive=False, @@ -1017,7 +1024,7 @@ def _verify_condition(condition, default_msg, msg, values=False): def _get_matches_in_iterable(iterable, pattern, case_insensitive=False, whitespace_insensitive=False): if not is_string(pattern): - raise TypeError("Pattern must be string, got '%s'." % type_name(pattern)) + raise TypeError(f"Pattern must be string, got '{type_name(pattern)}'.") regexp = False if pattern.startswith('regexp='): pattern = pattern[7:] From a5cde77a5863764b039593554c056e5e0114e84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 14 Jul 2022 18:31:35 +0300 Subject: [PATCH 0112/1592] Don't cast values to strings with Dictionary Should Contain Item Fixes #4408. --- .../standard_libraries/collections/dictionary.robot | 6 ++++++ .../standard_libraries/collections/dictionary.robot | 10 +++++++++- src/robot/libraries/Collections.py | 8 +++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/atest/robot/standard_libraries/collections/dictionary.robot b/atest/robot/standard_libraries/collections/dictionary.robot index 8456ec0f5a7..913ddb52080 100644 --- a/atest/robot/standard_libraries/collections/dictionary.robot +++ b/atest/robot/standard_libraries/collections/dictionary.robot @@ -81,6 +81,12 @@ Dictionary Should Contain Item With Missing Key Dictionary Should Contain Item With Wrong Value Check Test Case ${TEST NAME} +Dictionary Should Contain Item With Value Looking Same But With Different Type + Check Test Case ${TEST NAME} + +Dictionary Should Contain Item With Custom Message + Check Test Case ${TEST NAME} + Dictionary Should Not Contain Key Check Test Case ${TEST NAME} diff --git a/atest/testdata/standard_libraries/collections/dictionary.robot b/atest/testdata/standard_libraries/collections/dictionary.robot index d7a041195fc..740bdd1ab8a 100644 --- a/atest/testdata/standard_libraries/collections/dictionary.robot +++ b/atest/testdata/standard_libraries/collections/dictionary.robot @@ -122,7 +122,7 @@ Dictionary Should Contain Key With Missing Key 2 Dictionary Should Contain Key ${D3} ${TUPLE} Dictionary Should Contain Item - Dictionary Should Contain Item ${D3} a 1 + Dictionary Should Contain Item ${D3} a ${1} Dictionary Should Contain Item With Missing Key [Documentation] FAIL Dictionary does not contain key 'x'. @@ -132,6 +132,14 @@ Dictionary Should Contain Item With Wrong Value [Documentation] FAIL Value of dictionary key 'a' does not match: 1 != 2 Dictionary Should Contain Item ${D3} a 2 +Dictionary Should Contain Item With Value Looking Same But With Different Type + [Documentation] FAIL Value of dictionary key 'a' does not match: 1 (integer) != 1 (string) + Dictionary Should Contain Item ${D3} a 1 + +Dictionary Should Contain Item With Custom Message + [Documentation] FAIL Custom message + Dictionary Should Contain Item ${D3} a 1 Custom message + Dictionary Should Not Contain Key Dictionary Should Not Contain Key ${D3} x Dictionary Should Not Contain Key ${D3} ${TUPLE} diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 4a10041c8df..f3124911c25 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -727,11 +727,9 @@ def dictionary_should_contain_item(self, dictionary, key, value, msg=None): """ self._validate_dictionary(dictionary) self.dictionary_should_contain_key(dictionary, key, msg) - actual = dictionary[key] - _verify_condition(str(actual) == str(value), # FIXME!! - f"Value of dictionary key '{key}' does not match: " - f"{actual} != {value}", - msg) + assert_equal(dictionary[key], value, + msg or f"Value of dictionary key '{key}' does not match", + values=not msg) def dictionary_should_contain_value(self, dictionary, value, msg=None): """Fails if ``value`` is not found from ``dictionary``. From 0b9ac1d99667d8fc3bc8291a41f388897249cb79 Mon Sep 17 00:00:00 2001 From: Oliver Boehmer <oli@spine.de> Date: Thu, 14 Jul 2022 17:34:53 +0200 Subject: [PATCH 0113/1592] Document and test that failing setups stops test even if continue-on-failure is on (#4406) Fixes #4404 --- .../running/continue_on_failure_tag.robot | 5 +++++ .../running/continue_on_failure_tag.robot | 19 +++++++++++++++++++ .../src/ExecutingTestCases/TestExecution.rst | 5 +++++ 3 files changed, 29 insertions(+) diff --git a/atest/robot/running/continue_on_failure_tag.robot b/atest/robot/running/continue_on_failure_tag.robot index 318e340c088..7fefa2dc494 100644 --- a/atest/robot/running/continue_on_failure_tag.robot +++ b/atest/robot/running/continue_on_failure_tag.robot @@ -129,3 +129,8 @@ Test recursive-stop-recursive-continue Test recursive-stop-recursive-continue-recursive-stop Check Test Case ${TESTNAME} +Test test setup with continue-on-failure + Check Test Case ${TESTNAME} + +Test test setup with recursive-continue-on-failure + Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/continue_on_failure_tag.robot b/atest/testdata/running/continue_on_failure_tag.robot index f47fb83ce1b..2f45e0bf633 100644 --- a/atest/testdata/running/continue_on_failure_tag.robot +++ b/atest/testdata/running/continue_on_failure_tag.robot @@ -350,6 +350,21 @@ Test recursive-stop-recursive-continue-recursive-stop Failure in user keyword with recursive continue tag run_kw=Failure in user keyword with recursive stop tag Fail 2 +Test test setup with continue-on-failure + [Documentation] FAIL Setup failed:\n + ... setup-1 + [Tags] robot:continue-on-failure + [Setup] test setup + Fail should-not-run + +Test test setup with recursive-continue-on-failure + [Documentation] FAIL Setup failed:\n${HEADER}\n\n + ... 1) setup-1\n\n + ... 2) setup-2 + [Tags] robot:recursive-continue-on-failure + [Setup] test setup + Fail should-not-run + *** Keywords *** Failure in user keyword with continue tag [Arguments] ${run_kw}=No Operation @@ -447,3 +462,7 @@ run-kw-and-continue failure in user keyword with stop tag Fail kw10b Log This is not executed Fail kw10c + +test setup + Fail setup-1 + Fail setup-2 diff --git a/doc/userguide/src/ExecutingTestCases/TestExecution.rst b/doc/userguide/src/ExecutingTestCases/TestExecution.rst index 38a13320e03..6fae7e87d9b 100644 --- a/doc/userguide/src/ExecutingTestCases/TestExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/TestExecution.rst @@ -474,6 +474,11 @@ keywords in the following example are executed: Should be Equal 5 6 Log This is executed +Setting `robot:continue-on-failure` or `robot:recursive-continue-on-failure` in a +test case does NOT alter the behaviour of a failure in the keyword(s) executed +as part of the `[Setup]`:setting:: The test case is marked as failed and no +test case keywords are executed. + .. note:: The `robot:continue-on-failure` and `robot:recursive-continue-on-failure` tags are new in Robot Framework 4.1. They do not work properly with `WHILE` loops prior to Robot Framework 5.1. From 255f0fa2e013cd86419520755f6651311e9370f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 14 Jul 2022 22:57:20 +0300 Subject: [PATCH 0114/1592] Workaround lxml.etree._Attrib not inheriting Mapping. Our is_dict_like didn't recognize it but registing it explicitly as a Mapping avoids that problem. --- src/robot/libraries/XML.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/robot/libraries/XML.py b/src/robot/libraries/XML.py index 6d97ef4652d..a4976c47101 100644 --- a/src/robot/libraries/XML.py +++ b/src/robot/libraries/XML.py @@ -22,6 +22,11 @@ from lxml import etree as lxml_etree except ImportError: lxml_etree = None +else: + # `_Attrib` doesn't inherit `Mapping` and thus our `is_dict_like` doesn't + # recognize it. Registering explicitly avoids that problem. + from collections.abc import Mapping + Mapping.register(lxml_etree._Attrib) from robot.api import logger from robot.api.deco import keyword From 8495c19b67af6a62025be5983875c3b74330650c Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Thu, 14 Jul 2022 22:03:26 +0200 Subject: [PATCH 0115/1592] Update languages.py (#4377) - PT by @HelioGuilherme66 - BT-BR by @HelioGuilherme66 - DE by @Snooz82 and @Noordsestern - NL by @Heatzone87 and @leeuwe - CS by @MoreFamed - FR --- src/robot/conf/languages.py | 229 ++++++++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 10 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 1a4a4e16e78..2203e34a419 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -176,20 +176,89 @@ class En(Language): bdd_prefixes = {'Given', 'When', 'Then', 'And', 'But'} +class Cs(Language): + """Czech""" + setting_headers = {'Nastavení', 'Nastavení', 'Nastavení', 'Nastavení'} + variable_headers = {'Proměnná', 'Proměnné', 'Proměnné', 'Proměnné'} + test_case_headers = {'Testovací případ', 'Testovací případy', 'Testovací případy', 'Testovací případy'} + task_headers = {'Úloha', 'Úlohy', 'Úlohy', 'Úlohy'} + keyword_headers = {'Klíčové slovo', 'Klíčová slova', 'Klíčová slova', 'Klíčová slova'} + comment_headers = {'Komentář', 'Komentáře', 'Komentáře', 'Komentáře'} + library = 'Knihovna' + resource = 'Zdroj' + variables = 'Proměnná' + documentation = 'Dokumentace' + metadata = 'Metadata' + suite_setup = 'Příprava sady' + suite_teardown = 'Ukončení sady' + test_setup = 'Příprava testu' + test_teardown = 'Ukončení testu' + test_template = 'Šablona testu' + test_timeout = 'Časový limit testu' + test_tags = 'Štítky testů' + task_setup = 'Příprava úlohy' + task_teardown = 'Ukončení úlohy' + task_template = 'Šablona úlohy' + task_timeout = 'Časový limit úlohy' + task_tags = 'Štítky úloh' + keyword_tags = 'Štítky klíčových slov' + tags = 'Štítky' + setup = 'Příprava' + teardown = 'Ukončení' + template = 'Šablona' + timeout = 'Časový limit' + arguments = 'Argumenty' + bdd_prefixes = {'Pokud', 'Když', 'Pak', 'A', 'Ale'} + + +class Nl(Language): + """Dutch""" + setting_headers = {'Instelling', 'Instellingen'} + variable_headers = {'Variabele', 'Variabelen'} + test_case_headers = {'Testgeval', 'Testgevallen'} + task_headers = {'Taak', 'Taken'} + keyword_headers = {'Sleutelwoord', 'Sleutelwoorden'} + comment_headers = {'Opmerking', 'Opmerkingen'} + library = 'Bibliotheek' + resource = 'Resource' + variables = 'Variabele' + documentation = 'Documentatie' + metadata = 'Metadata' + suite_setup = 'Suite Preconditie' + suite_teardown = 'Suite Postconditie' + test_setup = 'Test Preconditie' + test_teardown = 'Test Postconditie' + test_template = 'Test Sjabloon' + test_timeout = 'Test Time-out' + test_tags = 'Test Labels' + task_setup = 'Taak Preconditie' + task_teardown = 'Taak Postconditie' + task_template = 'Taak Sjabloon' + task_timeout = 'Taak Time-out' + task_tags = 'Taak Labels' + keyword_tags = 'Sleutelwoord Labels' + tags = 'Labels' + setup = 'Preconditie' + teardown = 'Postconditie' + template = 'Sjabloon' + timeout = 'Time-out' + arguments = 'Parameters' + bdd_prefixes = {'Stel', 'Als', 'Dan', 'En', 'Maar'} + + class Fi(Language): - # FIXME: Update based on terms agreed at - # https://robotframework.crowdin.com/robot-framework - setting_headers = {'Asetukset', 'Asetus'} - variable_headers = {'Muuttujat', 'Muuttuja'} - test_case_headers = {'Testit', 'Testi'} - task_headers = {'Tehtävät', 'Tehtävä'} - keyword_headers = {'Avainsanat', 'Avainsana'} - comment_headers = {'Kommentit', 'Kommentti'} + """Finnish""" + setting_headers = {'Asetus', 'Asetukset'} + variable_headers = {'Muuttuja', 'Muuttujat'} + test_case_headers = {'Testi', 'Testit'} + task_headers = {'Tehtävä', 'Tehtävät'} + keyword_headers = {'Avainsana', 'Avainsanat'} + comment_headers = {'Kommentti', 'Kommentit'} library = 'Kirjasto' resource = 'Resurssi' variables = 'Muuttujat' documentation = 'Dokumentaatio' - metadata = 'Metadata' + metadata = 'Metatiedot' suite_setup = 'Setin Alustus' suite_teardown = 'Setin Purku' test_setup = 'Testin Alustus' @@ -203,10 +272,150 @@ class Fi(Language): test_tags = 'Testin Tagit' task_tags = 'Tehtävän Tagit' keyword_tags = 'Avainsanan Tagit' + tags = 'Tagit' setup = 'Alustus' teardown = 'Purku' template = 'Malli' - tags = 'Tagit' timeout = 'Aikaraja' arguments = 'Argumentit' bdd_prefixes = {'Oletetaan', 'Kun', 'Niin', 'Ja', 'Mutta'} + + +class Fr(Language): + """French""" + setting_headers = {'Paramètre', 'Paramètres'} + variable_headers = {'Variable', 'Variables'} + test_case_headers = {'Unité de test', 'Unités de test'} + task_headers = {'Tâche', 'Tâches'} + keyword_headers = {'Mot-clé', 'Mots-clés'} + comment_headers = {'Commentaire', 'Commentaires'} + library = 'Bibliothèque' + resource = 'Ressource' + variables = 'Variable' + documentation = 'Documentation' + metadata = 'Méta-donnée' + suite_setup = 'Mise en place de suite' + suite_teardown = 'Démontage de suite' + test_setup = 'Mise en place de test' + test_teardown = 'Démontage de test' + test_template = 'Modèle de test' + test_timeout = 'Délai de test' + test_tags = 'Étiquette de test' + task_setup = 'Mise en place de tâche' + task_teardown = 'Démontage de test' + task_template = 'Modèle de tâche' + task_timeout = 'Délai de tâche' + task_tags = 'Étiquette de tâche' + keyword_tags = 'Etiquette de mot-clé' + tags = 'Étiquette' + setup = 'Mise en place' + teardown = 'Démontage' + template = 'Modèle' + timeout = 'Délai d'attente' + arguments = 'Arguments' + bdd_prefixes = {'Étant donné', 'Lorsque', 'Alors', 'Et', 'Mais'} + + +class De(Language): + """German""" + setting_headers = {'Einstellung', 'Einstellungen'} + variable_headers = {'Variable', 'Variablen'} + test_case_headers = {'Testfall', 'Testfälle'} + task_headers = {'Aufgabe', 'Aufgaben'} + keyword_headers = {'Schlüsselwort', 'Schlüsselwörter'} + comment_headers = {'Kommentar', 'Kommentare'} + library = 'Bibliothek' + resource = 'Ressource' + variables = 'Variable' + documentation = 'Dokumentation' + metadata = 'Metadaten' + suite_setup = 'Suitevorbereitung' + suite_teardown = 'Suitenachbereitung' + test_setup = 'Testvorbereitung' + test_teardown = 'Testnachbereitung' + test_template = 'Testvorlage' + test_timeout = 'Testzeitlimit' + test_tags = 'Test Marker' + task_setup = 'Aufgabenvorbereitung' + task_teardown = 'Aufgabennachbereitung' + task_template = 'Aufgabenvorlage' + task_timeout = 'Aufgabenzeitlimit' + task_tags = 'Aufgaben Marker' + keyword_tags = 'Schlüsselwort Marker' + tags = 'Marker' + setup = 'Vorbereitung' + teardown = 'Nachbereitung' + template = 'Vorlage' + timeout = 'Zeitlimit' + arguments = 'Argumente' + bdd_prefixes = {'Angenommen', 'Wenn', 'Dann', 'Und', 'Aber'} + + +class PtBr(Language): + """Portuguese, Brazilian""" + setting_headers = {'Configuração', 'Configurações'} + variable_headers = {'Variável', 'Variáveis'} + test_case_headers = {'Caso de Teste', 'Casos de Teste'} + task_headers = {'Tarefa', 'Tarefas'} + keyword_headers = {'Palavra-Chave', 'Palavras-Chave'} + comment_headers = {'Comentário', 'Comentários'} + library = 'Biblioteca' + resource = 'Recurso' + variables = 'Variável' + documentation = 'Documentação' + metadata = 'Metadados' + suite_setup = 'Configuração da Suíte' + suite_teardown = 'Finalização de Suíte' + test_setup = 'Inicialização de Teste' + test_teardown = 'Finalização de Teste' + test_template = 'Modelo de Teste' + test_timeout = 'Tempo Limite de Teste' + test_tags = 'Test Tags' + task_setup = 'Inicialização de Tarefa' + task_teardown = 'Finalização de Tarefa' + task_template = 'Modelo de Tarefa' + task_timeout = 'Tempo Limite de Tarefa' + task_tags = 'Task Tags' + keyword_tags = 'Keyword Tags' + tags = 'Etiquetas' + setup = 'Inicialização' + teardown = 'Finalização' + template = 'Modelo' + timeout = 'Tempo Limite' + arguments = 'Argumentos' + bdd_prefixes = {'Dado', 'Quando', 'Então', 'E', 'Mas'} + + +class Pt(Language): + """Portuguese""" + setting_headers = {'Definição', 'Definições'} + variable_headers = {'Variável', 'Variáveis'} + test_case_headers = {'Caso de Teste', 'Casos de Teste'} + task_headers = {'Tarefa', 'Tarefas'} + keyword_headers = {'Palavra-Chave', 'Palavras-Chave'} + comment_headers = {'Comentário', 'Comentários'} + library = 'Biblioteca' + resource = 'Recurso' + variables = 'Variável' + documentation = 'Documentação' + metadata = 'Metadados' + suite_setup = 'Inicialização de Suíte' + suite_teardown = 'Finalização de Suíte' + test_setup = 'Inicialização de Teste' + test_teardown = 'Finalização de Teste' + test_template = 'Modelo de Teste' + test_timeout = 'Tempo Limite de Teste' + test_tags = 'Etiquetas de Testes' + task_setup = 'Inicialização de Tarefa' + task_teardown = 'Finalização de Tarefa' + task_template = 'Modelo de Tarefa' + task_timeout = 'Tempo Limite de Tarefa' + task_tags = 'Etiquetas de Tarefas' + keyword_tags = 'Etiquetas de Palavras-Chave' + tags = 'Etiquetas' + setup = 'Inicialização' + teardown = 'Finalização' + template = 'Modelo' + timeout = 'Tempo Limite' + arguments = 'Argumentos' + bdd_prefixes = {'Dado', 'Quando', 'Então', 'E', 'Mas'} From 14d97cbba72aa387c5c7982b13f27f6274442ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 14 Jul 2022 23:23:53 +0300 Subject: [PATCH 0116/1592] Tuning translations - Fix syntax - Change Finnish teardown from "Purku" to "Alasajo" - Test for Finnish "Metatiedot" --- .../testdata/parsing/translations/finnish/tasks.robot | 4 ++-- .../testdata/parsing/translations/finnish/tests.robot | 10 +++++----- src/robot/conf/languages.py | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/atest/testdata/parsing/translations/finnish/tasks.robot b/atest/testdata/parsing/translations/finnish/tasks.robot index 9561976eee4..b54df69fcf0 100644 --- a/atest/testdata/parsing/translations/finnish/tasks.robot +++ b/atest/testdata/parsing/translations/finnish/tasks.robot @@ -1,6 +1,6 @@ *** Asetukset *** Tehtävän Alustus Task Setup -Tehtävän Purku Task Teardown +Tehtävän Alasajo Task Teardown Tehtävän Malli Task Template Tehtävän Aikaraja 1 minute Tehtävän Tagit task tags @@ -13,7 +13,7 @@ Task with settings [Dokumentaatio] Task documentation. [Tagit] own tag [Alustus] NONE - [Purku] NONE + [Alasajo] NONE [Malli] NONE [Aikaraja] NONE Log Nothing to see here diff --git a/atest/testdata/parsing/translations/finnish/tests.robot b/atest/testdata/parsing/translations/finnish/tests.robot index 26063ae14ca..acdf47a5226 100644 --- a/atest/testdata/parsing/translations/finnish/tests.robot +++ b/atest/testdata/parsing/translations/finnish/tests.robot @@ -1,10 +1,10 @@ *** Asetukset *** Dokumentaatio Suite documentation. -Metadata Metadata Value +Metatiedot Metadata Value Setin Alustus Suite Setup -Setin Purku Suite Teardown +Setin Alasajo Suite Teardown Testin Alustus Test Setup -Testin Purku Test Teardown +Testin Alasajo Test Teardown Testin Malli Test Template Testin Aikaraja 1 minute Testin Tagit test tags @@ -24,7 +24,7 @@ Test with settings [Dokumentaatio] Test documentation. [Tagit] own tag [Alustus] NONE - [Purku] NONE + [Alasajo] NONE [Malli] NONE [Aikaraja] NONE Keyword ${VARIABLE} @@ -54,7 +54,7 @@ Keyword [Tagit] own tag [Aikaraja] 1h Should Be Equal ${arg} ${VARIABLE} - [Purku] No Operation + [Alasajo] No Operation *** Kommentit *** Ignored comments. diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 2203e34a419..f2f34c53f34 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -260,11 +260,11 @@ class Fi(Language): documentation = 'Dokumentaatio' metadata = 'Metatiedot' suite_setup = 'Setin Alustus' - suite_teardown = 'Setin Purku' + suite_teardown = 'Setin Alasajo' test_setup = 'Testin Alustus' task_setup = 'Tehtävän Alustus' - test_teardown = 'Testin Purku' - task_teardown = 'Tehtävän Purku' + test_teardown = 'Testin Alasajo' + task_teardown = 'Tehtävän Alasajo' test_template = 'Testin Malli' task_template = 'Tehtävän Malli' test_timeout = 'Testin Aikaraja' @@ -274,7 +274,7 @@ class Fi(Language): keyword_tags = 'Avainsanan Tagit' tags = 'Tagit' setup = 'Alustus' - teardown = 'Purku' + teardown = 'Alasajo' template = 'Malli' timeout = 'Aikaraja' arguments = 'Argumentit' @@ -311,7 +311,7 @@ class Fr(Language): setup = 'Mise en place' teardown = 'Démontage' template = 'Modèle' - timeout = 'Délai d'attente' + timeout = "Délai d'attente" arguments = 'Arguments' bdd_prefixes = {'Étant donné', 'Lorsque', 'Alors', 'Et', 'Mais'} From 2159d0d463a8473ef7cbb335e642c263b118f8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 15 Jul 2022 14:49:08 +0300 Subject: [PATCH 0117/1592] Release notes for 5.1a1 --- doc/releasenotes/rf-5.1a1.rst | 462 ++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 doc/releasenotes/rf-5.1a1.rst diff --git a/doc/releasenotes/rf-5.1a1.rst b/doc/releasenotes/rf-5.1a1.rst new file mode 100644 index 00000000000..3f72631eed9 --- /dev/null +++ b/doc/releasenotes/rf-5.1a1.rst @@ -0,0 +1,462 @@ +=========================== +Robot Framework 5.1 alpha 1 +=========================== + +.. default-role:: code + +`Robot Framework`_ 5.1 is a new feature release that starts Robot Framework's +localization efforts and also brings in other nice enhancements. +Robot Framework 5.1 alpha 1 is the first preview release targeted especially +for people interested in translations. + +All issues targeted for Robot Framework 5.1 can be found +from the `issue tracker milestone`_. +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==5.1a1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 5.1 alpha 1 was released on Friday July 15, 2022. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Robot Framework Slack: https://robotframework-slack-invite.herokuapp.com +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Localization +------------ + +Robot Framework 5.1 starts localization efforts by making it possible to translate +various markers used in the data. It is possible to translate headers +(e.g. `Test Cases`) and settings (e.g. `Documentation`) used in data files (`#4096`_) +as well as `Given/When/Then` prefixes used in BDD (`#519`_). + +The plan is allow translating `True` and `False` words used in Boolean argument +conversion still as part of RF 5.1 (`#4400`__). Future versions may allow translating +syntax like `IF` and `FOR`, contents of log and report, error messages, and so on. + +Languages to use are specified when starting execution using the `--language` command +line option. With languages supported by Robot Framework out-of-the-box it is possible +to use just a language code like `--language fi`. With others it is possible to create +a custom language file and use it like `--language MyLang.py`. + +Robot Framework 5.1 alpha 1 contains built-in support for these languages in addition +to English that is automatically supported: + +- Czech (CS) +- Dutch (NL) +- Finnish (FI) +- French (FR) +- German (DE) +- Portuguese (PT) and Brazilian Portuguese (PTBR) + +All these translations have been provided by the community and we hope to get +more community contributed translations still before Robot Framework 5.1 final +release. If you are interested to help, head to Crowdin__ that we use +for collaboration. For more instructions see issue `#4390`__ and for general +discussion and questions join the `#localization` channel on our Slack. + +__ https://github.com/robotframework/robotframework/issues/4400 +__ https://robotframework.crowdin.com/robot-framework +__ https://github.com/robotframework/robotframework/issues/4390 + +Enhancements for setting keyword and test tags +---------------------------------------------- + +It is now possible to set tags for all keywords in a certain file by using +the new `Keyword Tags` setting (`#4373`_). It works in resource files and also +in test case and suite initialization files. When used in initialization files, +it only affects keywords in that file and does not propagate to lower level suites. + +The `Force Tags` setting has been renamed to `Test Tags` (`#4368`_). The motivation +is to make settings related to tests more consistent (`Test Setup`, `Test Timeout`, +`Test Tags`, ...) and to better separate settings for specifying test and keyword tags. +Consistent naming also easies translations. The old `Force Tags` setting still works but it +will be `deprecated in the future`__. When creating tasks, it is possible to use +`Task Tags` alias instead of `Test Tags`. + +To simplify setting tags, the `Default Tags` setting will `also be deprecated`__. +The functionality it provides , setting tags that some but no all tests get, +will be enabled in the future by using `-tag` syntax with the `[Tags]` setting +to indicate that a test should not get tag `tag`. This syntax will then work +also in combination with the new `Keyword Tags`. For more details see `#4374`__. + +__ `Force Tags and Default Tags settings`_ +__ `Force Tags and Default Tags settings`_ +__ https://github.com/robotframework/robotframework/issues/4374 + +Enhancements to keyword namespaces +---------------------------------- + +It is possible to mark keywords in resource files as private by adding +`robot:private` tag to them (`#430`_). If such a keyword is used by keywords +outside that resource file, there will be a warning. These keywords are also +excluded from HTML library documentation generated by Libdoc. + +If a keyword exists in the same resource file as a keyword using it, it will +be used even if there would be keyword with the same name in another resource +file (`#4366`_). Earlier this situation caused a conflict. + +If a keyword exists in the same resource file as a keyword using it and there +is a keyword with the same name in the test case file, the keyword in the test +case file will be used as it has been used earlier. This behavior is nowadays +deprecated__, though, and in the future local keywords will have precedence also +in these cases. + +__ `Keywords in test case files having precedence over local keywords in resource files`_ + +Possibility to disable continue-on-failure mode +----------------------------------------------- + +Robot Framework generally stops executing a keyword or a test case if there +is a failure. Exceptions to this rule include teardowns, templates and +cases where the continue-on-failure mode has been explicitly enabled with +`robot:continue-on-failure` or `robot:recursive-continue-on-failure` +tags. Robot Framework 5.1 makes it possible to disable the implicit or explicit +continue-on-failure mode when needed by using `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags (`#4303`_). + +Python 3.11 support +-------------------- + +Robot Framework 5.1 officially supports the forthcoming Python 3.11 +release (`#4401`_). Incompatibilities were not too big, so also the earlier +versions work fairly well. + +At the other end of the spectrum, Python 3.6 is deprecated and will not +anymore be supported by Robot Framework 6.0 (`#4295`_). + +Performance enhancements for executing user keywords +---------------------------------------------------- + +The overhead in executing user keywords has been reduced. The difference +can be seen especially if user keywords fail often, for example, when using +`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) + +Backwards incompatible changes +============================== + +- Space is required after `Given/When/Then` prefixes used with BDD scenarios. (`#4379`_) +- `Dictionary Should Contain Item` from the Collections library does not anymore convert + values to strings before comparison. (`#4408`_) +- Generation time in XML and JSON spec files generated by Libdoc has been changed to + `2022-05-27T19:07:15+00:00`. With XML specs the format used to be `2022-05-27T19:07:15Z` + that is equivalent with the new format. JSON spec files did not include the timezone + information at all and the format was `2022-05-27 19:07:15`. (`#4262`_) + +Deprecated features +=================== + +`Force Tags` and `Default Tags` settings +---------------------------------------- + +As `discussed above`__, new `Test Tags` setting has been added to replace `Force Tags` +and there is a plan to remove `Default Tags` altogether. Both of these settings still +work but they are considered deprecated. There is not visible deprecation warning yet, +but such a warning will be emitted starting from Robot Framework 6.0 and eventually these +settings will be removed. (`#4368`_) + +The plan is to add new `-tag` syntax that can be used with the `[Tags]` setting +to enable similar functionality that `Default Tags` provide. As the result +using tags starting with a hyphen with the `[Tags]` setting is deprecated. +If such literal values are needed, it is possible to use escaped format like +`\-tag`. (`#4380`_) + +__ `Enhancements for setting keyword and test tags`_ + +Python 3.6 +---------- + +Python 3.6 `reached end-of-life`__ in December 2021. It will be still supported +by Robot Framework 5.1 and all future RF 5.x releases, but not anymore by +Robot Framework 6.0 (`#4295`_). Users are recommended to upgrade to newer +versions already now. + +__ https://endoflife.date/python + +Keywords in test case files having precedence over local keywords in resource files +----------------------------------------------------------------------------------- + +Keywords in test cases files currently always have the highest precedence. They +are used even when a keyword in a resource file uses a keyword that would exist also +in the same resource file. This will change in Robot Framework 5.2 so that local +keywords always have highest precedence and the current behavior is deprecated. (`#4366`_) + +`WITH NAME` deprecated in favor of `AS` when giving alias to imported library +----------------------------------------------------------------------------- + +`WITH NAME` marker that is used when giving an alias to an imported library +will be renamed to `AS` (`#4371`_). The motivation is to be consistent with +Python that uses `as` for similar purpose. We also already use `AS` with +`TRY/EXCEPT` and reusing the same marker and internally used token simplifies +the syntax. Having less markers will also ease translations (but these markers +cannot yet be translated). + +In Robot Framework 5.1 both `AS` and `WITH NAME` work when setting an alias +for a library. `WITH NAME` is considered deprecated, but there will not be +visible deprecation warnings until Robot Framework 6.0. + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its close to 50 member organizations. Robot Framework 5.1 team funded by +them consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the wider open source community has provided several +great contributions: + +- `Elout van Leeuwen <https://github.com/leeuwe>`_ has lead the localization efforts + (`#4390`__). Individual translations have been provided by the following people: + + - Czech by `Václav Fuksa <https://github.com/MoreFamed>`_ + - Dutch by `Pim Jansen <https://github.com/pimjansen>`_ and + `Elout van Leeuwen <https://github.com/leeuwe>`_ + - French by `@lesnake <https://github.com/lesnake>`_ + - German by `René <https://github.com/Snooz82>`_ and `Markus <https://github.com/Noordsestern>`_ + - Portuguese and Brazilian Portuguese by `Hélio Guilherme <https://github.com/HelioGuilherme66>`_ + +- `Oliver Boehmer <https://github.com/oboehmer>`_ provide several contributions: + + - Support to disable the continue-on-failure mode using `robot:stop-on-failure` and + `robot:recursive-stop-on-failure` tags. (`#4303`_) + - Document that failing test setup stops execution even if the continue-on-failure + mode is active. (`#4404`_) + - Default value to `Get From Dictionary` keyword. (`#4398`_) + +- `Fabio Zadrozny <https://github.com/fabioz>`_ provided a pull request speeding up + user keyword execution. (`#4353`_). + +- `@Apteryks <https://github.com/Apteryks>`_ added support to generate deterministic + library documentation by using `SOURCE_DATE_EPOCH`__ environment variable. (`#4262`_) + +__ https://github.com/robotframework/robotframework/issues/4390 +__ https://reproducible-builds.org/specs/source-date-epoch/ + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#4096`_ + - enhancement + - critical + - Multilanguage support for markers used in data + - alpha 1 + * - `#519`_ + - enhancement + - critical + - Given/When/Then should support other languages than English + - alpha 1 + * - `#4295`_ + - enhancement + - high + - Deprecate Python 3.6 + - alpha 1 + * - `#430`_ + - enhancement + - high + - Keyword visibility modifiers for resource files + - alpha 1 + * - `#4303`_ + - enhancement + - high + - Support disabling continue-on-failure mode using `robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags + - alpha 1 + * - `#4366`_ + - enhancement + - high + - Give local keywords precedence over imported keywords in resource files + - alpha 1 + * - `#4368`_ + - enhancement + - high + - New `Test Tags` setting as an alias for `Force Tags` + - alpha 1 + * - `#4373`_ + - enhancement + - high + - Support adding tags for all keywords using `Keyword Tags` setting + - alpha 1 + * - `#4380`_ + - enhancement + - high + - Deprecate setting tags starting with a hyphen like `-tag` using the `[Tags]` setting + - alpha 1 + * - `#4388`_ + - enhancement + - high + - Enhance performance of executing user keywords especially when they fail + - alpha 1 + * - `#4401`_ + - enhancement + - high + - Python 3.11 compatibility + - alpha 1 + * - `#4351`_ + - bug + - medium + - Libdoc can give bad error message if library argument has extension matching resource files + - alpha 1 + * - `#4355`_ + - bug + - medium + - Continuable failures terminate WHILE loops + - alpha 1 + * - `#4357`_ + - bug + - medium + - Parsing model: Creating `TRY` and `WHILE` statements using `from_params` is not possible + - alpha 1 + * - `#4359`_ + - bug + - medium + - Parsing model: `Variable.from_params` doesn't handle list values properly + - alpha 1 + * - `#4381`_ + - bug + - medium + - Parsing errors are recognized as EmptyLines + - alpha 1 + * - `#4384`_ + - bug + - medium + - RPA aliases for settings do not work in suite initialization files + - alpha 1 + * - `#4387`_ + - bug + - medium + - Libdoc: Fix storing information about deprecated keywords to spec files + - alpha 1 + * - `#4408`_ + - bug + - medium + - Collection: `Dictionary Should Contain Item` incorrectly casts values to strings before comparison + - alpha 1 + * - `#4262`_ + - enhancement + - medium + - Honor `SOURCE_DATE_EPOCH` environment variable when generating library documentation + - alpha 1 + * - `#4312`_ + - enhancement + - medium + - Add project URLs to PyPI + - alpha 1 + * - `#4353`_ + - enhancement + - medium + - Performance enhancements to parsing + - alpha 1 + * - `#4371`_ + - enhancement + - medium + - Add `AS` alias for `WITH NAME` in library imports + - alpha 1 + * - `#4379`_ + - enhancement + - medium + - Require space after Given/When/Then prefixes + - alpha 1 + * - `#4398`_ + - enhancement + - medium + - Collections: `Get From Dictionary` should accept a default value + - alpha 1 + * - `#4404`_ + - enhancement + - medium + - Document that failing test setup stops execution even if continue-on-failure mode is active + - alpha 1 + * - `#4349`_ + - bug + - low + - User Guide: Example related to YAML variable files is buggy + - alpha 1 + * - `#4358`_ + - bug + - low + - User Guide: Errors in examples related to TRY/EXCEPT + - alpha 1 + * - `#4346`_ + - enhancement + - low + - Enhance documentation of the `--timestampoutputs` option + - alpha 1 + * - `#4372`_ + - enhancement + - low + - Document how to import resource files bundled into Python packages + - alpha 1 + * - `#4394`_ + - bug + - --- + - Error when `--doc` or `--metadata` value matches an existing directory + - alpha 1 + +Altogether 31 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.1>`__. + +.. _#4096: https://github.com/robotframework/robotframework/issues/4096 +.. _#519: https://github.com/robotframework/robotframework/issues/519 +.. _#4295: https://github.com/robotframework/robotframework/issues/4295 +.. _#430: https://github.com/robotframework/robotframework/issues/430 +.. _#4303: https://github.com/robotframework/robotframework/issues/4303 +.. _#4366: https://github.com/robotframework/robotframework/issues/4366 +.. _#4368: https://github.com/robotframework/robotframework/issues/4368 +.. _#4373: https://github.com/robotframework/robotframework/issues/4373 +.. _#4380: https://github.com/robotframework/robotframework/issues/4380 +.. _#4388: https://github.com/robotframework/robotframework/issues/4388 +.. _#4401: https://github.com/robotframework/robotframework/issues/4401 +.. _#4351: https://github.com/robotframework/robotframework/issues/4351 +.. _#4355: https://github.com/robotframework/robotframework/issues/4355 +.. _#4357: https://github.com/robotframework/robotframework/issues/4357 +.. _#4359: https://github.com/robotframework/robotframework/issues/4359 +.. _#4381: https://github.com/robotframework/robotframework/issues/4381 +.. _#4384: https://github.com/robotframework/robotframework/issues/4384 +.. _#4387: https://github.com/robotframework/robotframework/issues/4387 +.. _#4408: https://github.com/robotframework/robotframework/issues/4408 +.. _#4262: https://github.com/robotframework/robotframework/issues/4262 +.. _#4312: https://github.com/robotframework/robotframework/issues/4312 +.. _#4353: https://github.com/robotframework/robotframework/issues/4353 +.. _#4371: https://github.com/robotframework/robotframework/issues/4371 +.. _#4379: https://github.com/robotframework/robotframework/issues/4379 +.. _#4398: https://github.com/robotframework/robotframework/issues/4398 +.. _#4404: https://github.com/robotframework/robotframework/issues/4404 +.. _#4349: https://github.com/robotframework/robotframework/issues/4349 +.. _#4358: https://github.com/robotframework/robotframework/issues/4358 +.. _#4346: https://github.com/robotframework/robotframework/issues/4346 +.. _#4372: https://github.com/robotframework/robotframework/issues/4372 +.. _#4394: https://github.com/robotframework/robotframework/issues/4394 From 7f31eb49deed90f5da9e542d6f3966309337aa5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 15 Jul 2022 15:19:09 +0300 Subject: [PATCH 0118/1592] Updated version to 5.1a1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 9fb84ed032e..d0099f7290d 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1.dev1' +VERSION = '5.1a1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index fa9fd180d8c..41cf7c835df 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1.dev1' +VERSION = '5.1a1' def get_version(naked=False): From 12f937cd3cec87b9f964a038fb3af800636d36dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 15 Jul 2022 15:24:03 +0300 Subject: [PATCH 0119/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index d0099f7290d..1fa10c9008d 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1a1' +VERSION = '5.1a2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 41cf7c835df..ec24503c26b 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1a1' +VERSION = '5.1a2.dev1' def get_version(naked=False): From 4e226582f53fd97b28a3979131e58ebd489caa8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 18 Jul 2022 10:37:23 +0300 Subject: [PATCH 0120/1592] Enhance code and commend of lxml.etree._Attrib handling. --- src/robot/libraries/XML.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/robot/libraries/XML.py b/src/robot/libraries/XML.py index a4976c47101..64b890f251d 100644 --- a/src/robot/libraries/XML.py +++ b/src/robot/libraries/XML.py @@ -23,10 +23,14 @@ except ImportError: lxml_etree = None else: - # `_Attrib` doesn't inherit `Mapping` and thus our `is_dict_like` doesn't - # recognize it. Registering explicitly avoids that problem. - from collections.abc import Mapping - Mapping.register(lxml_etree._Attrib) + # `lxml.etree._Attrib` doesn't extend `Mapping` and thus our `is_dict_like` + # doesn't recognize it unless we register it ourselves. Fixed in lxml 4.9.2: + # https://bugs.launchpad.net/lxml/+bug/1981760 + from collections.abc import MutableMapping + Attrib = getattr(lxml_etree, '_Attrib', None) + if Attrib and not isinstance(Attrib, MutableMapping): + MutableMapping.register(Attrib) + del Attrib, MutableMapping from robot.api import logger from robot.api.deco import keyword From 82d428cbed688a662a7bef758cdfc498b412595a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 19 Jul 2022 13:15:08 +0300 Subject: [PATCH 0121/1592] Don't require translations to be in title case. This was already done with settings earlier but now also header and BDD prefix translations can be specified in any case. Fixes #4411. --- .../parsing/translations/custom/custom.py | 12 +++++------ .../translations/custom/resource.resource | 6 +++--- .../parsing/translations/custom/tasks.robot | 6 +++--- .../parsing/translations/custom/tests.robot | 10 ++++----- src/robot/conf/languages.py | 21 ++++++++++--------- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/atest/testdata/parsing/translations/custom/custom.py b/atest/testdata/parsing/translations/custom/custom.py index e52d4e92b0f..f6357b65e8f 100644 --- a/atest/testdata/parsing/translations/custom/custom.py +++ b/atest/testdata/parsing/translations/custom/custom.py @@ -2,12 +2,12 @@ class Custom(Language): - setting_headers = {'H 1'} - variable_headers = {'H 2'} - test_case_headers = {'H 3'} - task_headers = {'H 4'} - keyword_headers = {'H 5'} - comment_headers = {'H 6'} + setting_headers = {'H S'} + variable_headers = {'H v'} + test_case_headers = {'h te'} + task_headers = {'H Ta'} + keyword_headers = {'H k'} + comment_headers = {'h C'} library = 'L' resource = 'R' variables = 'V' diff --git a/atest/testdata/parsing/translations/custom/resource.resource b/atest/testdata/parsing/translations/custom/resource.resource index 6fb0ac03388..67fd04fc73d 100644 --- a/atest/testdata/parsing/translations/custom/resource.resource +++ b/atest/testdata/parsing/translations/custom/resource.resource @@ -1,9 +1,9 @@ -*** h 1 *** +*** h s *** D Example documentation. -*** h 2 *** +*** H V *** ${RESOURCE FILE} variable in resource file -*** h 5 *** +*** H k *** Keyword In Resource No Operation diff --git a/atest/testdata/parsing/translations/custom/tasks.robot b/atest/testdata/parsing/translations/custom/tasks.robot index a905c830087..1e66b7f9c60 100644 --- a/atest/testdata/parsing/translations/custom/tasks.robot +++ b/atest/testdata/parsing/translations/custom/tasks.robot @@ -1,11 +1,11 @@ -*** H 1 *** +*** h s *** Ta S Task Setup Ta Tea Task Teardown ta tem Task Template TA TI 1 minute Ta Ta task tags -*** h 4 *** +*** h ta *** Task without settings Nothing to see here @@ -18,7 +18,7 @@ Task with settings [Ti] NONE Log Nothing to see here -*** H 5 *** +*** H K *** Task Setup No Operation diff --git a/atest/testdata/parsing/translations/custom/tests.robot b/atest/testdata/parsing/translations/custom/tests.robot index c9f7c672a85..73b54ef9538 100644 --- a/atest/testdata/parsing/translations/custom/tests.robot +++ b/atest/testdata/parsing/translations/custom/tests.robot @@ -1,4 +1,4 @@ -*** H 1 *** +*** H S *** D Suite documentation. M Metadata Value S S Suite Setup @@ -13,10 +13,10 @@ L OperatingSystem R resource.resource V ../../variables.py -*** H 2 *** +*** h v *** ${VARIABLE} variable value -*** H 3 *** +*** H TE *** Test without settings Nothing to see here @@ -29,7 +29,7 @@ Test with settings [ti] NONE Keyword ${VARIABLE} -*** H 5 *** +*** h K *** Suite Setup Directory Should Exist ${CURDIR} @@ -56,5 +56,5 @@ Keyword Should Be Equal ${arg} ${VARIABLE} [TEA] No Operation -*** H 6 *** +*** H C *** Ignored comments. diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index f2f34c53f34..74e65c53c59 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -32,14 +32,16 @@ def __init__(self, languages): self.settings = {} self.bdd_prefixes = set() for lang in self.languages: - self.setting_headers |= lang.setting_headers - self.variable_headers |= lang.variable_headers - self.test_case_headers |= lang.test_case_headers - self.task_headers |= lang.task_headers - self.keyword_headers |= lang.keyword_headers - self.comment_headers |= lang.comment_headers - self.settings.update(lang.settings) - self.bdd_prefixes |= lang.bdd_prefixes + self.setting_headers |= {h.title() for h in lang.setting_headers} + self.variable_headers |= {h.title() for h in lang.variable_headers} + self.test_case_headers |= {h.title() for h in lang.test_case_headers} + self.task_headers |= {h.title() for h in lang.task_headers} + self.keyword_headers |= {h.title() for h in lang.keyword_headers} + self.comment_headers |= {h.title() for h in lang.comment_headers} + self.settings.update( + {name.title(): lang.settings[name] for name in lang.settings if name} + ) + self.bdd_prefixes |= {p.title() for p in lang.bdd_prefixes} def _get_languages(self, languages): languages = self._resolve_languages(languages) @@ -113,7 +115,7 @@ class Language: @property def settings(self): - settings = { + return { self.library: En.library, self.resource: En.resource, self.variables: En.variables, @@ -139,7 +141,6 @@ def settings(self): self.timeout: En.timeout, self.arguments: En.arguments, } - return {name.title(): settings[name] for name in settings if name} class En(Language): From a70c052233bd8fba59669150379bba2de893058f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 19 Jul 2022 13:23:46 +0300 Subject: [PATCH 0122/1592] Remove duplicate Czech header translations. See #4411. --- src/robot/conf/languages.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 74e65c53c59..5ab6c49b3af 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -179,12 +179,12 @@ class En(Language): class Cs(Language): """Czech""" - setting_headers = {'Nastavení', 'Nastavení', 'Nastavení', 'Nastavení'} - variable_headers = {'Proměnná', 'Proměnné', 'Proměnné', 'Proměnné'} - test_case_headers = {'Testovací případ', 'Testovací případy', 'Testovací případy', 'Testovací případy'} - task_headers = {'Úloha', 'Úlohy', 'Úlohy', 'Úlohy'} - keyword_headers = {'Klíčové slovo', 'Klíčová slova', 'Klíčová slova', 'Klíčová slova'} - comment_headers = {'Komentář', 'Komentáře', 'Komentáře', 'Komentáře'} + setting_headers = {'Nastavení'} + variable_headers = {'Proměnná', 'Proměnné'} + test_case_headers = {'Testovací případ', 'Testovací případy'} + task_headers = {'Úlohy', 'Úloha'} + keyword_headers = {'Klíčové slovo', 'Klíčová slova'} + comment_headers = {'Komentáře', 'Komentář'} library = 'Knihovna' resource = 'Zdroj' variables = 'Proměnná' From f63d3513c0dd8e1fd60619abc304180c4875b3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Hirsz?= <Bartlomiej_Hirsz@epam.com> Date: Tue, 19 Jul 2022 12:35:59 +0200 Subject: [PATCH 0123/1592] Add polish language (#4412) Part of #4390. --- src/robot/conf/languages.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 5ab6c49b3af..90996a64ddc 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -420,3 +420,38 @@ class Pt(Language): timeout = 'Tempo Limite' arguments = 'Argumentos' bdd_prefixes = {'Dado', 'Quando', 'Então', 'E', 'Mas'} + + +class Pl(Language): + """Polish""" + setting_headers = {'Ustawienia'} + variable_headers = {'Zmienna', 'Zmienne'} + test_case_headers = {'Przypadek testowy', 'Przypadki testowe', 'Test', 'Testy', 'Scenariusz', 'Scenariusze'} + task_headers = {'Zadanie', 'Zadania'} + keyword_headers = {'Słowo kluczowe', 'Słowa kluczowe', 'Funkcja', 'Funkcje'} + comment_headers = {'Komentarz', 'Komentarze'} + library = 'Biblioteka' + resource = 'Zasób' + variables = 'Zmienne' + documentation = 'Dokumentacja' + metadata = 'Metadane' + suite_setup = 'Inicjalizacja zestawu' + suite_teardown = 'Ukończenie zestawu' + test_setup = 'Inicjalizacja testu' + test_teardown = 'Ukończenie testu' + test_template = 'Szablon testu' + test_timeout = 'Limit czasowy testu' + test_tags = 'Znaczniki testu' + task_setup = 'Inicjalizacja zadania' + task_teardown = 'Ukończenie zadania' + task_template = 'Szablon zadania' + task_timeout = 'Limit czasowy zadania' + task_tags = 'Znaczniki zadania' + keyword_tags = 'Znaczniki słowa kluczowego' + tags = 'Znaczniki' + setup = 'Inicjalizacja' + teardown = 'Ukończenie' + template = 'Szablon' + timeout = 'Limit czasowy' + arguments = 'Argumenty' + bdd_prefixes = {'Zakładając', 'Zakładając, że', 'Mając', 'Jeżeli', 'Jeśli', 'Gdy', 'Kiedy', 'Wtedy', 'Oraz', 'I', 'Ale'} From 854577c62793bebee863bde8859539be0375c5f5 Mon Sep 17 00:00:00 2001 From: Somkiat Puisungnoen <somkiat.p@gmail.com> Date: Tue, 19 Jul 2022 17:43:39 +0700 Subject: [PATCH 0124/1592] Add thai language (#4410) Part of #4390. --- src/robot/conf/languages.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 90996a64ddc..1a69c65d3ef 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -422,6 +422,41 @@ class Pt(Language): bdd_prefixes = {'Dado', 'Quando', 'Então', 'E', 'Mas'} +class Th(Language): + """Thai""" + setting_headers = {'การตั้งค่า'} + variable_headers = {'กำหนดตัวแปร'} + test_case_headers = {'การทดสอบ'} + task_headers = {'งาน'} + keyword_headers = {'คำสั่งเพิ่มเติม'} + comment_headers = {'คำอธิบาย'} + library = 'ชุดคำสั่งที่ใช้' + resource = 'ไฟล์ที่ใช้' + variables = 'ชุดตัวแปร' + documentation = 'เอกสาร' + metadata = 'รายละเอียดเพิ่มเติม' + suite_setup = 'กำหนดค่าเริ่มต้นของชุดการทดสอบ' + suite_teardown = 'คืนค่าของชุดการทดสอบ' + test_setup = 'กำหนดค่าเริ่มต้นของการทดสอบ' + task_setup = 'กำหนดค่าเริ่มต้นของงาน' + test_teardown = 'คืนค่าของการทดสอบ' + task_teardown = 'คืนค่าของงาน' + test_template = 'โครงสร้างของการทดสอบ' + task_template = 'โครงสร้างของงาน' + test_timeout = 'เวลารอของการทดสอบ' + task_timeout = 'เวลารอของงาน' + test_tags = 'กลุ่มของการทดสอบ' + task_tags = 'กลุ่มของงาน' + keyword_tags = 'กลุ่มของคำสั่งเพิ่มเติม' + setup = 'กำหนดค่าเริ่มต้น' + teardown = 'คืนค่า' + template = 'โครงสร้าง' + tags = 'กลุ่ม' + timeout = 'หมดเวลา' + arguments = 'ค่าที่ส่งเข้ามา' + bdd_prefixes = {'กำหนดให้', 'เมื่อ', 'ดังนั้น', 'และ', 'แต่'} + + class Pl(Language): """Polish""" setting_headers = {'Ustawienia'} From 147ac6cf3dc2e2f2683788aa30b2727e976171ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 21 Jul 2022 10:56:51 +0300 Subject: [PATCH 0125/1592] Translation API enhancements. - Expose `Language` via `robot.api`. - Add `Language.from_name` for getting language instances. - Enhance `--language` option to works with language full names in docstrings - Enhance `--language` to ignore `-` to support e.g. `PT-BR`. --- atest/robot/parsing/translations.robot | 8 ++++++-- src/robot/api/__init__.py | 3 +++ src/robot/conf/languages.py | 25 +++++++++++++++++++---- utest/api/test_languages.py | 28 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 utest/api/test_languages.py diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index 357d659566a..101e64ad5d0 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -7,8 +7,12 @@ Finnish Validate Translations Finnish task aliases - [Documentation] Also test that '--language' works when running a directory. - Run Tests --language fi --rpa parsing/translations/finnish + [Documentation] + ... Also tests that + ... - '--language' works when running a directory, + ... - it is possible to use language class docstring, and + ... - '-' is ignored in the given name to support e.g. 'pt-br'. + Run Tests --language fin-nish --rpa parsing/translations/finnish Validate Task Translations Custom diff --git a/src/robot/api/__init__.py b/src/robot/api/__init__.py index 7c2eb459ec3..ea80fc8e4a7 100644 --- a/src/robot/api/__init__.py +++ b/src/robot/api/__init__.py @@ -58,6 +58,8 @@ returned by the :func:`~robot.result.resultbuilder.ExecutionResult` or an executed :class:`~robot.running.model.TestSuite`. +* :class:`~robot.conf.languages.Language` base class for custom translations. + All of the above names can be imported like:: from robot.api import ApiName @@ -68,6 +70,7 @@ via the :mod:`robot` root package. """ +from robot.conf.languages import Language from robot.model import SuiteVisitor from robot.parsing import (get_tokens, get_resource_tokens, get_init_tokens, get_model, get_resource_model, get_init_model, diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 1a69c65d3ef..7c72972c159 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -16,7 +16,7 @@ import inspect import os.path -from robot.utils import is_string, Importer +from robot.utils import getdoc, is_string, Importer class Languages: @@ -45,11 +45,12 @@ def __init__(self, languages): def _get_languages(self, languages): languages = self._resolve_languages(languages) - available = {c.__name__.lower(): c for c in Language.__subclasses__()} + available = self._get_available_languages() returned = [] for lang in languages: - if lang.lower() in available: - returned.append(available[lang.lower()]) + normalized = lang.lower().replace('-', '') + if normalized in available: + returned.append(available[normalized]) else: returned.extend(self._import_languages(lang)) return [subclass() for subclass in returned] @@ -63,6 +64,14 @@ def _resolve_languages(self, languages): languages.append('en') return languages + def _get_available_languages(self): + available = {} + for lang in Language.__subclasses__(): + available[lang.__name__.lower()] = lang + if lang.__doc__: + available[lang.__doc__.lower()] = lang + return available + def _import_languages(self, lang): def is_language(member): return (inspect.isclass(member) @@ -113,6 +122,14 @@ class Language: arguments = None bdd_prefixes = set() + @classmethod + def from_name(cls, name): + normalized = name.lower().replace('-', '') + for subcls in cls.__subclasses__(): + if normalized in (subcls.__name__.lower(), getdoc(subcls).lower()): + return subcls() + raise ValueError(f"No language with name '{name}' found.") + @property def settings(self): return { diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py new file mode 100644 index 00000000000..5d05596af55 --- /dev/null +++ b/utest/api/test_languages.py @@ -0,0 +1,28 @@ +import unittest + +from robot.api import Language +from robot.conf.languages import Fi, PtBr +from robot.utils.asserts import assert_raises_with_msg + + +class TestFromName(unittest.TestCase): + + def test_class_name(self): + assert isinstance(Language.from_name('fi'), Fi) + assert isinstance(Language.from_name('FI'), Fi) + + def test_docstring(self): + assert isinstance(Language.from_name('finnish'), Fi) + assert isinstance(Language.from_name('Finnish'), Fi) + + def test_hyphen_is_ignored(self): + assert isinstance(Language.from_name('pt-br'), PtBr) + assert isinstance(Language.from_name('PT-BR'), PtBr) + + def test_no_match(self): + assert_raises_with_msg(ValueError, "No language with name 'no match' found.", + Language.from_name, 'no match') + + +if __name__ == '__main__': + unittest.main() From dab64c498331746b208188338eb4c3b991af04f9 Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Thu, 21 Jul 2022 19:31:10 +0200 Subject: [PATCH 0126/1592] Add Simplified Chinese and Spanish translations (#4415) Part of #4390. --- src/robot/conf/languages.py | 70 +++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 7c72972c159..41aea40aaba 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -507,3 +507,73 @@ class Pl(Language): timeout = 'Limit czasowy' arguments = 'Argumenty' bdd_prefixes = {'Zakładając', 'Zakładając, że', 'Mając', 'Jeżeli', 'Jeśli', 'Gdy', 'Kiedy', 'Wtedy', 'Oraz', 'I', 'Ale'} + + +class Es(Language): + """Spanish""" + setting_headers = {'Configuración', 'Configuraciones'} + variable_headers = {'Variable', 'Variables'} + test_case_headers = {'Caso de prueba', 'Casos de prueba'} + task_headers = {'Tarea', 'Tareas'} + keyword_headers = {'Palabra clave', 'Palabras clave'} + comment_headers = {'Comentario', 'Comentarios'} + library = 'Biblioteca' + resource = 'Recursos' + variables = 'Variable' + documentation = 'Documentación' + metadata = 'Metadatos' + suite_setup = 'Configuración de la Suite' + suite_teardown = 'Desmontaje de la Suite' + test_setup = 'Configuración de prueba' + test_teardown = 'Desmontaje de la prueba' + test_template = 'Plantilla de prueba' + test_timeout = 'Tiempo de espera de la prueba' + test_tags = 'Etiquetas de la prueba' + task_setup = 'Configuración de tarea' + task_teardown = 'Desmontaje de tareas' + task_template = 'Plantilla de tareas' + task_timeout = 'Tiempo de espera de las tareas' + task_tags = 'Etiquetas de las tareas' + keyword_tags = 'Etiquetas de palabras clave' + tags = 'Etiquetas' + setup = 'Configuración' + teardown = 'Desmontaje' + template = 'Plantilla' + timeout = 'Tiempo agotado' + arguments = 'Argumentos' + bdd_prefixes = {'Dado', 'Cuando', 'Entonces', 'Y', 'Pero'} + + +class ZhCn(Language): + """Chinese Simplified""" + setting_headers = {'设置'} + variable_headers = {'变量'} + test_case_headers = {'用例'} + task_headers = {'任务'} + keyword_headers = {'关键字'} + comment_headers = {'备注'} + library = '库' + resource = '资源' + variables = '变量' + documentation = '说明文档' + metadata = '元数据' + suite_setup = '用例集预置' + suite_teardown = '用例集收尾' + test_setup = '用例预置' + test_teardown = '用例收尾' + test_template = '测试模板' + test_timeout = '用例超时' + test_tags = '测试标签' + task_setup = '任务启程' + task_teardown = '任务收尾' + task_template = '任务模板' + task_timeout = '任务超时' + task_tags = '任务标签' + keyword_tags = '关键字标签' + tags = '标签' + setup = '预设' + teardown = '终程' + template = '模板' + timeout = '超时' + arguments = '参数' + bdd_prefixes = {'输入', '当', '则', '且', '但'} From fccd85ff7ee7074c5574883cda0e8f74cad44cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 21 Jul 2022 11:03:59 +0300 Subject: [PATCH 0127/1592] API doc to Language.from_name --- src/robot/conf/languages.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 41aea40aaba..7bb7962ea5d 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -124,6 +124,14 @@ class Language: @classmethod def from_name(cls, name): + """Return langauge class based on given `name`. + + Name is matched both against the class name (language short name) + and possible docstring (full language name). Matching is case-insensitive + and hyphen (`-`) is ignored to support, for example, `PT-BR`. + + Raises `ValueError` if no matching langauge is found. + """ normalized = name.lower().replace('-', '') for subcls in cls.__subclasses__(): if normalized in (subcls.__name__.lower(), getdoc(subcls).lower()): From d5c507f39dfe74f63af628938d3d1ed7c1016cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 21 Jul 2022 21:01:49 +0300 Subject: [PATCH 0128/1592] Release notes for 5.1a2 --- doc/releasenotes/rf-5.1a2.rst | 478 ++++++++++++++++++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 doc/releasenotes/rf-5.1a2.rst diff --git a/doc/releasenotes/rf-5.1a2.rst b/doc/releasenotes/rf-5.1a2.rst new file mode 100644 index 00000000000..1300ad19eeb --- /dev/null +++ b/doc/releasenotes/rf-5.1a2.rst @@ -0,0 +1,478 @@ +=========================== +Robot Framework 5.1 alpha 2 +=========================== + +.. default-role:: code + +`Robot Framework`_ 5.1 is a new feature release that starts Robot Framework's +localization efforts and also brings in other nice enhancements. +Robot Framework 5.1 alpha releases are targeted especially +for people interested in translations. + +All issues targeted for Robot Framework 5.1 can be found +from the `issue tracker milestone`_. +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==5.1a1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 5.1 alpha 2 was released on Thursday July 21, 2022. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Robot Framework Slack: https://robotframework-slack-invite.herokuapp.com +.. _installation instructions: ../../INSTALL.rst + + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Localization +------------ + +Robot Framework 5.1 starts localization efforts by making it possible to translate +various markers used in the data. It is possible to translate headers +(e.g. `Test Cases`) and settings (e.g. `Documentation`) used in data files (`#4096`_) +as well as `Given/When/Then` prefixes used in BDD (`#519`_). + +The plan is allow translating `True` and `False` words used in Boolean argument +conversion still as part of RF 5.1 (`#4400`__). Future versions may allow translating +syntax like `IF` and `FOR`, contents of log and report, error messages, and so on. + +Languages to use are specified when starting execution using the `--language` command +line option. With languages supported by Robot Framework out-of-the-box it is possible +to use just a language code like `--language fi`. With others it is possible to create +a custom language file and use it like `--language MyLang.py`. + +Robot Framework 5.1 alpha 2 contains built-in support for these languages in addition +to English that is automatically supported: + +- Czech (CS) +- Dutch (NL) +- Finnish (FI) +- French (FR) +- German (DE) +- Polish (PL) +- Portuguese (PT) and Brazilian Portuguese (PT-BR) +- Simplified Chinese (ZH-CN) +- Spanish (ES) +- Thai (TH) + +All these translations have been provided by the community and we hope to get +more community contributed translations still before Robot Framework 5.1 final +release. If you are interested to help, head to Crowdin__ that we use +for collaboration. For more instructions see issue `#4390`__ and for general +discussion and questions join the `#localization` channel on our Slack. + +__ https://github.com/robotframework/robotframework/issues/4400 +__ https://robotframework.crowdin.com/robot-framework +__ https://github.com/robotframework/robotframework/issues/4390 + +Enhancements for setting keyword and test tags +---------------------------------------------- + +It is now possible to set tags for all keywords in a certain file by using +the new `Keyword Tags` setting (`#4373`_). It works in resource files and also +in test case and suite initialization files. When used in initialization files, +it only affects keywords in that file and does not propagate to lower level suites. + +The `Force Tags` setting has been renamed to `Test Tags` (`#4368`_). The motivation +is to make settings related to tests more consistent (`Test Setup`, `Test Timeout`, +`Test Tags`, ...) and to better separate settings for specifying test and keyword tags. +Consistent naming also easies translations. The old `Force Tags` setting still works but it +will be `deprecated in the future`__. When creating tasks, it is possible to use +`Task Tags` alias instead of `Test Tags`. + +To simplify setting tags, the `Default Tags` setting will `also be deprecated`__. +The functionality it provides , setting tags that some but no all tests get, +will be enabled in the future by using `-tag` syntax with the `[Tags]` setting +to indicate that a test should not get tag `tag`. This syntax will then work +also in combination with the new `Keyword Tags`. For more details see `#4374`__. + +__ `Force Tags and Default Tags settings`_ +__ `Force Tags and Default Tags settings`_ +__ https://github.com/robotframework/robotframework/issues/4374 + +Enhancements to keyword namespaces +---------------------------------- + +It is possible to mark keywords in resource files as private by adding +`robot:private` tag to them (`#430`_). If such a keyword is used by keywords +outside that resource file, there will be a warning. These keywords are also +excluded from HTML library documentation generated by Libdoc. + +If a keyword exists in the same resource file as a keyword using it, it will +be used even if there would be keyword with the same name in another resource +file (`#4366`_). Earlier this situation caused a conflict. + +If a keyword exists in the same resource file as a keyword using it and there +is a keyword with the same name in the test case file, the keyword in the test +case file will be used as it has been used earlier. This behavior is nowadays +deprecated__, though, and in the future local keywords will have precedence also +in these cases. + +__ `Keywords in test case files having precedence over local keywords in resource files`_ + +Possibility to disable continue-on-failure mode +----------------------------------------------- + +Robot Framework generally stops executing a keyword or a test case if there +is a failure. Exceptions to this rule include teardowns, templates and +cases where the continue-on-failure mode has been explicitly enabled with +`robot:continue-on-failure` or `robot:recursive-continue-on-failure` +tags. Robot Framework 5.1 makes it possible to disable the implicit or explicit +continue-on-failure mode when needed by using `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags (`#4303`_). + +Python 3.11 support +-------------------- + +Robot Framework 5.1 officially supports the forthcoming Python 3.11 +release (`#4401`_). Incompatibilities were not too big, so also the earlier +versions work fairly well. + +At the other end of the spectrum, Python 3.6 is deprecated and will not +anymore be supported by Robot Framework 6.0 (`#4295`_). + +Performance enhancements for executing user keywords +---------------------------------------------------- + +The overhead in executing user keywords has been reduced. The difference +can be seen especially if user keywords fail often, for example, when using +`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) + +Backwards incompatible changes +============================== + +- Space is required after `Given/When/Then` prefixes used with BDD scenarios. (`#4379`_) +- Dictionary related keywords in `Collections` require dictionaries to inherit `Mapping`. (`#4413`_) +- `Dictionary Should Contain Item` from the Collections library does not anymore convert + values to strings before comparison. (`#4408`_) +- Generation time in XML and JSON spec files generated by Libdoc has been changed to + `2022-05-27T19:07:15+00:00`. With XML specs the format used to be `2022-05-27T19:07:15Z` + that is equivalent with the new format. JSON spec files did not include the timezone + information at all and the format was `2022-05-27 19:07:15`. (`#4262`_) + +Deprecated features +=================== + +`Force Tags` and `Default Tags` settings +---------------------------------------- + +As `discussed above`__, new `Test Tags` setting has been added to replace `Force Tags` +and there is a plan to remove `Default Tags` altogether. Both of these settings still +work but they are considered deprecated. There is not visible deprecation warning yet, +but such a warning will be emitted starting from Robot Framework 6.0 and eventually these +settings will be removed. (`#4368`_) + +The plan is to add new `-tag` syntax that can be used with the `[Tags]` setting +to enable similar functionality that `Default Tags` provide. As the result +using tags starting with a hyphen with the `[Tags]` setting is deprecated. +If such literal values are needed, it is possible to use escaped format like +`\-tag`. (`#4380`_) + +__ `Enhancements for setting keyword and test tags`_ + +Python 3.6 +---------- + +Python 3.6 `reached end-of-life`__ in December 2021. It will be still supported +by Robot Framework 5.1 and all future RF 5.x releases, but not anymore by +Robot Framework 6.0 (`#4295`_). Users are recommended to upgrade to newer +versions already now. + +__ https://endoflife.date/python + +Keywords in test case files having precedence over local keywords in resource files +----------------------------------------------------------------------------------- + +Keywords in test cases files currently always have the highest precedence. They +are used even when a keyword in a resource file uses a keyword that would exist also +in the same resource file. This will change in Robot Framework 5.2 so that local +keywords always have highest precedence and the current behavior is deprecated. (`#4366`_) + +`WITH NAME` deprecated in favor of `AS` when giving alias to imported library +----------------------------------------------------------------------------- + +`WITH NAME` marker that is used when giving an alias to an imported library +will be renamed to `AS` (`#4371`_). The motivation is to be consistent with +Python that uses `as` for similar purpose. We also already use `AS` with +`TRY/EXCEPT` and reusing the same marker and internally used token simplifies +the syntax. Having less markers will also ease translations (but these markers +cannot yet be translated). + +In Robot Framework 5.1 both `AS` and `WITH NAME` work when setting an alias +for a library. `WITH NAME` is considered deprecated, but there will not be +visible deprecation warnings until Robot Framework 6.0. + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its close to 50 member organizations. Robot Framework 5.1 team funded by +them consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the wider open source community has provided several +great contributions: + +- `Elout van Leeuwen <https://github.com/leeuwe>`_ has lead the localization efforts + (`#4390`__). Individual translations have been provided by the following people: + + - Czech by `Václav Fuksa <https://github.com/MoreFamed>`_ + - Dutch by `Pim Jansen <https://github.com/pimjansen>`_ and + `Elout van Leeuwen <https://github.com/leeuwe>`_ + - French by `@lesnake <https://github.com/lesnake>`_ + - German by `René <https://github.com/Snooz82>`_ and `Markus <https://github.com/Noordsestern>`_ + - Polish by `Bartłomiej Hirsz <https://github.com/bhirsz>`_ + - Portuguese and Brazilian Portuguese by `Hélio Guilherme <https://github.com/HelioGuilherme66>`_ + - Simplified Chinese by `charis <https://github.com/mawentao119>`_ and `@nixuewei <https://github.com/nixuewei>`_ + - Spanish by Miguel Angel Apolayo Mendoza + - Thai by `Somkiat Puisungnoen <https://github.com/up1>`_ + +- `Oliver Boehmer <https://github.com/oboehmer>`_ provide several contributions: + + - Support to disable the continue-on-failure mode using `robot:stop-on-failure` and + `robot:recursive-stop-on-failure` tags. (`#4303`_) + - Document that failing test setup stops execution even if the continue-on-failure + mode is active. (`#4404`_) + - Default value to `Get From Dictionary` keyword. (`#4398`_) + +- `Fabio Zadrozny <https://github.com/fabioz>`_ provided a pull request speeding up + user keyword execution. (`#4353`_). + +- `@Apteryks <https://github.com/Apteryks>`_ added support to generate deterministic + library documentation by using `SOURCE_DATE_EPOCH`__ environment variable. (`#4262`_) + +__ https://github.com/robotframework/robotframework/issues/4390 +__ https://reproducible-builds.org/specs/source-date-epoch/ + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#4096`_ + - enhancement + - critical + - Multilanguage support for markers used in data + - alpha 1 + * - `#519`_ + - enhancement + - critical + - Given/When/Then should support other languages than English + - alpha 1 + * - `#4295`_ + - enhancement + - high + - Deprecate Python 3.6 + - alpha 1 + * - `#430`_ + - enhancement + - high + - Keyword visibility modifiers for resource files + - alpha 1 + * - `#4303`_ + - enhancement + - high + - Support disabling continue-on-failure mode using `robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags + - alpha 1 + * - `#4366`_ + - enhancement + - high + - Give local keywords precedence over imported keywords in resource files + - alpha 1 + * - `#4368`_ + - enhancement + - high + - New `Test Tags` setting as an alias for `Force Tags` + - alpha 1 + * - `#4373`_ + - enhancement + - high + - Support adding tags for all keywords using `Keyword Tags` setting + - alpha 1 + * - `#4380`_ + - enhancement + - high + - Deprecate setting tags starting with a hyphen like `-tag` using the `[Tags]` setting + - alpha 1 + * - `#4388`_ + - enhancement + - high + - Enhance performance of executing user keywords especially when they fail + - alpha 1 + * - `#4401`_ + - enhancement + - high + - Python 3.11 compatibility + - alpha 1 + * - `#4351`_ + - bug + - medium + - Libdoc can give bad error message if library argument has extension matching resource files + - alpha 1 + * - `#4355`_ + - bug + - medium + - Continuable failures terminate WHILE loops + - alpha 1 + * - `#4357`_ + - bug + - medium + - Parsing model: Creating `TRY` and `WHILE` statements using `from_params` is not possible + - alpha 1 + * - `#4359`_ + - bug + - medium + - Parsing model: `Variable.from_params` doesn't handle list values properly + - alpha 1 + * - `#4381`_ + - bug + - medium + - Parsing errors are recognized as EmptyLines + - alpha 1 + * - `#4384`_ + - bug + - medium + - RPA aliases for settings do not work in suite initialization files + - alpha 1 + * - `#4387`_ + - bug + - medium + - Libdoc: Fix storing information about deprecated keywords to spec files + - alpha 1 + * - `#4408`_ + - bug + - medium + - Collection: `Dictionary Should Contain Item` incorrectly casts values to strings before comparison + - alpha 1 + * - `#4262`_ + - enhancement + - medium + - Honor `SOURCE_DATE_EPOCH` environment variable when generating library documentation + - alpha 1 + * - `#4312`_ + - enhancement + - medium + - Add project URLs to PyPI + - alpha 1 + * - `#4353`_ + - enhancement + - medium + - Performance enhancements to parsing + - alpha 1 + * - `#4371`_ + - enhancement + - medium + - Add `AS` alias for `WITH NAME` in library imports + - alpha 1 + * - `#4379`_ + - enhancement + - medium + - Require space after Given/When/Then prefixes + - alpha 1 + * - `#4398`_ + - enhancement + - medium + - Collections: `Get From Dictionary` should accept a default value + - alpha 1 + * - `#4404`_ + - enhancement + - medium + - Document that failing test setup stops execution even if continue-on-failure mode is active + - alpha 1 + * - `#4413`_ + - enhancement + - medium + - Dictionary related keywords in `Collections` is more script about accepted values + - alpha 1 + * - `#4349`_ + - bug + - low + - User Guide: Example related to YAML variable files is buggy + - alpha 1 + * - `#4358`_ + - bug + - low + - User Guide: Errors in examples related to TRY/EXCEPT + - alpha 1 + * - `#4346`_ + - enhancement + - low + - Enhance documentation of the `--timestampoutputs` option + - alpha 1 + * - `#4372`_ + - enhancement + - low + - Document how to import resource files bundled into Python packages + - alpha 1 + * - `#4394`_ + - bug + - --- + - Error when `--doc` or `--metadata` value matches an existing directory + - alpha 1 + +Altogether 32 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.1>`__. + +.. _#4096: https://github.com/robotframework/robotframework/issues/4096 +.. _#519: https://github.com/robotframework/robotframework/issues/519 +.. _#4295: https://github.com/robotframework/robotframework/issues/4295 +.. _#430: https://github.com/robotframework/robotframework/issues/430 +.. _#4303: https://github.com/robotframework/robotframework/issues/4303 +.. _#4366: https://github.com/robotframework/robotframework/issues/4366 +.. _#4368: https://github.com/robotframework/robotframework/issues/4368 +.. _#4373: https://github.com/robotframework/robotframework/issues/4373 +.. _#4380: https://github.com/robotframework/robotframework/issues/4380 +.. _#4388: https://github.com/robotframework/robotframework/issues/4388 +.. _#4401: https://github.com/robotframework/robotframework/issues/4401 +.. _#4351: https://github.com/robotframework/robotframework/issues/4351 +.. _#4355: https://github.com/robotframework/robotframework/issues/4355 +.. _#4357: https://github.com/robotframework/robotframework/issues/4357 +.. _#4359: https://github.com/robotframework/robotframework/issues/4359 +.. _#4381: https://github.com/robotframework/robotframework/issues/4381 +.. _#4384: https://github.com/robotframework/robotframework/issues/4384 +.. _#4387: https://github.com/robotframework/robotframework/issues/4387 +.. _#4408: https://github.com/robotframework/robotframework/issues/4408 +.. _#4262: https://github.com/robotframework/robotframework/issues/4262 +.. _#4312: https://github.com/robotframework/robotframework/issues/4312 +.. _#4353: https://github.com/robotframework/robotframework/issues/4353 +.. _#4371: https://github.com/robotframework/robotframework/issues/4371 +.. _#4379: https://github.com/robotframework/robotframework/issues/4379 +.. _#4398: https://github.com/robotframework/robotframework/issues/4398 +.. _#4404: https://github.com/robotframework/robotframework/issues/4404 +.. _#4413: https://github.com/robotframework/robotframework/issues/4413 +.. _#4349: https://github.com/robotframework/robotframework/issues/4349 +.. _#4358: https://github.com/robotframework/robotframework/issues/4358 +.. _#4346: https://github.com/robotframework/robotframework/issues/4346 +.. _#4372: https://github.com/robotframework/robotframework/issues/4372 +.. _#4394: https://github.com/robotframework/robotframework/issues/4394 From c9b63cdaf35bc3f63777536ca50bc00b60884b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 21 Jul 2022 21:02:13 +0300 Subject: [PATCH 0129/1592] Updated version to 5.1a2 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 1fa10c9008d..76c15af8484 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1a2.dev1' +VERSION = '5.1a2' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index ec24503c26b..fe31cd73319 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1a2.dev1' +VERSION = '5.1a2' def get_version(naked=False): From ba49b24fbfcd73269cd36f4759e92ab29943e4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 21 Jul 2022 21:05:21 +0300 Subject: [PATCH 0130/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 76c15af8484..a77e1648b9e 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1a2' +VERSION = '5.1a3.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index fe31cd73319..0dcbc2070ee 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1a2' +VERSION = '5.1a3.dev1' def get_version(naked=False): From 12e913fe3ebb4de51542c075426a93b7bcf0e097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 21 Jul 2022 21:06:39 +0300 Subject: [PATCH 0131/1592] Update rf-5.1a2.rst --- doc/releasenotes/rf-5.1a2.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/releasenotes/rf-5.1a2.rst b/doc/releasenotes/rf-5.1a2.rst index 1300ad19eeb..41c458fad82 100644 --- a/doc/releasenotes/rf-5.1a2.rst +++ b/doc/releasenotes/rf-5.1a2.rst @@ -25,7 +25,7 @@ to install the latest available release or use :: - pip install robotframework==5.1a1 + pip install robotframework==5.1a2 to install exactly this version. Alternatively you can download the source distribution from PyPI_ and install it manually. For more details and other From 40753788e360fde9d5b514b21d9b5ba73b3d6b51 Mon Sep 17 00:00:00 2001 From: Daniel Biehl <7069968+d-biehl@users.noreply.github.com> Date: Fri, 29 Jul 2022 23:55:56 +0200 Subject: [PATCH 0132/1592] Change german Variables to plural form (#4416) In german the plural form of the english word "Variables" is "Variablen". --- src/robot/conf/languages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 7bb7962ea5d..2c5e437601e 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -352,7 +352,7 @@ class De(Language): comment_headers = {'Kommentar', 'Kommentare'} library = 'Bibliothek' resource = 'Ressource' - variables = 'Variable' + variables = 'Variablen' documentation = 'Dokumentation' metadata = 'Metadaten' suite_setup = 'Suitevorbereitung' From 7296962ce79f7182d8c519c638bd79e3295b2b02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Aug 2022 18:53:28 +0300 Subject: [PATCH 0133/1592] Bump actions/setup-python from 4.1.0 to 4.2.0 (#4421) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.1.0 to 4.2.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.1.0...v4.2.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 1c830159153..587e8b1d627 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.2.0 with: python-version: '3.10' architecture: 'x64' @@ -51,7 +51,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.2.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index b8d590a97d7..3f835728c47 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.2.0 with: python-version: '3.10' 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@v4.1.0 + uses: actions/setup-python@v4.2.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 550141a0d65..4548ca7f38a 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.2.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 4231f4c3152..bdb287797ca 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.2.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From e9b1aeeb4a0f531a8f9aac049d288a7a48bf12ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 2 Aug 2022 13:53:06 +0300 Subject: [PATCH 0134/1592] cleanup --- src/robot/model/modifier.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/robot/model/modifier.py b/src/robot/model/modifier.py index a79a4c6a711..edc164b22a8 100644 --- a/src/robot/model/modifier.py +++ b/src/robot/model/modifier.py @@ -14,8 +14,8 @@ # limitations under the License. from robot.errors import DataError -from robot.utils import (get_error_details, is_string, - split_args_from_name_or_path, type_name, Importer) +from robot.utils import (get_error_details, Importer, is_string, + split_args_from_name_or_path, type_name) from .visitor import SuiteVisitor @@ -25,31 +25,28 @@ class ModelModifier(SuiteVisitor): def __init__(self, visitors, empty_suite_ok, logger): self._log_error = logger.error self._empty_suite_ok = empty_suite_ok - self._visitors = list(self._yield_visitors(visitors)) + self._visitors = list(self._yield_visitors(visitors, logger)) def visit_suite(self, suite): for visitor in self._visitors: try: suite.visit(visitor) - except: + except Exception: message, details = get_error_details() - self._log_error("Executing model modifier '%s' failed: %s\n%s" - % (type_name(visitor), message, details)) + self._log_error(f"Executing model modifier '{type_name(visitor)}' " + f"failed: {message}\n{details}") if not (suite.test_count or self._empty_suite_ok): - raise DataError("Suite '%s' contains no tests after model modifiers." - % suite.name) + raise DataError(f"Suite '{suite.name}' contains no tests after " + f"model modifiers.") - def _yield_visitors(self, visitors): - # Avoid cyclic imports. Yuck. - from robot.output import LOGGER - - importer = Importer('model modifier', logger=LOGGER) + def _yield_visitors(self, visitors, logger): + importer = Importer('model modifier', logger=logger) for visitor in visitors: - try: - if not is_string(visitor): - yield visitor - else: - name, args = split_args_from_name_or_path(visitor) + if is_string(visitor): + name, args = split_args_from_name_or_path(visitor) + try: yield importer.import_class_or_module(name, args) - except DataError as err: - self._log_error(err.message) + except DataError as err: + logger.error(err.message) + else: + yield visitor From 794ba5dc591d591a857c6459e2db4f8b587fc057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 5 Aug 2022 10:51:24 +0300 Subject: [PATCH 0135/1592] YAML var files: Convert dicts inside lists to DotDicts Fixes #4418. --- atest/robot/variables/yaml_variable_file.robot | 6 ++++++ atest/testdata/variables/valid.yaml | 6 ++++++ atest/testdata/variables/yaml_variable_file.robot | 9 ++++++++- src/robot/variables/filesetter.py | 4 +++- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/atest/robot/variables/yaml_variable_file.robot b/atest/robot/variables/yaml_variable_file.robot index eb415e933b6..ff37cf393dc 100644 --- a/atest/robot/variables/yaml_variable_file.robot +++ b/atest/robot/variables/yaml_variable_file.robot @@ -20,6 +20,12 @@ Non-ASCII strings Dictionary is dot-accessible Check Test Case ${TESTNAME} +Nested dictionary is dot-accessible + Check Test Case ${TESTNAME} + +Dictionary inside list is dot-accessible + Check Test Case ${TESTNAME} + YAML file in PYTHONPATH Check Test Case ${TESTNAME} diff --git a/atest/testdata/variables/valid.yaml b/atest/testdata/variables/valid.yaml index ee8a9d43e7b..8b745504b45 100644 --- a/atest/testdata/variables/valid.yaml +++ b/atest/testdata/variables/valid.yaml @@ -14,3 +14,9 @@ dict: key with spaces: value with spaces nested dict: dict: *alias +list with dict: + - scalar + - key: value + - dict: *alias + nested: + - leaf: value diff --git a/atest/testdata/variables/yaml_variable_file.robot b/atest/testdata/variables/yaml_variable_file.robot index 145cd82e2c9..f82e0b4a832 100644 --- a/atest/testdata/variables/yaml_variable_file.robot +++ b/atest/testdata/variables/yaml_variable_file.robot @@ -35,10 +35,17 @@ Non-ASCII strings Dictionary is dot-accessible ${DICT.a} 1 ${DICT.b} ${2} - ${NESTED DICT.dict} ${DICT} + +Nested dictionary is dot-accessible + ${NESTED DICT.dict} ${EXPECTED DICT} ${NESTED DICT.dict.a} 1 ${NESTED DICT.dict.b} ${2} +Dictionary inside list is dot-accessible + ${LIST WITH DICT[1].key} value + ${LIST WITH DICT[2].dict} ${EXPECTED DICT} + ${LIST WITH DICT[2].nested[0].leaf} value + YAML file in PYTHONPATH ${YAML FILE IN PYTHONPATH} ${TRUE} diff --git a/src/robot/variables/filesetter.py b/src/robot/variables/filesetter.py index b988fe4310f..3569a8c6667 100644 --- a/src/robot/variables/filesetter.py +++ b/src/robot/variables/filesetter.py @@ -85,7 +85,9 @@ def _load_yaml(self, stream): def _dot_dict(self, value): if is_dict_like(value): - value = DotDict((n, self._dot_dict(v)) for n, v in value.items()) + return DotDict((k, self._dot_dict(v)) for k, v in value.items()) + if is_list_like(value): + return [self._dot_dict(v) for v in value] return value From b5498dede5689b70f4d3c276898c1052b0d3d9df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 10 Aug 2022 13:36:01 +0300 Subject: [PATCH 0136/1592] Validate variable values agains custom embedded argument patterns. String values must match the custom pattern. Non-string values are accepted as-is. Fixes #4069 --- atest/robot/keywords/embedded_arguments.robot | 14 +++- .../embedded_arguments_library_keywords.robot | 8 +- atest/testdata/core/keyword_teardown.robot | 4 +- .../keywords/embedded_arguments.robot | 38 ++++++---- .../embedded_arguments_library_keywords.robot | 23 ++++-- .../resources/embedded_args_in_lk_1.py | 19 +++-- .../CreatingTestData/CreatingUserKeywords.rst | 44 ++++++----- .../CreatingTestLibraries.rst | 2 +- src/robot/running/arguments/embedded.py | 75 ++++++++++++++----- src/robot/running/handlers.py | 16 ++-- src/robot/running/librarykeywordrunner.py | 2 +- src/robot/running/testlibraries.py | 4 +- src/robot/running/userkeyword.py | 10 +-- src/robot/running/userkeywordrunner.py | 14 ++-- utest/running/test_userhandlers.py | 38 ++++------ 15 files changed, 190 insertions(+), 121 deletions(-) diff --git a/atest/robot/keywords/embedded_arguments.robot b/atest/robot/keywords/embedded_arguments.robot index 93b8103f887..0d46577b525 100644 --- a/atest/robot/keywords/embedded_arguments.robot +++ b/atest/robot/keywords/embedded_arguments.robot @@ -89,18 +89,24 @@ Grouping Custom Regexp Custom Regexp Matching Variables Check Test Case ${TEST NAME} -Custom Regexp Matching Variables When Regexp Does No Match Them +Non Matching Variable Is Not Accepted With Custom Regexp + Check Test Case ${TEST NAME} + +Partially Matching Variable Is Not Accepted With Custom Regexp + Check Test Case ${TEST NAME} + +Non String Variable Is Accepted With Custom Regexp Check Test Case ${TEST NAME} Regexp Extensions Are Not Supported Check Test Case ${TEST NAME} - Creating Keyword Failed 1 277 + Creating Keyword Failed 1 287 ... Regexp extensions like \${x:(?x)re} are not supported ... Regexp extensions are not allowed in embedded arguments. Invalid Custom Regexp Check Test Case ${TEST NAME} - Creating Keyword Failed 2 280 + Creating Keyword Failed 2 290 ... Invalid \${x:(} Regexp ... Compiling embedded arguments regexp failed: * @@ -137,7 +143,7 @@ Keyword with embedded args cannot be used as "normal" keyword Check Test Case ${TEST NAME} Creating keyword with both normal and embedded arguments fails - Creating Keyword Failed 0 223 + Creating Keyword Failed 0 234 ... Keyword with \${embedded} and normal args is invalid ... Keyword cannot have both normal and embedded arguments. Check Test Case ${TEST NAME} diff --git a/atest/robot/keywords/embedded_arguments_library_keywords.robot b/atest/robot/keywords/embedded_arguments_library_keywords.robot index 2720592bfd1..52f62bf5aa8 100755 --- a/atest/robot/keywords/embedded_arguments_library_keywords.robot +++ b/atest/robot/keywords/embedded_arguments_library_keywords.robot @@ -77,7 +77,13 @@ Grouping Custom Regexp Custom Regexp Matching Variables Check Test Case ${TEST NAME} -Custom Regexp Matching Variables When Regexp Does No Match Them +Non Matching Variable Is Not Accepted With Custom Regexp + Check Test Case ${TEST NAME} + +Partially Matching Variable Is Not Accepted With Custom Regexp + Check Test Case ${TEST NAME} + +Non String Variable Is Accepted With Custom Regexp Check Test Case ${TEST NAME} Embedded Arguments Syntax is Space Sensitive diff --git a/atest/testdata/core/keyword_teardown.robot b/atest/testdata/core/keyword_teardown.robot index bb475b56c80..b324fefd0ac 100644 --- a/atest/testdata/core/keyword_teardown.robot +++ b/atest/testdata/core/keyword_teardown.robot @@ -59,11 +59,11 @@ Failing Keyword with Teardown Log Executed if in nested Teardown [Teardown] Log In Failing UK Teardown -Keyword with Teardown and ${embedded} ${arguments:a.*} +Keyword with Teardown and ${embedded} ${arguments:A.*} Log In UK with ${embedded} ${arguments} [Teardown] Log In Teardown of UK with ${embedded} ${arguments} -Failing Keyword with Teardown and ${embedded} ${arguments:a.*} +Failing Keyword with Teardown and ${embedded} ${arguments:[Aa].*} Fail Expected Failure in UK with ${embedded} ${arguments} [Teardown] Log In Teardown of Failing UK with ${embedded} ${arguments} diff --git a/atest/testdata/keywords/embedded_arguments.robot b/atest/testdata/keywords/embedded_arguments.robot index 6d5b04d4418..03f0d5ef375 100644 --- a/atest/testdata/keywords/embedded_arguments.robot +++ b/atest/testdata/keywords/embedded_arguments.robot @@ -4,6 +4,9 @@ Resource resources/embedded_args_in_uk_2.robot *** Variables *** ${INDENT} ${SPACE * 4} +${foo} foo +${bar} bar +${zap} zap *** Test Cases *** Embedded Arguments In User Keyword Name @@ -77,6 +80,7 @@ Custom Regexp With Curly Braces Today is Tuesday and tomorrow is Wednesday Literal { Brace Literal } Brace + Literal {} Braces Custom Regexp With Escape Chars Custom Regexp With Escape Chars e.g. \\, \\\\ and c:\\temp\\test.txt @@ -91,17 +95,24 @@ Grouping Custom Regexp Should Be Equal ${matches} Cuts-Regexperts Custom Regexp Matching Variables - [Documentation] FAIL 42 != foo - ${foo} ${bar} ${zap} = Create List foo bar zap + [Documentation] FAIL bar != foo I execute "${foo}" I execute "${bar}" with "${zap}" - I execute "${42}" + I execute "${bar}" + +Non Matching Variable Is Not Accepted With Custom Regexp + [Documentation] FAIL ValueError: Embedded argument 'x' got value 'foo' that does not match custom pattern 'bar'. + I execute "${foo}" with "${bar}" + +Partially Matching Variable Is Not Accepted With Custom Regexp + [Documentation] FAIL ValueError: Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. + I execute "${bar[:2]}" with "${zap}" -Custom Regexp Matching Variables When Regexp Does No Match Them +Non String Variable Is Accepted With Custom Regexp + [Documentation] FAIL 42 != foo Result of ${3} + ${-1} is ${2} Result of ${40} - ${-2} is ${42} - ${s42} = Set Variable 42 - I want ${42} and ${s42} as variables + I execute "${42}" Regexp Extensions Are Not Supported [Documentation] FAIL Regexp extensions are not allowed in embedded arguments. @@ -240,10 +251,6 @@ I execute "${x:bar}" with "${y:...}" Result of ${a:\d+} ${operator:[+-]} ${b:\d+} is ${result} Should Be True ${a} ${operator} ${b} == ${result} -I want ${integer:whatever} and ${string:everwhat} as variables - Should Be Equal ${integer} ${42} - Should Be Equal ${string} 42 - Today is ${date:\d{4}-\d{2}-\d{2}} Should Be Equal ${date} 2011-06-21 @@ -257,18 +264,21 @@ Literal ${Curly:\{} Brace Literal ${Curly:\}} Brace Should Be Equal ${Curly} } -Custom Regexp With Escape Chars e.g. ${1E:\\\\}, ${2E:\\\\\\\\} and ${PATH:c:\\\\temp\\.*} +Literal ${Curly:{}} Braces + Should Be Equal ${Curly} {} + +Custom Regexp With Escape Chars e.g. ${1E:\\}, ${2E:\\\\} and ${PATH:c:\\temp\\.*} Should Be Equal ${1E} \\ Should Be Equal ${2E} \\\\ Should Be Equal ${PATH} c:\\temp\\test.txt -Custom Regexp With ${pattern:\\\\\}} +Custom Regexp With ${pattern:\\\}} Should Be Equal ${pattern} \\} -Custom Regexp With ${pattern:\\\\\{} +Custom Regexp With ${pattern:\\\{} Should Be Equal ${pattern} \\{ -Custom Regexp With ${pattern:\\\\{}} +Custom Regexp With ${pattern:\\{}} Should Be Equal ${pattern} \\{} Grouping ${x:Cu(st|ts)(om)?} ${y:Regexp\(?erts\)?} diff --git a/atest/testdata/keywords/embedded_arguments_library_keywords.robot b/atest/testdata/keywords/embedded_arguments_library_keywords.robot index cc22bce7248..c596613a944 100755 --- a/atest/testdata/keywords/embedded_arguments_library_keywords.robot +++ b/atest/testdata/keywords/embedded_arguments_library_keywords.robot @@ -4,6 +4,9 @@ Library resources/embedded_args_in_lk_2.py *** Variables *** ${INDENT} ${SPACE * 4} +${foo} foo +${bar} bar +${zap} zap *** Test Cases *** Embedded Arguments In Library Keyword Name @@ -61,6 +64,7 @@ Custom Regexp With Curly Braces Today is Tuesday and tomorrow is Wednesday Literal { Brace Literal } Brace + Literal {} Braces Custom Regexp With Escape Chars Custom Regexp With Escape Chars e.g. \\, \\\\ and c:\\temp\\test.txt @@ -75,17 +79,24 @@ Grouping Custom Regexp Should Be Equal ${matches} Cuts-Regexperts Custom Regexp Matching Variables - [Documentation] FAIL 42 != foo - ${foo} ${bar} ${zap} = Create List foo bar zap + [Documentation] FAIL bar != foo I execute "${foo}" I execute "${bar}" with "${zap}" - I execute "${42}" + I execute "${bar}" + +Non Matching Variable Is Not Accepted With Custom Regexp + [Documentation] FAIL ValueError: Embedded argument 'x' got value 'foo' that does not match custom pattern 'bar'. + I execute "${foo}" with "${bar}" -Custom Regexp Matching Variables When Regexp Does No Match Them +Partially Matching Variable Is Not Accepted With Custom Regexp + [Documentation] FAIL ValueError: Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. + I execute "${bar[:2]}" with "${zap}" + +Non String Variable Is Accepted With Custom Regexp + [Documentation] FAIL 42 != foo Result of ${3} + ${-1} is ${2} Result of ${40} - ${-2} is ${42} - ${s42} = Set Variable 42 - I want ${42} and ${s42} as variables + I execute "${42}" Escaping Values Given As Embedded Arguments ${name} ${item} = User \${nonex} Selects \\ From Webshop diff --git a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py index 2e33ce3afe0..bb1c312b68e 100755 --- a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py +++ b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py @@ -70,7 +70,7 @@ def today_is(date): should_be_equal(date, "2011-06-21") -@keyword(name=r"Today is ${day1:\w\{6,9\}} and tomorrow is ${day2:\w{6,9}}") +@keyword(name=r"Today is ${day1:\w{6,9}} and tomorrow is ${day2:\w{6,9}}") def today_is_and_tomorrow_is(day1, day2): should_be_equal(day1, "Tuesday") should_be_equal(day2, "Wednesday") @@ -86,32 +86,37 @@ def literal_closing_curly_brace(curly): should_be_equal(curly, "}") -@keyword(name=r"Custom Regexp With Escape Chars e.g. ${1E:\\\\}, " - r"${2E:\\\\\\\\} and ${PATH:c:\\\\temp\\.*}") +@keyword(name="Literal ${Curly:{}} Braces") +def literal_curly_braces(curly): + should_be_equal(curly, "{}") + + +@keyword(name=r"Custom Regexp With Escape Chars e.g. ${1E:\\}, " + r"${2E:\\\\} and ${PATH:c:\\temp\\.*}") def custom_regexp_with_escape_chars(e1, e2, path): should_be_equal(e1, "\\") should_be_equal(e2, "\\\\") should_be_equal(path, "c:\\temp\\test.txt") -@keyword(name=r"Custom Regexp With ${escapes:\\\\\}}") +@keyword(name=r"Custom Regexp With ${escapes:\\\}}") def custom_regexp_with_escapes_1(escapes): should_be_equal(escapes, r'\}') -@keyword(name=r"Custom Regexp With ${escapes:\\\\\{}") +@keyword(name=r"Custom Regexp With ${escapes:\\\{}") def custom_regexp_with_escapes_2(escapes): should_be_equal(escapes, r'\{') -@keyword(name=r"Custom Regexp With ${escapes:\\\\{}}") +@keyword(name=r"Custom Regexp With ${escapes:\\{}}") def custom_regexp_with_escapes_3(escapes): should_be_equal(escapes, r'\{}') @keyword(name=r"Grouping ${x:Cu(st|ts)(om)?} ${y:Regexp\(?erts\)?}") def grouping(x, y): - return "%s-%s" % (x, y) + return f'{x}-{y}' @keyword(name="Wrong ${number} of embedded ${args}") diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 47e62f2e33f..465cc96f0d6 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -639,8 +639,7 @@ Being implemented with Python, Robot Framework naturally uses Python's :name:`re` module that has pretty standard `regular expressions syntax`__. This syntax is otherwise fully supported with embedded arguments, but regexp extensions in format `(?...)` cannot be -used. Notice also that matching embedded arguments is done -case-insensitively. If the regular expression syntax is invalid, +used. If the regular expression syntax is invalid, creating the keyword fails with an error visible in `test execution errors`__. @@ -652,26 +651,32 @@ to escape characters that have a special meaning in regexps (e.g. `\$`) and to form special sequences (e.g. `\d`). Typically in Robot Framework data backslash characters `need to be escaped`__ with another backslash, but that is not required in this context. If there is a need to have a literal -backslash in the pattern, then the backslash must be escaped. +backslash in the pattern, then the backslash must be escaped__ like +`${path:c:\\temp\\.*}`. + +__ escaping_ Possible lone opening and closing curly braces in the pattern must be escaped -like `${open:\}}` and `${close:\{}`. If there are matching braces like -`${two digits:\d{2}}`, escaping is not needed. Escaping only opening or +like `${open:\{}` and `${close:\}}`. If there are matching braces like +`${digits:\d{2}}`, escaping is not needed. Escaping only opening or closing brace is not allowed. -.. warning:: Prior to Robot Framework 3.2 it was mandatory to escape all - closing curly braces in the pattern like `${two digits:\d{2\}}`. - This syntax is unfortunately not supported by Robot Framework 3.2 - or newer and keywords using it must be updated when upgrading. +.. note:: Prior to Robot Framework 3.2, it was mandatory to escape all + closing curly braces in the pattern like `${digits:\d{2\}}`. + This syntax is unfortunately not supported by Robot Framework 3.2 + or newer and keywords using it must be updated when upgrading. + +.. note:: Prior to Robot Framework 5.1, using literal backslashes in the pattern + required double escaping them like `${path:c:\\\\temp\\\\.*}`. + Patterns using literal backslashes need to be updated when upgrading. Using variables with custom embedded argument regular expressions ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' -Whenever custom embedded argument regular expressions are used, Robot +When custom embedded argument regular expressions are used, Robot Framework automatically enhances the specified regexps so that they -match variables in addition to the text matching the pattern. This -means that it is always possible to use variables with keywords having -embedded arguments. For example, the following test case would pass +match variables in addition to the text matching the pattern. +For example, the following test case would pass using the keywords from the earlier example. .. sourcecode:: robotframework @@ -681,14 +686,15 @@ using the keywords from the earlier example. *** Test Cases *** Example - I type ${1} + ${2} Today is ${DATE} + I type ${1} + ${2} + +If the value of the variable is a string, it must match the custom regular +expression. Non-string values are accepted without validation. -A drawback of variables automatically matching custom regular -expressions is that it is possible that the value the keyword gets -does not actually match the specified regexp. For example, variable -`${DATE}` in the above example could contain any value and -:name:`Today is ${DATE}` would still match the same keyword. +.. note:: Validating string values against custom regular expressions is new + in Robot Framework 5.1. Earlier all variable values were accepted + without validation. __ http://en.wikipedia.org/wiki/Regular_expression __ `Embedded arguments matching too much`_ diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 6f227310cd2..24929d323d2 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1799,7 +1799,7 @@ This example uses annotations: .. sourcecode:: python @keyword('Add ${quantity:\d+} copies of ${item} to cart') - def add_copies_to_cart(quantity: int, item): + def add_copies_to_cart(quantity: int, item: str): # ... __ `Specifying argument types using function annotations`_ diff --git a/src/robot/running/arguments/embedded.py b/src/robot/running/arguments/embedded.py index 0bc5ea36577..9c7b2f6cf78 100644 --- a/src/robot/running/arguments/embedded.py +++ b/src/robot/running/arguments/embedded.py @@ -16,17 +16,37 @@ import re from robot.errors import DataError -from robot.utils import get_error_message +from robot.utils import get_error_message, is_string from robot.variables import VariableIterator class EmbeddedArguments: - def __init__(self, name): - if '${' in name: - self.name, self.args = EmbeddedArgumentParser().parse(name) - else: - self.name, self.args = None, [] + def __init__(self, name=None, args=(), custom_patterns=None): + self.name = name + self.args = args + self.custom_patterns = custom_patterns + + @classmethod + def from_name(cls, name): + return EmbeddedArgumentParser().parse(name) if '${' in name else cls() + + def match(self, name): + return self.name.match(name) + + def map(self, values): + self.validate(values) + return list(zip(self.args, values)) + + def validate(self, values): + if not self.custom_patterns: + return + for arg, value in zip(self.args, values): + if arg in self.custom_patterns and is_string(value): + pattern = self.custom_patterns[arg] + if not re.match(pattern + '$', value): + raise ValueError(f"Embedded argument '{arg}' got value '{value}' " + f"that does not match custom pattern '{pattern}'.") def __bool__(self): return self.name is not None @@ -42,46 +62,61 @@ class EmbeddedArgumentParser: def parse(self, string): args = [] + custom_patterns = {} name_regexp = ['^'] for before, variable, string in VariableIterator(string, identifiers='$'): - name, pattern = self._get_name_and_pattern(variable[2:-1]) + name, pattern, custom = self._get_name_and_pattern(variable[2:-1]) args.append(name) - name_regexp.extend([re.escape(before), '(%s)' % pattern]) + if custom: + custom_patterns[name] = pattern + pattern = self._format_custom_regexp(pattern) + name_regexp.extend([re.escape(before), f'({pattern})']) name_regexp.extend([re.escape(string), '$']) name = self._compile_regexp(name_regexp) if args else None - return name, args + return EmbeddedArguments(name, args, custom_patterns or None) def _get_name_and_pattern(self, name): - if ':' not in name: - return name, self._default_pattern - name, pattern = name.split(':', 1) - return name, self._format_custom_regexp(pattern) + if ':' in name: + name, pattern = name.split(':', 1) + custom = True + else: + pattern = self._default_pattern + custom = False + return name, pattern, custom def _format_custom_regexp(self, pattern): for formatter in (self._regexp_extensions_are_not_allowed, self._make_groups_non_capturing, self._unescape_curly_braces, + self._escape_escapes, self._add_automatic_variable_pattern): pattern = formatter(pattern) return pattern def _regexp_extensions_are_not_allowed(self, pattern): - if not self._regexp_extension.search(pattern): - return pattern - raise DataError('Regexp extensions are not allowed in embedded ' - 'arguments.') + if self._regexp_extension.search(pattern): + raise DataError('Regexp extensions are not allowed in embedded arguments.') + return pattern def _make_groups_non_capturing(self, pattern): return self._regexp_group_start.sub(self._regexp_group_escape, pattern) def _unescape_curly_braces(self, pattern): - def unescaper(match): + # Users must escape possible lone curly braces in patters (e.g. `${x:\{}`) + # or otherwise the variable syntax is invalid. + def unescape(match): backslashes = len(match.group(1)) return '\\' * (backslashes // 2 * 2) + match.group(2) - return self._escaped_curly.sub(unescaper, pattern) + return self._escaped_curly.sub(unescape, pattern) + + def _escape_escapes(self, pattern): + # When keywords are matched, embedded arguments have not yet been + # resolved which means possible escapes are still doubled. We thus + # need to double them in the pattern as well. + return pattern.replace(r'\\', r'\\\\') def _add_automatic_variable_pattern(self, pattern): - return '%s|%s' % (pattern, self._variable_pattern) + return f'{pattern}|{self._variable_pattern}' def _compile_regexp(self, pattern): try: diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py index 687f022825b..97bde6ee218 100644 --- a/src/robot/running/handlers.py +++ b/src/robot/running/handlers.py @@ -284,9 +284,9 @@ def _parse_arguments(self, init_method): class EmbeddedArgumentsHandler: - def __init__(self, name_regexp, orig_handler): + def __init__(self, embedded, orig_handler): self.arguments = ArgumentSpec() # Show empty argument spec for Libdoc - self.name_regexp = name_regexp + self.embedded = embedded self._orig_handler = orig_handler def __getattr__(self, item): @@ -301,18 +301,18 @@ def library(self, library): self._orig_handler.library = library def matches(self, name): - return self.name_regexp.match(name) is not None + return self.embedded.match(name) is not None def create_runner(self, name): return EmbeddedArgumentsRunner(self, name) def resolve_arguments(self, args, variables=None): - positional = [variables.replace_scalar(a) for a in args] if variables else args - named = {} + if variables: + args = [variables.replace_scalar(a) for a in args] + self.embedded.validate(args) argspec = self._orig_handler.arguments - return argspec.convert(positional, named, self.library.converters, + return argspec.convert(args, named={}, converters=self.library.converters, dry_run=not variables) def __copy__(self): - orig_handler = copy(self._orig_handler) - return EmbeddedArgumentsHandler(self.name_regexp, orig_handler) + return EmbeddedArgumentsHandler(self.embedded, copy(self._orig_handler)) diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index ed9540c8f09..4aa820991cd 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -131,7 +131,7 @@ class EmbeddedArgumentsRunner(LibraryKeywordRunner): def __init__(self, handler, name): super().__init__(handler, name) - self._embedded_args = handler.name_regexp.match(name).groups() + self._embedded_args = handler.embedded.match(name).groups() def _run(self, context, args): if args: diff --git a/src/robot/running/testlibraries.py b/src/robot/running/testlibraries.py index 4c2c3ee5944..fadc280fe6e 100644 --- a/src/robot/running/testlibraries.py +++ b/src/robot/running/testlibraries.py @@ -313,10 +313,10 @@ def _create_handler(self, handler_name, handler_method): return Handler(self, handler_name, handler_method) def _get_possible_embedded_args_handler(self, handler): - embedded = EmbeddedArguments(handler.name) + embedded = EmbeddedArguments.from_name(handler.name) if embedded: self._validate_embedded_count(embedded, handler.arguments) - return EmbeddedArgumentsHandler(embedded.name, handler), True + return EmbeddedArgumentsHandler(embedded, handler), True return handler, False def _validate_embedded_count(self, embedded, arguments): diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index 2f58e0c9809..d6ec16aa9c2 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -53,7 +53,7 @@ def __init__(self, resource, source_type=RESOURCE_FILE_TYPE): def _create_handler(self, kw): if kw.error: raise DataError(kw.error) - embedded = EmbeddedArguments(kw.name) + embedded = EmbeddedArguments.from_name(kw.name) if not embedded: return UserKeywordHandler(kw, self.name) if kw.args: @@ -78,7 +78,6 @@ def __init__(self, keyword, libname): self.tags = keyword.tags self.arguments = UserKeywordArgumentParser().parse(tuple(keyword.args), self.longname) - self._kw = keyword self.timeout = keyword.timeout self.body = keyword.body self.return_value = tuple(keyword.return_) @@ -103,13 +102,12 @@ def create_runner(self, name): class EmbeddedArgumentsHandler(UserKeywordHandler): def __init__(self, keyword, libname, embedded): - UserKeywordHandler.__init__(self, keyword, libname) + super().__init__(keyword, libname) self.keyword = keyword - self.embedded_name = embedded.name - self.embedded_args = embedded.args + self.embedded = embedded def matches(self, name): - return self.embedded_name.match(name) is not None + return self.embedded.match(name) is not None def create_runner(self, name): return EmbeddedArgumentsRunner(self, name) diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 47096695b22..0898c0c475a 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -19,7 +19,7 @@ ExecutionPassed, ExecutionStatus, PassExecution, ReturnFromKeyword, UserKeywordExecutionFailed, VariableError) from robot.result import Keyword as KeywordResult -from robot.utils import getshortdoc, DotDict, prepr, split_tags_from_doc +from robot.utils import DotDict, getshortdoc, prepr, split_tags_from_doc from robot.variables import is_list_variable, VariableAssignment from .arguments import DefaultValue @@ -245,18 +245,16 @@ def _dry_run(self, context, args, result): class EmbeddedArgumentsRunner(UserKeywordRunner): def __init__(self, handler, name): - UserKeywordRunner.__init__(self, handler, name) - match = handler.embedded_name.match(name) - if not match: - raise ValueError('Does not match given name') - self.embedded_args = list(zip(handler.embedded_args, match.groups())) + super().__init__(handler, name) + self.embedded_args = handler.embedded.match(name).groups() def _resolve_arguments(self, args, variables=None): # Validates that no arguments given. self.arguments.resolve(args, variables) if not variables: return [] - return [(n, variables.replace_scalar(v)) for n, v in self.embedded_args] + embedded = [variables.replace_scalar(e) for e in self.embedded_args] + return self._handler.embedded.map(embedded) def _set_arguments(self, embedded_args, context): variables = context.variables @@ -265,7 +263,7 @@ def _set_arguments(self, embedded_args, context): context.output.trace(lambda: self._trace_log_args_message(variables)) def _trace_log_args_message(self, variables): - args = ['${%s}' % arg for arg, _ in self.embedded_args] + args = [f'${{{arg}}}' for arg in self._handler.embedded.args] return self._format_trace_log_args_message(args, variables) def _get_result(self, kw, assignment, variables): diff --git a/utest/running/test_userhandlers.py b/utest/running/test_userhandlers.py index bfb9d2188fe..a32167b5535 100644 --- a/utest/running/test_userhandlers.py +++ b/utest/running/test_userhandlers.py @@ -47,7 +47,7 @@ def __init__(self, name, args=[]): def EAT(name, args=[]): handler = HandlerDataMock(name, args) - embedded = EmbeddedArguments(name) + embedded = EmbeddedArguments.from_name(name) return EmbeddedArgumentsHandler(handler, 'resource', embedded) @@ -57,63 +57,57 @@ def setUp(self): self.tmp1 = EAT('User selects ${item} from list') self.tmp2 = EAT('${x} * ${y} from "${z}"') - def test_no_embedded_args(self): - assert_true(not EmbeddedArguments('No embedded args here')) - assert_true(EmbeddedArguments('${Yes} embedded args here')) + def test_truthy(self): + assert_true(EmbeddedArguments.from_name('${Yes} embedded args here')) + assert_true(not EmbeddedArguments.from_name('No embedded args here')) def test_get_embedded_arg_and_regexp(self): - assert_equal(self.tmp1.embedded_args, ['item']) - assert_equal(self.tmp1.embedded_name.pattern, + assert_equal(self.tmp1.embedded.args, ['item']) + assert_equal(self.tmp1.embedded.name.pattern, '^User\\ selects\\ (.*?)\\ from\\ list$') assert_equal(self.tmp1.name, 'User selects ${item} from list') def test_get_multiple_embedded_args_and_regexp(self): - assert_equal(self.tmp2.embedded_args, ['x', 'y', 'z']) + assert_equal(self.tmp2.embedded.args, ['x', 'y', 'z']) quote = '"' if sys.version_info[:2] >= (3, 7) else '\\"' - assert_equal(self.tmp2.embedded_name.pattern, + assert_equal(self.tmp2.embedded.name.pattern, '^(.*?)\\ \\*\\ (.*?)\\ from\\ {0}(.*?){0}$'.format(quote)) - def test_create_runner_when_no_match(self): - assert_raises(ValueError, self.tmp1.create_runner, 'Not matching') - def test_create_runner_with_one_embedded_arg(self): runner = self.tmp1.create_runner('User selects book from list') - assert_equal(runner.embedded_args, [('item', 'book')]) + assert_equal(runner.embedded_args, ('book',)) assert_equal(runner.name, 'User selects book from list') assert_equal(runner.longname, 'resource.User selects book from list') runner = self.tmp1.create_runner('User selects radio from list') - assert_equal(runner.embedded_args, [('item', 'radio')]) + assert_equal(runner.embedded_args, ('radio',)) assert_equal(runner.name, 'User selects radio from list') assert_equal(runner.longname, 'resource.User selects radio from list') def test_create_runner_with_many_embedded_args(self): runner = self.tmp2.create_runner('User * book from "list"') - assert_equal(runner.embedded_args, - [('x', 'User'), ('y', 'book'), ('z', 'list')]) + assert_equal(runner.embedded_args, ('User', 'book', 'list')) def test_create_runner_with_empty_embedded_arg(self): runner = self.tmp1.create_runner('User selects from list') - assert_equal(runner.embedded_args, [('item', '')]) + assert_equal(runner.embedded_args, ('',)) def test_create_runner_with_special_characters_in_embedded_args(self): runner = self.tmp2.create_runner('Janne & Heikki * "enjoy" from """') - assert_equal(runner.embedded_args, - [('x', 'Janne & Heikki'), ('y', '"enjoy"'), ('z', '"')]) + assert_equal(runner.embedded_args, ('Janne & Heikki', '"enjoy"', '"')) def test_embedded_args_without_separators(self): template = EAT('This ${does}${not} work so well') runner = template.create_runner('This doesnot work so well') - assert_equal(runner.embedded_args, [('does', ''), ('not', 'doesnot')]) + assert_equal(runner.embedded_args, ('', 'doesnot')) def test_embedded_args_with_separators_in_values(self): template = EAT('This ${could} ${work}-${OK}') runner = template.create_runner("This doesn't really work---") - assert_equal(runner.embedded_args, - [('could', "doesn't"), ('work', 'really work'), ('OK', '--')]) + assert_equal(runner.embedded_args, ("doesn't", 'really work', '--')) def test_creating_runners_is_case_insensitive(self): runner = self.tmp1.create_runner('User SELECts book frOm liST') - assert_equal(runner.embedded_args, [('item', 'book')]) + assert_equal(runner.embedded_args, ('book',)) assert_equal(runner.name, 'User SELECts book frOm liST') assert_equal(runner.longname, 'resource.User SELECts book frOm liST') From 766ec100309fb6a824507507f944d3c767c4b1a0 Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Wed, 10 Aug 2022 15:14:24 +0200 Subject: [PATCH 0137/1592] Added Ukrainian (#4427) Translation by @Sunshine0000000. See #4390. --- src/robot/conf/languages.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 2c5e437601e..5d4c378ca8c 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -516,6 +516,41 @@ class Pl(Language): arguments = 'Argumenty' bdd_prefixes = {'Zakładając', 'Zakładając, że', 'Mając', 'Jeżeli', 'Jeśli', 'Gdy', 'Kiedy', 'Wtedy', 'Oraz', 'I', 'Ale'} + +class Uk(Language): + """Ukrainian""" + setting_headers = {'Налаштування', 'Налаштування', 'Налаштування', 'Налаштування'} + variable_headers = {'Змінна', 'Змінні', 'Змінних', 'Змінних'} + test_case_headers = {'Тест-кейс', 'Тест-кейси', 'Тест-кейсів', 'Тест-кейси'} + task_headers = {'Завдання', 'Завадання', 'Завдань', 'Завдань'} + keyword_headers = {'Ключове слово', 'Ключових слова', 'Ключових слів', 'Ключових слова'} + comment_headers = {'Коментувати', 'Коментувати', 'Коментувати', 'Коментарів'} + library = 'Бібліотека' + resource = 'Ресурс' + variables = 'Змінна' + documentation = 'Документація' + metadata = 'Метадані' + suite_setup = 'Налаштування Suite' + suite_teardown = 'Розбірка Suite' + test_setup = 'Налаштування тесту' + test_teardown = 'Розбирання тестy' + test_template = 'Тестовий шаблон' + test_timeout = 'Час тестування' + test_tags = 'Тестові теги' + task_setup = 'Налаштування завдання' + task_teardown = 'Розбір завдання' + task_template = 'Шаблон завдання' + task_timeout = 'Час очікування завдання' + task_tags = 'Теги завдань' + keyword_tags = 'Теги ключових слів' + tags = 'Теги' + setup = 'Встановлення' + teardown = 'Cпростовувати пункт за пунктом' + template = 'Шаблон' + timeout = 'Час вийшов' + arguments = 'Аргументи' + bdd_prefixes = {'Дано', 'Коли', 'Тоді', 'Та', 'Але'} + class Es(Language): """Spanish""" From 9613ddbb5d3c0fa90f124adbf4d916e67567e976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 10 Aug 2022 17:17:44 +0300 Subject: [PATCH 0138/1592] Minor cleanup --- src/robot/model/itemlist.py | 31 ++++++++++++++++--------------- utest/model/test_itemlist.py | 30 +++++++++++++++--------------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index b948cfaa1bb..695da536a7c 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -16,6 +16,8 @@ from collections.abc import MutableSequence from functools import total_ordering +from robot.utils import type_name + @total_ordering class ItemList(MutableSequence): @@ -40,9 +42,8 @@ def _check_type_and_set_attrs(self, *items): common_attrs = self._common_attrs or {} for item in items: if not isinstance(item, self._item_class): - raise TypeError("Only %s objects accepted, got %s." - % (self._item_class.__name__, - item.__class__.__name__)) + raise TypeError(f'Only {type_name(self._item_class)} objects ' + f'accepted, got {type_name(item)}.') for attr in common_attrs: setattr(item, attr, common_attrs[attr]) return items @@ -71,9 +72,9 @@ def __iter__(self): index += 1 def __getitem__(self, index): - if not isinstance(index, slice): - return self._items[index] - return self._create_new_from(self._items[index]) + if isinstance(index, slice): + return self._create_new_from(self._items[index]) + return self._items[index] def _create_new_from(self, items): # Cannot pass common_attrs directly to new object because all @@ -100,12 +101,12 @@ def __len__(self): return len(self._items) def __str__(self): - return '[%s]' % ', '.join(repr(item) for item in self) + return str(list(self)) def __repr__(self): - return '%s(item_class=%s, items=%s)' % (type(self).__name__, - self._item_class.__name__, - self._items) + class_name = type(self).__name__ + item_name = self._item_class.__name__ + return f'{class_name}(item_class={item_name}, items={self._items})' def count(self, item): return self._items.count(item) @@ -133,21 +134,21 @@ def _is_compatible(self, other): def __lt__(self, other): if not isinstance(other, ItemList): - raise TypeError('Cannot order ItemList and %s' % type(other).__name__) + raise TypeError(f'Cannot order ItemList and {type_name(other)}.') if not self._is_compatible(other): - raise TypeError('Cannot order incompatible ItemLists') + raise TypeError('Cannot order incompatible ItemLists.') return self._items < other._items def __add__(self, other): if not isinstance(other, ItemList): - raise TypeError('Cannot add ItemList and %s' % type(other).__name__) + raise TypeError(f'Cannot add ItemList and {type_name(other)}.') if not self._is_compatible(other): - raise TypeError('Cannot add incompatible ItemLists') + raise TypeError('Cannot add incompatible ItemLists.') return self._create_new_from(self._items + other._items) def __iadd__(self, other): if isinstance(other, ItemList) and not self._is_compatible(other): - raise TypeError('Cannot add incompatible ItemLists') + raise TypeError('Cannot add incompatible ItemLists.') self.extend(other) return self diff --git a/utest/model/test_itemlist.py b/utest/model/test_itemlist.py index 74613a88715..7a089be1a38 100644 --- a/utest/model/test_itemlist.py +++ b/utest/model/test_itemlist.py @@ -58,13 +58,13 @@ def test_insert(self): def test_only_matching_types_can_be_added(self): assert_raises_with_msg(TypeError, - 'Only int objects accepted, got str.', + 'Only integer objects accepted, got string.', ItemList(int).append, 'not integer') assert_raises_with_msg(TypeError, - 'Only int objects accepted, got Object.', + 'Only integer objects accepted, got Object.', ItemList(int).extend, [Object()]) assert_raises_with_msg(TypeError, - 'Only Object objects accepted, got int.', + 'Only Object objects accepted, got integer.', ItemList(Object).insert, 0, 42) def test_common_attrs(self): @@ -146,7 +146,7 @@ def test_setitem_slice(self): def test_setitem_slice_invalid_type(self): assert_raises_with_msg(TypeError, - 'Only int objects accepted, got float.', + 'Only integer objects accepted, got float.', ItemList(int).__setitem__, slice(0), [1, 1.1]) def test_delitem(self): @@ -312,9 +312,9 @@ def test_comparisons(self): def test_compare_incompatible(self): assert_false(ItemList(int) == ItemList(str)) assert_false(ItemList(int) == ItemList(int, {'a': 1})) - assert_raises_with_msg(TypeError, 'Cannot order incompatible ItemLists', + assert_raises_with_msg(TypeError, 'Cannot order incompatible ItemLists.', ItemList(int).__gt__, ItemList(str)) - assert_raises_with_msg(TypeError, 'Cannot order incompatible ItemLists', + assert_raises_with_msg(TypeError, 'Cannot order incompatible ItemLists.', ItemList(int).__gt__, ItemList(int, {'a': 1})) def test_comparisons_with_other_objects(self): @@ -325,11 +325,11 @@ def test_comparisons_with_other_objects(self): assert_true(items != 123) assert_true(items != [1, 2, 3]) assert_true(items != (1, 2, 3)) - assert_raises_with_msg(TypeError, 'Cannot order ItemList and int', + assert_raises_with_msg(TypeError, 'Cannot order ItemList and integer.', items.__gt__, 1) - assert_raises_with_msg(TypeError, 'Cannot order ItemList and list', + assert_raises_with_msg(TypeError, 'Cannot order ItemList and list.', items.__lt__, [1, 2, 3]) - assert_raises_with_msg(TypeError, 'Cannot order ItemList and tuple', + assert_raises_with_msg(TypeError, 'Cannot order ItemList and tuple.', items.__ge__, (1, 2, 3)) def test_add(self): @@ -338,13 +338,13 @@ def test_add(self): def test_add_incompatible(self): assert_raises_with_msg(TypeError, - 'Cannot add ItemList and list', + 'Cannot add ItemList and list.', ItemList(int).__add__, []) assert_raises_with_msg(TypeError, - 'Cannot add incompatible ItemLists', + 'Cannot add incompatible ItemLists.', ItemList(int).__add__, ItemList(str)) assert_raises_with_msg(TypeError, - 'Cannot add incompatible ItemLists', + 'Cannot add incompatible ItemLists.', ItemList(int).__add__, ItemList(int, {'a': 1})) def test_iadd(self): @@ -358,14 +358,14 @@ def test_iadd(self): def test_iadd_incompatible(self): items = ItemList(int, items=[1, 2]) - assert_raises_with_msg(TypeError, 'Cannot add incompatible ItemLists', + assert_raises_with_msg(TypeError, 'Cannot add incompatible ItemLists.', items.__iadd__, ItemList(str)) - assert_raises_with_msg(TypeError, 'Cannot add incompatible ItemLists', + assert_raises_with_msg(TypeError, 'Cannot add incompatible ItemLists.', items.__iadd__, ItemList(int, {'a': 1})) def test_iadd_wrong_type(self): assert_raises_with_msg(TypeError, - 'Only int objects accepted, got str.', + 'Only integer objects accepted, got string.', ItemList(int).__iadd__, ['a', 'b', 'c']) def test_mul(self): From 35b48814ad623ad9737d1bad019514cf81b0f8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 12 Aug 2022 14:57:23 +0300 Subject: [PATCH 0139/1592] Add Language.code and Language.name convenience propertys. Also make matching languages by name space insensitive and cleanup related code. Related to #4390. --- src/robot/conf/languages.py | 32 ++++++++++++++++++++++---------- utest/api/test_languages.py | 29 +++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 5d4c378ca8c..abe405271ca 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -16,7 +16,7 @@ import inspect import os.path -from robot.utils import getdoc, is_string, Importer +from robot.utils import getdoc, is_string, Importer, normalize class Languages: @@ -48,7 +48,7 @@ def _get_languages(self, languages): available = self._get_available_languages() returned = [] for lang in languages: - normalized = lang.lower().replace('-', '') + normalized = normalize(lang, ignore='-') if normalized in available: returned.append(available[normalized]) else: @@ -67,9 +67,9 @@ def _resolve_languages(self, languages): def _get_available_languages(self): available = {} for lang in Language.__subclasses__(): - available[lang.__name__.lower()] = lang + available[normalize(lang.__name__)] = lang if lang.__doc__: - available[lang.__doc__.lower()] = lang + available[normalize(lang.__doc__)] = lang return available def _import_languages(self, lang): @@ -126,18 +126,30 @@ class Language: def from_name(cls, name): """Return langauge class based on given `name`. - Name is matched both against the class name (language short name) - and possible docstring (full language name). Matching is case-insensitive - and hyphen (`-`) is ignored to support, for example, `PT-BR`. + Name can either be a language name (e.g. 'Finnish' or 'Brazilian Portuguese') + or a language code (e.g. 'fi' or 'pt-BR'). Matching is case and space + insensitive and the hyphen is ignored when matching language codes. Raises `ValueError` if no matching langauge is found. """ - normalized = name.lower().replace('-', '') + normalized = normalize(name, ignore='-') for subcls in cls.__subclasses__(): - if normalized in (subcls.__name__.lower(), getdoc(subcls).lower()): + if normalized in (normalize(subcls.__name__), + normalize(getdoc(subcls))): return subcls() raise ValueError(f"No language with name '{name}' found.") + @property + def code(self): + name = type(self).__name__ + if len(name) < 3: + return name.lower() + return f'{name[:2].lower()}-{name[2:].upper()}' + + @property + def name(self): + return self.__doc__ or '' + @property def settings(self): return { @@ -378,7 +390,7 @@ class De(Language): class PtBr(Language): - """Portuguese, Brazilian""" + """Brazilian Portuguese""" setting_headers = {'Configuração', 'Configurações'} variable_headers = {'Variável', 'Variáveis'} test_case_headers = {'Caso de Teste', 'Casos de Teste'} diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index 5d05596af55..e9a15469bf0 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -2,22 +2,39 @@ from robot.api import Language from robot.conf.languages import Fi, PtBr -from robot.utils.asserts import assert_raises_with_msg +from robot.utils.asserts import assert_equal, assert_raises_with_msg + + +class TestLanguage(unittest.TestCase): + + def test_one_part_code(self): + assert_equal(Fi().code, 'fi') + + def test_two_part_code(self): + assert_equal(PtBr().code, 'pt-BR') + + def test_name(self): + assert_equal(Fi().name, 'Finnish') + assert_equal(PtBr().name, 'Brazilian Portuguese') class TestFromName(unittest.TestCase): - def test_class_name(self): + def test_code(self): assert isinstance(Language.from_name('fi'), Fi) assert isinstance(Language.from_name('FI'), Fi) - def test_docstring(self): + def test_two_part_code(self): + assert isinstance(Language.from_name('pt-BR'), PtBr) + assert isinstance(Language.from_name('PTBR'), PtBr) + + def test_name(self): assert isinstance(Language.from_name('finnish'), Fi) assert isinstance(Language.from_name('Finnish'), Fi) - def test_hyphen_is_ignored(self): - assert isinstance(Language.from_name('pt-br'), PtBr) - assert isinstance(Language.from_name('PT-BR'), PtBr) + def test_multi_part_name(self): + assert isinstance(Language.from_name('Brazilian Portuguese'), PtBr) + assert isinstance(Language.from_name('brazilianportuguese'), PtBr) def test_no_match(self): assert_raises_with_msg(ValueError, "No language with name 'no match' found.", From 2b7c1e88a20015ba4f549aff969a1eb2262df4b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 13 Aug 2022 22:31:15 +0300 Subject: [PATCH 0140/1592] Expand @{list} used as embedded arg if kw accepts varargs. Fixes #4364. --- .../embedded_arguments_library_keywords.robot | 8 +++++++- .../embedded_arguments_library_keywords.robot | 16 ++++++++++++---- .../keywords/resources/embedded_args_in_lk_1.py | 4 ++-- src/robot/running/arguments/argumentresolver.py | 3 +-- src/robot/running/handlers.py | 7 +++++-- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/atest/robot/keywords/embedded_arguments_library_keywords.robot b/atest/robot/keywords/embedded_arguments_library_keywords.robot index 52f62bf5aa8..8eedaa43d81 100755 --- a/atest/robot/keywords/embedded_arguments_library_keywords.robot +++ b/atest/robot/keywords/embedded_arguments_library_keywords.robot @@ -116,7 +116,13 @@ Embedded argument count must match accepted arguments Optional Non-Embedded Args Are Okay Check Test Case ${TESTNAME} -Star Args With Embedded Args Are Okay +Varargs With Embedded Args Are Okay + Check Test Case ${TESTNAME} + +List variable is expanded when keyword accepts varargs + Check Test Case ${TESTNAME} + +Scalar variable containing list is not expanded when keyword accepts varargs Check Test Case ${TESTNAME} Same name with different regexp works diff --git a/atest/testdata/keywords/embedded_arguments_library_keywords.robot b/atest/testdata/keywords/embedded_arguments_library_keywords.robot index c596613a944..4b62c1297e4 100755 --- a/atest/testdata/keywords/embedded_arguments_library_keywords.robot +++ b/atest/testdata/keywords/embedded_arguments_library_keywords.robot @@ -7,6 +7,7 @@ ${INDENT} ${SPACE * 4} ${foo} foo ${bar} bar ${zap} zap +@{list} first ${2} third *** Test Cases *** Embedded Arguments In Library Keyword Name @@ -146,10 +147,17 @@ Embedded argument count must match accepted arguments Optional Non-Embedded Args Are Okay Optional Non-Embedded Args Are Okay -Star Args With Embedded Args Are Okay - @{ret} = Star Args With Embedded Args are Okay - @{args} = Create List Embedded Okay - Should Be Equal ${ret} ${args} +Varargs With Embedded Args Are Okay + @{ret} = Varargs With Embedded Args are Okay + Should Be Equal ${ret} ${{['Embedded', 'Okay']}} + +List variable is expanded when keyword accepts varargs + @{ret} = Varargs With @{list} Args are Okay + Should Be Equal ${ret} ${{['first', 2, 'third', 'Okay']}} + +Scalar variable containing list is not expanded when keyword accepts varargs + @{ret} = Varargs With ${list} Args are Okay + Should Be Equal ${ret} ${{[['first', 2, 'third'], 'Okay']}} Same name with different regexp works It is a car diff --git a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py index bb1c312b68e..06fc0c3ecd3 100755 --- a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py +++ b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py @@ -129,8 +129,8 @@ def optional_args_are_okay(nonembedded=1, okay=2, indeed=3): pass -@keyword(name="Star Args With ${embedded} Args Are ${okay}") -def star_args_are_okay(*args): +@keyword(name="Varargs With ${embedded} Args Are ${okay}") +def varargs_are_okay(*args): return args diff --git a/src/robot/running/arguments/argumentresolver.py b/src/robot/running/arguments/argumentresolver.py index 3bd092755a5..75b4edf8066 100644 --- a/src/robot/running/arguments/argumentresolver.py +++ b/src/robot/running/arguments/argumentresolver.py @@ -32,8 +32,7 @@ def __init__(self, argspec, resolve_named=True, def resolve(self, arguments, variables=None): positional, named = self._named_resolver.resolve(arguments, variables) - positional, named = self._variable_replacer.replace(positional, named, - variables) + positional, named = self._variable_replacer.replace(positional, named, variables) positional, named = self._dict_to_kwargs.handle(positional, named) self._argument_validator.validate(positional, named, dryrun=variables is None) diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py index 97bde6ee218..aff633c718d 100644 --- a/src/robot/running/handlers.py +++ b/src/robot/running/handlers.py @@ -307,10 +307,13 @@ def create_runner(self, name): return EmbeddedArgumentsRunner(self, name) def resolve_arguments(self, args, variables=None): + argspec = self._orig_handler.arguments if variables: - args = [variables.replace_scalar(a) for a in args] + if argspec.var_positional: + args = variables.replace_list(args) + else: + args = [variables.replace_scalar(a) for a in args] self.embedded.validate(args) - argspec = self._orig_handler.arguments return argspec.convert(args, named={}, converters=self.library.converters, dry_run=not variables) From 48dbec3332f5162d1ea83921b85e092f64cc744b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 14 Aug 2022 12:11:35 +0300 Subject: [PATCH 0141/1592] Fine to Language.name, add docstrings. --- src/robot/conf/languages.py | 30 ++++++++++++++++++++++++------ utest/api/test_languages.py | 8 ++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index abe405271ca..bdd1bf6f478 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -90,6 +90,14 @@ def __iter__(self): class Language: + """Base class for language definitions. + + New translations can be added by extending this class and setting class + attributes listed below. + + Language :attr:`code` is got based on the class name and :attr:`name` + based on the docstring. + """ setting_headers = set() variable_headers = set() test_case_headers = set() @@ -141,14 +149,24 @@ def from_name(cls, name): @property def code(self): - name = type(self).__name__ - if len(name) < 3: - return name.lower() - return f'{name[:2].lower()}-{name[2:].upper()}' + """Language code like 'fi' or 'pt-BR'. + + Got based on the class name. If the class name is two characters (or less), + the code is just the name in lower case. If it is longer, a hyphen is added + remainder of the class name is upper-cased. + """ + code = type(self).__name__.lower() + if len(code) < 3: + return code + return f'{code[:2]}-{code[2:].upper()}' @property def name(self): - return self.__doc__ or '' + """Language name like 'Finnish' or 'Brazilian Portuguese'. + + Got from the first line of the class docstring. + """ + return getdoc(self).splitlines()[0] @property def settings(self): @@ -528,7 +546,7 @@ class Pl(Language): arguments = 'Argumenty' bdd_prefixes = {'Zakładając', 'Zakładając, że', 'Mając', 'Jeżeli', 'Jeśli', 'Gdy', 'Kiedy', 'Wtedy', 'Oraz', 'I', 'Ale'} - + class Uk(Language): """Ukrainian""" setting_headers = {'Налаштування', 'Налаштування', 'Налаштування', 'Налаштування'} diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index e9a15469bf0..f8b2e78c1a2 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -17,6 +17,14 @@ def test_name(self): assert_equal(Fi().name, 'Finnish') assert_equal(PtBr().name, 'Brazilian Portuguese') + def test_name_with_multiline_docstring(self): + class X(Language): + """Language Name + + Other lines are ignored. + """ + assert_equal(X().name, 'Language Name') + class TestFromName(unittest.TestCase): From 4cc1e2a6c521d186052066a9e0e4f90d4dfed552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 15 Aug 2022 15:14:50 +0300 Subject: [PATCH 0142/1592] Document that singular section headers are deprecated. Also update examples not to use singular headers. Fixes #4431. --- .../CreatingTestData/ControlStructures.rst | 12 ++++---- .../src/CreatingTestData/TestDataSyntax.rst | 29 ++++++++----------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index 128d4fa48a3..1ea95950906 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -303,7 +303,7 @@ the number of loop-variables (excluding the index variable): .. sourcecode:: robotframework - *** Test Case *** + *** Test Cases *** FOR-IN-ENUMERATE with two values per iteration FOR ${index} ${en} ${fi} IN ENUMERATE ... cat kissa @@ -317,7 +317,7 @@ will become a Python tuple containing the index and the iterated value: .. sourcecode:: robotframework - *** Test Case *** + *** Test Cases *** FOR-IN-ENUMERATE with one loop variable FOR ${x} IN ENUMERATE @{LIST} Length Should Be ${x} 2 @@ -801,7 +801,7 @@ equivalent: .. sourcecode:: robotframework - *** Keyword *** + *** Keywords *** Normal IF IF $condition1 Keyword argument @@ -818,7 +818,7 @@ The inline `IF` syntax supports also `ELSE` and `ELSE IF` branches: .. sourcecode:: robotframework - *** Keyword *** + *** Keywords *** Inline IF/ELSE IF $condition Keyword argument ELSE Another Keyword @@ -840,7 +840,7 @@ assignment is used and no branch is run, the variable gets value `None`. .. sourcecode:: robotframework - *** Keyword *** + *** Keywords *** Inline IF/ELSE with assignment ${var} = IF $condition Keyword argument ELSE Another Keyword @@ -862,7 +862,7 @@ as `FOR-IN-ENUMERATE loop`_, `named-only arguments with user keywords`_ and .. sourcecode:: robotframework - *** Keyword *** + *** Keywords *** Log items [Arguments] @{items} ${log_values}=True IF not ${items} diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index d387a5f2dbc..3af47cb464d 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -77,10 +77,13 @@ called tables, listed below: Different sections are recognized by their header row. The recommended header format is `*** Settings ***`, but the header is case-insensitive, surrounding spaces are optional, and the number of asterisk characters can -vary as long as there is one asterisk in the beginning. In addition to using -the plural format, also singular variants like `Setting` and `Test Case` are -accepted. In other words, also `*setting` would be recognized as a section -header. +vary as long as there is at least one asterisk in the beginning. For example, +also `*settings` would be recognized as a section header. + +Robot Framework also supports the singular form with headers like +`*** Setting ***,` but that support is deprecated. There are no visible +deprecation warnings yet, but warnings will emitted in the future and +singular headers will eventually not be supported at all. The header row can contain also other data than the actual section header. The extra data must be separated from the section header using the data @@ -91,14 +94,6 @@ purposes. This is especially useful when creating test cases using the Possible data before the first section is ignored. -.. note:: Section names used to be space-insensitive, but that was deprecated - in Robot Framework 3.1 and trying to use something like `TestCases` - or `S e t t i n g s` causes an error in Robot Framework 3.2. - -.. note:: Prior to Robot Framework 3.1, all unrecognized sections were silently - ignored but nowadays they cause an error. `Comments` sections can - be used if sections not containing actual test data are needed. - Supported file formats ---------------------- @@ -286,9 +281,9 @@ marked using the `code` directive, but Robot Framework supports also # Both space and pipe separated formats are supported. - | *** Keyword *** | | | - | My Keyword | [Arguments] | ${path} | - | | Directory Should Exist | ${path} | + | *** Keywords *** | | | + | My Keyword | [Arguments] | ${path} | + | | Directory Should Exist | ${path} | .. code:: python @@ -507,7 +502,7 @@ __ `Newlines in test data`_ Documentation Here we have documentation for this suite.\nDocumentation is often quite long.\n\nIt can also contain multiple paragraphs. Default Tags default tag 1 default tag 2 default tag 3 default tag 4 default tag 5 - *** Variable *** + *** Variables *** ${STRING} This is a long string. It has multiple sentences. It does not have newlines. ${MULTILINE} This is a long multiline string.\nThis is the second line.\nThis is the third and the last line. @{LIST} this list is quite long and items in it can also be long @@ -529,7 +524,7 @@ __ `Newlines in test data`_ Default Tags default tag 1 default tag 2 default tag 3 ... default tag 4 default tag 5 - *** Variable *** + *** Variables *** ${STRING} This is a long string. ... It has multiple sentences. ... It does not have newlines. From fe934e0f930e65a851ff671a6e9089e3dae24379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 15 Aug 2022 15:55:31 +0300 Subject: [PATCH 0143/1592] Enhance test_or_task utility. Now it supports passing just 'test' in simple cases instead of '{test}'. --- src/robot/utils/misc.py | 17 +++++++++++++---- utest/utils/test_misc.py | 8 ++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/robot/utils/misc.py b/src/robot/utils/misc.py index ae78906bc1e..140640e09e3 100644 --- a/src/robot/utils/misc.py +++ b/src/robot/utils/misc.py @@ -95,14 +95,23 @@ def seq2str2(sequence): def test_or_task(text, rpa=False): - """Replaces `{test}` in `text` with `test` or `task` depending on `rpa`.""" - def replace(match): - test = match.group(1) + """Replace 'test' with 'task' in the given `text` depending on `rpa`. + + If given text is `test`, `test` or `task` is returned directly. Otherwise, + pattern `{test}` is searched from the text and occurrences replaced with + `test` or `task`. + + In both cases matching the word `test` is case-insensitive and the returned + `test` or `task` has exactly same case as the original. + """ + def replace(test): if not rpa: return test upper = [c.isupper() for c in test] return ''.join(c.upper() if up else c for c, up in zip('task', upper)) - return re.sub('{(test)}', replace, text, flags=re.IGNORECASE) + if text.upper() == 'TEST': + return replace(text) + return re.sub('{(test)}', lambda m: replace(m.group(1)), text, flags=re.IGNORECASE) def isatty(stream): diff --git a/utest/utils/test_misc.py b/utest/utils/test_misc.py index 2378d616c46..167678faf09 100644 --- a/utest/utils/test_misc.py +++ b/utest/utils/test_misc.py @@ -117,6 +117,14 @@ def test_multiple_matches(self): assert_equal(test_or_task('Contains {test}, {TEST} and {TesT}', True), 'Contains task, TASK and TasK') + def test_test_without_curlies(self): + for test, task in [('test', 'task'), + ('Test', 'Task'), + ('TEST', 'TASK'), + ('tESt', 'tASk')]: + assert_equal(test_or_task(test, rpa=False), test) + assert_equal(test_or_task(test, rpa=True), task) + if __name__ == "__main__": unittest.main() From 8446a0d232c9aabf3658659f55a288868b2df707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 15 Aug 2022 16:17:33 +0300 Subject: [PATCH 0144/1592] f-strings --- src/robot/result/merger.py | 40 +++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/robot/result/merger.py b/src/robot/result/merger.py index fec380a2363..96d17d91027 100644 --- a/src/robot/result/merger.py +++ b/src/robot/result/merger.py @@ -48,9 +48,8 @@ def start_suite(self, suite): def _find_root(self, name): root = self.result.suite if root.name != name: - raise DataError("Cannot merge outputs containing different root suites. " - "Original suite is '%s' and merged is '%s'." - % (root.name, name)) + raise DataError(f"Cannot merge outputs containing different root suites. " + f"Original suite is '{root.name}' and merged is '{name}'.") return root def _find(self, items, name): @@ -75,8 +74,8 @@ def visit_test(self, test): self.current.tests[index] = test def _create_add_message(self, item, suite=False): - item_type = 'Suite' if suite else test_or_task('{Test}', self.rpa) - prefix = '*HTML* %s added from merged output.' % item_type + item_type = 'Suite' if suite else test_or_task('Test', self.rpa) + prefix = f'*HTML* {item_type} added from merged output.' if not item.message: return prefix return ''.join([prefix, '<hr>', self._html(item.message)]) @@ -87,9 +86,8 @@ def _html(self, message): return html_escape(message) def _create_merge_message(self, new, old): - header = test_or_task('*HTML* <span class="merge">' - '{Test} has been re-executed and results merged.' - '</span>', self.rpa) + header = (f'*HTML* <span class="merge">{test_or_task("Test", self.rpa)} ' + f'has been re-executed and results merged.</span>') return ''.join([ header, '<hr>', @@ -99,21 +97,19 @@ def _create_merge_message(self, new, old): ]) def _format_status_and_message(self, state, test): - message = '%s %s<br>' % (self._status_header(state), - self._status_text(test.status)) + msg = f'{self._status_header(state)} {self._status_text(test.status)}<br>' if test.message: - message += '%s %s<br>' % (self._message_header(state), - self._html(test.message)) - return message + msg += f'{self._message_header(state)} {self._html(test.message)}<br>' + return msg def _status_header(self, state): - return '<span class="%s-status">%s status:</span>' % (state.lower(), state) + return f'<span class="{state.lower()}-status">{state} status:</span>' def _status_text(self, status): - return '<span class="%s">%s</span>' % (status.lower(), status) + return f'<span class="{status.lower()}">{status}</span>' def _message_header(self, state): - return '<span class="%s-message">%s message:</span>' % (state.lower(), state) + return f'<span class="{state.lower()}-message">{state} message:</span>' def _format_old_status_and_message(self, test, merge_header): if not test.message.startswith(merge_header): @@ -126,9 +122,9 @@ def _format_old_status_and_message(self, test, merge_header): ) def _create_skip_message(self, test, new): - msg = test_or_task('*HTML* {Test} has been re-executed and results merged. ' - 'Latter result had %s status and was ignored. Message:\n%s' - % (self._status_text('SKIP'), self._html(new.message))) - if not test.message: - return msg - return '%s<hr>Original message:\n%s' % (msg, self._html(test.message)) + msg = (f'*HTML* {test_or_task("Test", self.rpa)} has been re-executed and ' + f'results merged. Latter result had {self._status_text("SKIP")} status ' + f'and was ignored. Message:\n{self._html(new.message)}') + if test.message: + msg += f'<hr>Original message:\n{self._html(test.message)}' + return msg From 3c1b79c3dac2cdc5852934da22f5c19b633486d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 15 Aug 2022 16:17:48 +0300 Subject: [PATCH 0145/1592] Change test_or_task util to require `rpa` argument. Calling the util without this argument makes no sense and can lead to bugs. --- src/robot/utils/misc.py | 2 +- utest/utils/test_misc.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robot/utils/misc.py b/src/robot/utils/misc.py index 140640e09e3..fbb9c4b28d3 100644 --- a/src/robot/utils/misc.py +++ b/src/robot/utils/misc.py @@ -94,7 +94,7 @@ def seq2str2(sequence): return '[ %s ]' % ' | '.join(safe_str(item) for item in sequence) -def test_or_task(text, rpa=False): +def test_or_task(text: str, rpa: bool): """Replace 'test' with 'task' in the given `text` depending on `rpa`. If given text is `test`, `test` or `task` is returned directly. Otherwise, diff --git a/utest/utils/test_misc.py b/utest/utils/test_misc.py index 167678faf09..b00b6df0339 100644 --- a/utest/utils/test_misc.py +++ b/utest/utils/test_misc.py @@ -99,7 +99,7 @@ class TestTestOrTask(unittest.TestCase): def test_no_match(self): for inp in ['', 'No match', 'No {match}', '{No} {task} {match}']: - assert_equal(test_or_task(inp), inp) + assert_equal(test_or_task(inp, rpa=False), inp) assert_equal(test_or_task(inp, rpa=True), inp) def test_match(self): From b37d1b7ab60840a8b61140073c97790263fb0b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 15 Aug 2022 17:15:48 +0300 Subject: [PATCH 0146/1592] Update data files not to use deprecated syntax - Force Tags -> Test Tags - Default Tags -> remove - Singular headers -> Plural --- atest/testdata/misc/suites/__init__.robot | 4 ++-- atest/testdata/misc/suites/fourth.robot | 7 +++---- atest/testdata/misc/suites/tsuite1.robot | 8 ++++---- atest/testdata/misc/suites/tsuite2.robot | 7 +++---- atest/testdata/misc/suites/tsuite3.robot | 7 +++---- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/atest/testdata/misc/suites/__init__.robot b/atest/testdata/misc/suites/__init__.robot index 92af41594d1..852aef9e2ce 100644 --- a/atest/testdata/misc/suites/__init__.robot +++ b/atest/testdata/misc/suites/__init__.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Suite Setup ${SUITE_SETUP} Suite Teardown ${SUITE_TEARDOWN} ${SUITE_TEARDOWN_ARG} Library OperatingSystem -*** Variable *** +*** Variables *** ${SUITE_SETUP} NONE ${SUITE_TEARDOWN} Log ${SUITE_TEARDOWN_ARG} Default suite teardown diff --git a/atest/testdata/misc/suites/fourth.robot b/atest/testdata/misc/suites/fourth.robot index 68dba578fb4..ae33f47ebb1 100644 --- a/atest/testdata/misc/suites/fourth.robot +++ b/atest/testdata/misc/suites/fourth.robot @@ -1,16 +1,15 @@ -*** Setting *** +*** Settings *** Documentation Normal test cases Suite Setup Log ${SETUP MSG} Suite Teardown Log ${TEARDOWN MSG} -Force Tags f1 -Default Tags d1 d2 +Test Tags f1 Metadata Something My Value *** Variables *** ${SETUP MSG} Suite Setup of Fourth ${TEARDOWN MSG} Suite Teardown of Fourth -*** Test Case *** +*** Test Cases *** Suite4 First [Documentation] FAIL Expected [Tags] t1 diff --git a/atest/testdata/misc/suites/tsuite1.robot b/atest/testdata/misc/suites/tsuite1.robot index 9194c4f5cb9..7c308de6627 100644 --- a/atest/testdata/misc/suites/tsuite1.robot +++ b/atest/testdata/misc/suites/tsuite1.robot @@ -1,10 +1,9 @@ -*** Setting *** +*** Settings *** Documentation Normal test cases -Force Tags f1 -Default Tags d1 d2 +Test Tags f1 Metadata Something My Value -*** Test Case *** +*** Test Cases *** Suite1 First [Tags] t1 Log Suite1_First @@ -15,4 +14,5 @@ Suite1 Second Log Suite1_Second Third In Suite1 + [Tags] d1 d2 Log Suite2_third diff --git a/atest/testdata/misc/suites/tsuite2.robot b/atest/testdata/misc/suites/tsuite2.robot index 2e4e93dc9ef..304018632c1 100644 --- a/atest/testdata/misc/suites/tsuite2.robot +++ b/atest/testdata/misc/suites/tsuite2.robot @@ -1,10 +1,9 @@ -*** Setting *** +*** Settings *** Documentation Normal test cases -Force Tags f1 -Default Tags d1 d2 +Test Tags f1 Metadata Something My Value -*** Test Case *** +*** Test Cases *** Suite2 First [Tags] t1 Log Suite2_First diff --git a/atest/testdata/misc/suites/tsuite3.robot b/atest/testdata/misc/suites/tsuite3.robot index 16aefb43107..e88b4470bc5 100644 --- a/atest/testdata/misc/suites/tsuite3.robot +++ b/atest/testdata/misc/suites/tsuite3.robot @@ -1,11 +1,10 @@ -*** Setting *** +*** Settings *** Documentation Normal test cases Suite Teardown Log Suite Teardown of Tsuite3 -Force Tags f1 -Default Tags d1 d2 +Test Tags f1 Metadata Something My Value -*** Test Case *** +*** Test Cases *** Suite3 First [Tags] t1 Log Suite3_First From 61b634d1ffce462c3d3cc41b029ef7e2c9044d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 15 Aug 2022 17:46:27 +0300 Subject: [PATCH 0147/1592] Get suite doc and metadata from last suite when merging. Metadata from earlier is preserved but values in latter ones have precedence. Fixes #4354. --- atest/robot/rebot/merge.robot | 18 +++++++++++++++++- .../src/ExecutingTestCases/PostProcessing.rst | 15 ++++++++++++--- src/robot/result/merger.py | 4 +++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/atest/robot/rebot/merge.robot b/atest/robot/rebot/merge.robot index 90b0a2acb1b..77cf638e067 100644 --- a/atest/robot/rebot/merge.robot +++ b/atest/robot/rebot/merge.robot @@ -33,6 +33,10 @@ Merge suite setup and teardown [Setup] Should Be Equal ${PREV_TEST_STATUS} PASS Suite setup and teardown should have been merged +Merge suite documentation and metadata + [Setup] Should Be Equal ${PREV_TEST_STATUS} PASS + Suite documentation and metadata should have been merged + Merge re-executed and re-re-executed tests Re-run tests Re-re-run tests @@ -84,7 +88,12 @@ Merge ignores skip *** Keywords *** Run original tests - Create Output With Robot ${ORIGINAL} --variable FAIL:YES --variable LEVEL:WARN ${SUITES} + ${options} = Catenate + ... --variable FAIL:YES + ... --variable LEVEL:WARN + ... --doc "Doc for original run" + ... --metadata Original:True + Create Output With Robot ${ORIGINAL} ${options} ${SUITES} Verify original tests Verify original tests @@ -98,6 +107,8 @@ Verify original tests Re-run tests [Arguments] ${options}= ${options} = Catenate + ... --doc "Doc for re-run" + ... --metadata ReRun:True ... --variable SUITE_SETUP:NoOperation # Affects misc/suites/__init__.robot ... --variable SUITE_TEARDOWN:NONE # -- ;; -- ... --variable SETUP_MSG:Rerun! # Affects misc/suites/fourth.robot @@ -173,6 +184,11 @@ Suite setup and teardown should have been merged Should Be Equal ${SUITE.suites[2].suites[0].setup.name} ${NONE} Should Be Equal ${SUITE.suites[2].suites[0].teardown.name} ${NONE} +Suite documentation and metadata should have been merged + Should Be Equal ${SUITE.doc} Doc for re-run + Should Be Equal ${SUITE.metadata}[ReRun] True + Should Be Equal ${SUITE.metadata}[Original] True + Test add should have been successful Should Be Equal ${SUITE.name} Suites Should Contain Suites ${SUITE} @{ALL SUITES} diff --git a/doc/userguide/src/ExecutingTestCases/PostProcessing.rst b/doc/userguide/src/ExecutingTestCases/PostProcessing.rst index 5af03b9f43c..64e1167a5a9 100644 --- a/doc/userguide/src/ExecutingTestCases/PostProcessing.rst +++ b/doc/userguide/src/ExecutingTestCases/PostProcessing.rst @@ -117,10 +117,19 @@ Merging is done by using :option:`--merge (-R)` option which changes the way how Rebot combines two or more output files. This option itself takes no arguments and all other command line options can be used with it normally:: - rebot --merge --name Example original.xml merged.xml + rebot --merge original.xml merged.xml + rebot --merge --name Example first.xml second.xml third.xml -How merging works in practice is explained in the following sections discussing -its two main use cases. + +When suites are merged, documentation, suite setup and suite teardown are got +from the last merged suite. Suite metadata from all merged suites is preserved +so that values in latter suites have precedence. + +How merging tests works is explained in the following sections discussing +the two main merge use cases. + +.. note:: Getting suite documentation and metadata from merged suites is new in + Robot Framework 5.1. Merging re-executed tests ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/result/merger.py b/src/robot/result/merger.py index 96d17d91027..2f5fa453fa1 100644 --- a/src/robot/result/merger.py +++ b/src/robot/result/merger.py @@ -37,13 +37,15 @@ def start_suite(self, suite): old = self._find(self.current.suites, suite.name) if old is not None: old.starttime = old.endtime = None + old.doc = suite.doc + old.metadata.update(suite.metadata) old.setup = suite.setup old.teardown = suite.teardown self.current = old else: suite.message = self._create_add_message(suite, suite=True) self.current.suites.append(suite) - return bool(old) + return old is not None def _find_root(self, name): root = self.result.suite From 4057ce4d4617c6f5cac3246b406df094d87fe095 Mon Sep 17 00:00:00 2001 From: Anatoly Kolpakov <feroxsnake@me.com> Date: Mon, 15 Aug 2022 18:04:01 +0300 Subject: [PATCH 0148/1592] Add Russian language support (#4417) Part of #4390 --- src/robot/conf/languages.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index bdd1bf6f478..e78e0e23ec2 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -617,6 +617,41 @@ class Es(Language): bdd_prefixes = {'Dado', 'Cuando', 'Entonces', 'Y', 'Pero'} +class Ru(Language): + """Russian""" + setting_headers = {'Настройки'} + variable_headers = {'Переменная', 'Переменные'} + test_case_headers = {'Заголовки тестов'} + task_headers = {'Задача'} + keyword_headers = {'Ключевое слово', 'Ключевые слова'} + comment_headers = {'Комментарий', 'Комментарии'} + library = 'Библиотека' + resource = 'Ресурс' + variables = 'Переменные' + documentation = 'Документация' + metadata = 'Метаданные' + suite_setup = 'Инициализация комплекта тестов' + suite_teardown = 'Завершение комплекта тестов' + test_setup = 'Инициализация теста' + test_teardown = 'Завершение теста' + test_template = 'Шаблон теста' + test_timeout = 'Лимит выполнения теста' + test_tags = 'Теги тестов' + task_setup = 'Инициализация задания' + task_teardown = 'Завершение задания' + task_template = 'Шаблон задания' + task_timeout = 'Лимит задания' + task_tags = 'Метки заданий' + keyword_tags = 'Метки ключевых слов' + tags = 'Метки' + setup = 'Инициализация' + teardown = 'Завершение' + template = 'Шаблон' + timeout = 'Лимит' + arguments = 'Аргументы' + bdd_prefixes = {'Дано', 'Когда', 'Тогда', 'И', 'Но'} + + class ZhCn(Language): """Chinese Simplified""" setting_headers = {'设置'} From a1c6fce9ee6fdc82603ff7dce9ca5a79b2ed6223 Mon Sep 17 00:00:00 2001 From: Oliver Boehmer <oli@spine.de> Date: Tue, 16 Aug 2022 22:45:39 +0200 Subject: [PATCH 0149/1592] Add flags argument to all regexp kws in Builtin and String (#4430) Fixes #4429. --- .../builtin/should_match.robot | 6 ++++ .../string/get_matching_lines.robot | 6 ++++ .../string/get_regexp_matches.robot | 20 ++++++++++++ .../string/remove_from_string.robot | 6 ++++ .../string/replace_string.robot | 4 +++ src/robot/libraries/BuiltIn.py | 23 ++++++++------ src/robot/libraries/String.py | 31 +++++++++++-------- src/robot/utils/__init__.py | 3 +- src/robot/utils/misc.py | 16 ++++++++++ utest/utils/test_misc.py | 26 ++++++++++++++-- 10 files changed, 116 insertions(+), 25 deletions(-) diff --git a/atest/testdata/standard_libraries/builtin/should_match.robot b/atest/testdata/standard_libraries/builtin/should_match.robot index 8d6be1323a4..896b3648093 100644 --- a/atest/testdata/standard_libraries/builtin/should_match.robot +++ b/atest/testdata/standard_libraries/builtin/should_match.robot @@ -49,6 +49,8 @@ Should Match Regexp [Template] Should Match Regexp Foo: 42 \\w+: \\d{2} IGNORE CASE (?i)case + IGNORE CASE case flags=IGNORECASE + abc\nDEFG ab.*fg flags=IGNORECASE|DOTALL ${EMPTY} whatever Something failed No values Should Match Regexp returns match and groups @@ -59,6 +61,9 @@ Should Match Regexp returns match and groups ${match} @{groups} = Should Match Regexp Foo: 42 (xxx) ^(Fo+)([:.;]) (\\d+?) Should Be Equal ${match} Foo: 4 Should Be True @{groups} == ['Foo', ':', '4'] + ${match} @{groups} = Should Match Regexp FOO: 42 (xxx) ^(fo+)([:.;]) (\\d+?) flags=I + Should Be Equal ${match} FOO: 4 + Should Be True @{groups} == ['FOO', ':', '4'] ${match} ${group1} ${group2} = Should Match Regexp Hello, (my) World!!!!! (?ix)^hel+o,\\s # Comment \n\\((my|your)\\)\\ WORLD(!*)$ Should Be Equal ${match} Hello, (my) World!!!!! Should Be Equal ${group1} my @@ -74,3 +79,4 @@ Should Not Match Regexp [Template] Should Not Match Regexp this string does not match this pattern James Bond 007 ^J\\w{4}\\sB[donkey]+ \\d*$ + this string does not match this pattern flags=DOTALL diff --git a/atest/testdata/standard_libraries/string/get_matching_lines.robot b/atest/testdata/standard_libraries/string/get_matching_lines.robot index ab1c91e44ca..20003338937 100644 --- a/atest/testdata/standard_libraries/string/get_matching_lines.robot +++ b/atest/testdata/standard_libraries/string/get_matching_lines.robot @@ -65,6 +65,7 @@ Get Lines Matching Regexp Matching Some Lines Get Lines Matching Regexp With Case-Insensitive Test Get Lines Matching Regexp ${INPUT} (?i).*line.* Line 1\nLine 2\nThird line + Test Get Lines Matching Regexp with Flags ${INPUT} .*line.* IGNORECASE Line 1\nLine 2\nThird line Test Get Lines Matching Regexp ${INPUT} (?i).*LINE Third line Test Get Lines Matching Regexp ${INPUT} .*LINE.* ${EMPTY} @@ -109,6 +110,11 @@ Test Get Lines Matching Regexp ${actual} = Get Lines Matching Regexp ${input} ${pattern} Should Be Equal ${actual} ${expected} +Test Get Lines Matching Regexp With Flags + [Arguments] ${input} ${pattern} ${flags} ${expected} + ${actual} = Get Lines Matching Regexp ${input} ${pattern} flags=${flags} + Should Be Equal ${actual} ${expected} + Test Get Lines Containing Regexp [Arguments] ${input} ${pattern} ${expected} ${actual} = Get Lines Matching Regexp ${input} ${pattern} partial_match=true diff --git a/atest/testdata/standard_libraries/string/get_regexp_matches.robot b/atest/testdata/standard_libraries/string/get_regexp_matches.robot index 852ef92ad5c..8d009e4b6bf 100644 --- a/atest/testdata/standard_libraries/string/get_regexp_matches.robot +++ b/atest/testdata/standard_libraries/string/get_regexp_matches.robot @@ -4,13 +4,18 @@ Library Collections *** Variables *** ${TEXT IN COLUMNS} abcdefg123\tabcdefg123\nabcdefg123\tabcdefg123 +${TEXT IN LINES} ab\ncd\ef\n ${TEXT REPEAT COUNT} 4 ${REGULAR EXPRESSION} abcdefg ${REGULAR EXPRESSION WITH GROUP} ab(?P<group_name>cd)e(?P<group_name2>fg) +${REGULAR EXPRESSION CASEIGNORE} ABCdefg +${REGULAR EXPRESSION WITH GROUP CASEIGNORE} AB(?P<group_name>cd)e(?P<group_name2>fg) +${REGULAR EXPRESSION DOTALL} AB.*ef ${UNMATCH REGULAR EXPRESSION} hijk ${MATCH} abcdefg ${GROUP MATCH} cd ${SECOND GROUP MATCH} fg +${MATCH DOTALL} ab\ncd\ef *** Test Cases *** Get Regexp Matches With No Match @@ -22,6 +27,15 @@ Get Regexp Matches Without Group ${result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION} ${expect_result}= Create List ${MATCH} ${MATCH} ${MATCH} ${MATCH} Should be Equal ${result} ${expect_result} + ${result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION CASEIGNORE} flags=I + ${expect_result}= Create List ${MATCH} ${MATCH} ${MATCH} ${MATCH} + Should be Equal ${result} ${expect_result} + ${result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION CASEIGNORE} flags=IGNORECASE + ${expect_result}= Create List ${MATCH} ${MATCH} ${MATCH} ${MATCH} + Should be Equal ${result} ${expect_result} + ${result}= Get Regexp Matches ${TEXT IN LINES} ${REGULAR EXPRESSION DOTALL} flags=I|dotALL + ${expect_result}= Create List ${MATCH DOTALL} + Should be Equal ${result} ${expect_result} Get Regexp Matches Insert Group Regex Without Groups ${result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION WITH GROUP} @@ -32,11 +46,17 @@ Get Regexp Matches Insert Group Regex With Group Name ${result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION WITH GROUP} group_name ${expect_result}= Create List ${GROUP MATCH} ${GROUP MATCH} ${GROUP MATCH} ${GROUP MATCH} Should be Equal ${result} ${expect_result} + ${result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION WITH GROUP CASEIGNORE} group_name flags=I + ${expect_result}= Create List ${GROUP MATCH} ${GROUP MATCH} ${GROUP MATCH} ${GROUP MATCH} + Should be Equal ${result} ${expect_result} Get Regexp Matches Insert Group Regex With Group Names @{result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION WITH GROUP} group_name group_name2 ${expect_result}= Evaluate [('${GROUP MATCH}', '${SECOND GROUP MATCH}') for i in range(${TEXT REPEAT COUNT})] Should be Equal ${result} ${expect_result} + @{result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION WITH GROUP CASEIGNORE} group_name group_name2 flags=IGNORECASE|S + ${expect_result}= Evaluate [('${GROUP MATCH}', '${SECOND GROUP MATCH}') for i in range(${TEXT REPEAT COUNT})] + Should be Equal ${result} ${expect_result} Get Regexp Matches Insert Group Regex With Group Index ${result}= Get Regexp Matches ${TEXT IN COLUMNS} ${REGULAR EXPRESSION WITH GROUP} 2 diff --git a/atest/testdata/standard_libraries/string/remove_from_string.robot b/atest/testdata/standard_libraries/string/remove_from_string.robot index ff37a6a4df9..5c225a7b168 100644 --- a/atest/testdata/standard_libraries/string/remove_from_string.robot +++ b/atest/testdata/standard_libraries/string/remove_from_string.robot @@ -27,7 +27,13 @@ Remove String Using Regexp Not Found Remove String Using Regexp ${result} = Remove String Using Regexp RobotFramework F.*k Should Be Equal ${result} Robot + ${result} = Remove String Using Regexp RobotFramework f.*k flags=I + Should Be Equal ${result} Robot + ${result} = Remove String Using Regexp RobotFrame\nwork f.*k flags=IGNORECASE|DOTALL + Should Be Equal ${result} Robot Remove String Using Regexp Multiple Patterns ${result} = Remove String Using Regexp RobotFramework o.o r.*w Should Be Equal ${result} RtFork + ${result} = Remove String Using Regexp RobotFrame\nwork o.o f.*w flags=IGNORECASE|DOTALL + Should Be Equal ${result} Rtork diff --git a/atest/testdata/standard_libraries/string/replace_string.robot b/atest/testdata/standard_libraries/string/replace_string.robot index 89edcee8f8c..be11a2a7ee5 100644 --- a/atest/testdata/standard_libraries/string/replace_string.robot +++ b/atest/testdata/standard_libraries/string/replace_string.robot @@ -27,8 +27,12 @@ Replace String With Invalid Count Replace String Using Regexp ${result} = Replace String Using Regexp Robot Framework F.*k Class Should be equal ${result} Robot Class + ${result} = Replace String Using Regexp Robot Framework f.*k Class flags=IGNORECASE + Should be equal ${result} Robot Class ${result} = Replace String Using Regexp Robot Framework o\\w foo 2 Should be equal ${result} Rfoofoo Framework + ${result} = Replace String Using Regexp Robot Framework O\\w foo 2 flags=IGNORECASE + Should be equal ${result} Rfoofoo Framework Replace String Using Regexp With Count 0 ${result} = Replace String Using Regexp Robot Framework F.*k Class 0 diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 8d5277f3210..c69a8b0b38c 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -29,9 +29,10 @@ from robot.utils import (DotDict, escape, format_assign_message, get_error_message, get_time, html_escape, is_falsy, is_integer, is_list_like, is_string, is_truthy, Matcher, normalize, - normalize_whitespace, parse_time, prepr, plural_or_not as s, - RERAISED_EXCEPTIONS, safe_str, secs_to_timestr, seq2str, - split_from_equals, timestr_to_secs, type_name) + normalize_whitespace, parse_re_flags, parse_time, prepr, + plural_or_not as s, RERAISED_EXCEPTIONS, safe_str, + secs_to_timestr, seq2str, split_from_equals, + timestr_to_secs, type_name) from robot.utils.asserts import assert_equal, assert_not_equal from robot.variables import (evaluate_expression, is_dict_variable, is_list_variable, search_variable, @@ -1303,7 +1304,7 @@ def should_match(self, string, pattern, msg=None, values=True, raise AssertionError(self._get_string_msg(string, pattern, msg, values, 'does not match')) - def should_match_regexp(self, string, pattern, msg=None, values=True): + def should_match_regexp(self, string, pattern, msg=None, values=True, flags=None): """Fails if ``string`` does not match ``pattern`` as a regular expression. See the `Regular expressions` section for more information about @@ -1316,10 +1317,13 @@ def should_match_regexp(self, string, pattern, msg=None, values=True): For example, ``^ello$`` only matches the exact string ``ello``. Possible flags altering how the expression is parsed (e.g. - ``re.IGNORECASE``, ``re.MULTILINE``) must be embedded to the + ``re.IGNORECASE``, ``re.MULTILINE``) can be embedded to the pattern like ``(?im)pattern``. The most useful flags are ``i`` (case-insensitive), ``m`` (multiline mode), ``s`` (dotall mode) - and ``x`` (verbose). + and ``x`` (verbose). Alternatively, RobotFramework 5.1 introduced the + optional ``flags`` argument to specify the flags directly, + i.e. ``flags=MULTILINE`` or ``flags=IGNORECASE|DOTALL``. + All valid Python re.XXX flags are supported. If this keyword passes, it returns the portion of the string that matched the pattern. Additionally, the possible captured groups are @@ -1332,6 +1336,7 @@ def should_match_regexp(self, string, pattern, msg=None, values=True): | Should Match Regexp | ${output} | \\\\d{6} | # Output contains six numbers | | Should Match Regexp | ${output} | ^\\\\d{6}$ | # Six numbers and nothing more | | ${ret} = | Should Match Regexp | Foo: 42 | (?i)foo: \\\\d+ | + | ${ret} = | Should Match Regexp | Foo: 42 | foo: \\\\d+ | flags=IGNORECASE | | ${match} | ${group1} | ${group2} = | | ... | Should Match Regexp | Bar: 43 | (Foo|Bar): (\\\\d+) | => @@ -1340,7 +1345,7 @@ def should_match_regexp(self, string, pattern, msg=None, values=True): | ${group1} = 'Bar' | ${group2} = '43' """ - res = re.search(pattern, string) + res = re.search(pattern, string, flags=parse_re_flags(flags)) if res is None: raise AssertionError(self._get_string_msg(string, pattern, msg, values, 'does not match')) @@ -1350,12 +1355,12 @@ def should_match_regexp(self, string, pattern, msg=None, values=True): return [match] + list(groups) return match - def should_not_match_regexp(self, string, pattern, msg=None, values=True): + def should_not_match_regexp(self, string, pattern, msg=None, values=True, flags=None): """Fails if ``string`` matches ``pattern`` as a regular expression. See `Should Match Regexp` for more information about arguments. """ - if re.search(pattern, string) is not None: + if re.search(pattern, string, flags=parse_re_flags(flags)) is not None: raise AssertionError(self._get_string_msg(string, pattern, msg, values, 'matches')) diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index 3835a1bb04b..99e3c973b3d 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -19,10 +19,10 @@ from random import randint from string import ascii_lowercase, ascii_uppercase, digits - from robot.api import logger from robot.api.deco import keyword -from robot.utils import FileReader, is_bytes, is_string, is_truthy, safe_str, type_name +from robot.utils import (FileReader, is_bytes, is_string, is_truthy, + parse_re_flags, safe_str, type_name) from robot.version import get_version @@ -335,7 +335,7 @@ def get_lines_matching_pattern(self, string, pattern, case_insensitive=False): matches = lambda line: fnmatchcase(line, pattern) return self._get_matching_lines(string, matches) - def get_lines_matching_regexp(self, string, pattern, partial_match=False): + def get_lines_matching_regexp(self, string, pattern, partial_match=False, flags=None): """Returns lines of the given ``string`` that match the regexp ``pattern``. See `BuiltIn.Should Match Regexp` for more information about @@ -352,8 +352,9 @@ def get_lines_matching_regexp(self, string, pattern, partial_match=False): If the pattern is empty, it matches only empty lines by default. When partial matching is enabled, empty pattern matches all lines. - Notice that to make the match case-insensitive, you need to prefix - the pattern with case-insensitive flag ``(?i)``. + To make the match case-insensitive, you can prefix + the pattern with case-insensitive flag ``(?i)`` or use the + ``flags=IGNORECASE`` argument (new in RobotFramework 5.1) Lines are returned as one string concatenated back together with newlines. Possible trailing newline is never returned. The @@ -363,15 +364,16 @@ def get_lines_matching_regexp(self, string, pattern, partial_match=False): | ${lines} = | Get Lines Matching Regexp | ${result} | Reg\\\\w{3} example | | ${lines} = | Get Lines Matching Regexp | ${result} | Reg\\\\w{3} example | partial_match=true | | ${ret} = | Get Lines Matching Regexp | ${ret} | (?i)FAIL: .* | + | ${ret} = | Get Lines Matching Regexp | ${ret} | FAIL: .* | flags=IGNORECASE | See `Get Lines Matching Pattern` and `Get Lines Containing String` if you do not need full regular expression powers (and complexity). """ if is_truthy(partial_match): - match = re.compile(pattern).search + match = re.compile(pattern, flags=parse_re_flags(flags)).search else: - match = re.compile(pattern + '$').match + match = re.compile(pattern + '$', flags=parse_re_flags(flags)).match return self._get_matching_lines(string, match) def _get_matching_lines(self, string, matches): @@ -380,7 +382,7 @@ def _get_matching_lines(self, string, matches): logger.info('%d out of %d lines matched' % (len(matching), len(lines))) return '\n'.join(matching) - def get_regexp_matches(self, string, pattern, *groups): + def get_regexp_matches(self, string, pattern, *groups, flags=None): """Returns a list of all non-overlapping matches in the given string. ``string`` is the string to find matches from and ``pattern`` is the @@ -397,6 +399,7 @@ def get_regexp_matches(self, string, pattern, *groups): Examples: | ${no match} = | Get Regexp Matches | the string | xxx | | ${matches} = | Get Regexp Matches | the string | t.. | + | ${matches} = | Get Regexp Matches | the string | T.. | flags=IGNORECASE | | ${one group} = | Get Regexp Matches | the string | t(..) | 1 | | ${named group} = | Get Regexp Matches | the string | t(?P<name>..) | name | | ${two groups} = | Get Regexp Matches | the string | t(.)(.) | 1 | 2 | @@ -406,8 +409,10 @@ def get_regexp_matches(self, string, pattern, *groups): | ${one group} = ['he', 'ri'] | ${named group} = ['he', 'ri'] | ${two groups} = [('h', 'e'), ('r', 'i')] + + The ``flags`` option has been introduced in RobotFramework 5.1. """ - regexp = re.compile(pattern) + regexp = re.compile(pattern, flags=parse_re_flags(flags)) groups = [self._parse_group(g) for g in groups] return [m.group(*groups) for m in regexp.finditer(string)] @@ -441,7 +446,7 @@ def replace_string(self, string, search_for, replace_with, count=-1): count = self._convert_to_integer(count, 'count') return string.replace(search_for, replace_with, count) - def replace_string_using_regexp(self, string, pattern, replace_with, count=-1): + def replace_string_using_regexp(self, string, pattern, replace_with, count=-1, flags=None): """Replaces ``pattern`` in the given ``string`` with ``replace_with``. This keyword is otherwise identical to `Replace String`, but @@ -460,7 +465,7 @@ def replace_string_using_regexp(self, string, pattern, replace_with, count=-1): # re.sub handles 0 and negative counts differently than string.replace if count == 0: return string - return re.sub(pattern, replace_with, string, max(count, 0)) + return re.sub(pattern, replace_with, string, max(count, 0), flags=parse_re_flags(flags)) def remove_string(self, string, *removables): """Removes all ``removables`` from the given ``string``. @@ -486,7 +491,7 @@ def remove_string(self, string, *removables): string = self.replace_string(string, removable, '') return string - def remove_string_using_regexp(self, string, *patterns): + def remove_string_using_regexp(self, string, *patterns, flags=None): """Removes ``patterns`` from the given ``string``. This keyword is otherwise identical to `Remove String`, but @@ -497,7 +502,7 @@ def remove_string_using_regexp(self, string, *patterns): occurrences. """ for pattern in patterns: - string = self.replace_string_using_regexp(string, pattern, '') + string = self.replace_string_using_regexp(string, pattern, '', flags=flags) return string @keyword(types=None) diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 442ffa4f31a..60471147b04 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -49,7 +49,8 @@ from .markupwriters import HtmlWriter, XmlWriter, NullMarkupWriter from .importer import Importer from .match import eq, Matcher, MultiMatcher -from .misc import isatty, plural_or_not, printable_name,seq2str, seq2str2, test_or_task +from .misc import (isatty, parse_re_flags, plural_or_not, printable_name,seq2str, + seq2str2, test_or_task) from .normalizing import normalize, normalize_whitespace, NormalizedDict from .platform import PY_VERSION, PYPY, UNIXY, WINDOWS, RERAISED_EXCEPTIONS from .recommendations import RecommendationFinder diff --git a/src/robot/utils/misc.py b/src/robot/utils/misc.py index fbb9c4b28d3..7ee9c93b06d 100644 --- a/src/robot/utils/misc.py +++ b/src/robot/utils/misc.py @@ -124,3 +124,19 @@ def isatty(stream): return stream.isatty() except ValueError: # Occurs if file is closed. return False + +def parse_re_flags(flags=None): + result = 0 + if not flags: + return result + for flag in flags.split('|'): + try: + re_flag = getattr(re, flag.upper().strip()) + except AttributeError: + raise ValueError(f'Unknown regexp flag: {flag}') + else: + if isinstance(re_flag, re.RegexFlag): + result |= re_flag + else: + raise ValueError(f'Unknown regexp flag: {flag}') + return result diff --git a/utest/utils/test_misc.py b/utest/utils/test_misc.py index b00b6df0339..da02d44c5dd 100644 --- a/utest/utils/test_misc.py +++ b/utest/utils/test_misc.py @@ -1,7 +1,9 @@ +import re import unittest -from robot.utils.asserts import assert_equal -from robot.utils import printable_name, seq2str, plural_or_not, test_or_task +from robot.utils import (parse_re_flags, plural_or_not, printable_name, + seq2str, test_or_task) +from robot.utils.asserts import assert_equal, assert_raises_with_msg class TestSeg2Str(unittest.TestCase): @@ -126,5 +128,25 @@ def test_test_without_curlies(self): assert_equal(test_or_task(test, rpa=True), task) +class TestParseReFlags(unittest.TestCase): + + def test_parse(self): + for inp, exp in [('DOTALL', re.DOTALL), + ('I', re.I), + ('IGNORECASE|dotall', re.IGNORECASE | re.DOTALL), + (' MULTILINE ', re.MULTILINE)]: + assert_equal(parse_re_flags(inp), exp) + + def test_parse_empty(self): + for inp in ['', None]: + assert_equal(parse_re_flags(inp), 0) + + def test_parse_negative(self): + for inp, exp_msg in [('foo', 'Unknown regexp flag: foo'), + ('IGNORECASE|foo', 'Unknown regexp flag: foo'), + ('compile', 'Unknown regexp flag: compile')]: + assert_raises_with_msg(ValueError, exp_msg, parse_re_flags, inp) + + if __name__ == "__main__": unittest.main() From 7e61ad3f67fb7f7213d7e5f04922518e9becf696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 16 Aug 2022 12:36:06 +0300 Subject: [PATCH 0150/1592] Fix Language.name if subclass doesn't have __doc__. --- src/robot/conf/languages.py | 13 +++++++------ utest/api/test_languages.py | 6 ++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index e78e0e23ec2..224905820ec 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -69,7 +69,7 @@ def _get_available_languages(self): for lang in Language.__subclasses__(): available[normalize(lang.__name__)] = lang if lang.__doc__: - available[normalize(lang.__doc__)] = lang + available[normalize(lang.__doc__.splitlines()[0])] = lang return available def _import_languages(self, lang): @@ -141,10 +141,11 @@ def from_name(cls, name): Raises `ValueError` if no matching langauge is found. """ normalized = normalize(name, ignore='-') - for subcls in cls.__subclasses__(): - if normalized in (normalize(subcls.__name__), - normalize(getdoc(subcls))): - return subcls() + for lang in cls.__subclasses__(): + if normalized == normalize(lang.__name__): + return lang() + if lang.__doc__ and normalized == normalize(lang.__doc__.splitlines()[0]): + return lang() raise ValueError(f"No language with name '{name}' found.") @property @@ -166,7 +167,7 @@ def name(self): Got from the first line of the class docstring. """ - return getdoc(self).splitlines()[0] + return self.__doc__.splitlines()[0] if self.__doc__ else '' @property def settings(self): diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index f8b2e78c1a2..c0d90524de2 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -25,6 +25,12 @@ class X(Language): """ assert_equal(X().name, 'Language Name') + def test_name_without_docstring(self): + class X(Language): + pass + X.__doc__ = None + assert_equal(X().name, '') + class TestFromName(unittest.TestCase): From 800e7e60a270d9e997de65388e9b9fb33b23facb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 16 Aug 2022 13:05:14 +0300 Subject: [PATCH 0151/1592] Allow setting language by using Language instances. Also add docstrings for `lang` to public API. --- src/robot/conf/languages.py | 24 ++++++++++++++---------- src/robot/parsing/lexer/lexer.py | 12 ++++++++---- src/robot/parsing/parser/parser.py | 7 +++++-- src/robot/running/builder/builders.py | 13 +++++++++---- utest/parsing/test_lexer.py | 19 ++++++++++++++++--- 5 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 224905820ec..9260a751d27 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -16,7 +16,7 @@ import inspect import os.path -from robot.utils import getdoc, is_string, Importer, normalize +from robot.utils import is_list_like, Importer, normalize class Languages: @@ -48,20 +48,24 @@ def _get_languages(self, languages): available = self._get_available_languages() returned = [] for lang in languages: - normalized = normalize(lang, ignore='-') - if normalized in available: - returned.append(available[normalized]) + if isinstance(lang, Language): + returned.append(lang) else: - returned.extend(self._import_languages(lang)) - return [subclass() for subclass in returned] + normalized = normalize(lang, ignore='-') + if normalized in available: + returned.append(available[normalized]()) + else: + returned.extend(self._import_languages(lang)) + return returned def _resolve_languages(self, languages): if not languages: languages = [] - if is_string(languages): + elif is_list_like(languages): + languages = list(languages) + else: languages = [languages] - if 'en' not in languages: - languages.append('en') + languages.append(En()) return languages def _get_available_languages(self): @@ -80,7 +84,7 @@ def is_language(member): if os.path.exists(lang): lang = os.path.abspath(lang) module = Importer('language file').import_module(lang) - return [value for _, value in inspect.getmembers(module, is_language)] + return [value() for _, value in inspect.getmembers(module, is_language)] def translate_setting(self, name): return self.settings.get(name, name) diff --git a/src/robot/parsing/lexer/lexer.py b/src/robot/parsing/lexer/lexer.py index 12c55d6f781..780a8c084ea 100644 --- a/src/robot/parsing/lexer/lexer.py +++ b/src/robot/parsing/lexer/lexer.py @@ -24,7 +24,6 @@ from .tokens import EOS, END, Token -# FIXME: Documentation for `lang`. def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): """Parses the given source to tokens. @@ -39,6 +38,11 @@ def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): arguments and elsewhere are tokenized. See the :meth:`~robot.parsing.lexer.tokens.Token.tokenize_variables` method for details. + :param lang: Additional languages to be supported during parsing. + Can be a string matching any of the supported language codes or names, + an initialized :class:`~robot.conf.languages.Language` subsclass, + a list containing such strings or instances, or a + :class:`~robot.conf.languages.Languages` instance. Returns a generator that yields :class:`~robot.parsing.lexer.tokens.Token` instances. @@ -51,7 +55,7 @@ def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): def get_resource_tokens(source, data_only=False, tokenize_variables=False, lang=None): """Parses the given source to resource file tokens. - Otherwise same as :func:`get_tokens` but the source is considered to be + Same as :func:`get_tokens` otherwise, but the source is considered to be a resource file. This affects, for example, what settings are valid. """ lexer = Lexer(ResourceFileContext(lang=lang), data_only, tokenize_variables) @@ -62,7 +66,7 @@ def get_resource_tokens(source, data_only=False, tokenize_variables=False, lang= def get_init_tokens(source, data_only=False, tokenize_variables=False, lang=None): """Parses the given source to init file tokens. - Otherwise same as :func:`get_tokens` but the source is considered to be + Same as :func:`get_tokens` otherwise, but the source is considered to be a suite initialization file. This affects, for example, what settings are valid. """ @@ -96,7 +100,7 @@ def _read(self, source): try: with FileReader(source, accept_text=True) as reader: return reader.read() - except: + except Exception: raise DataError(get_error_message()) def get_tokens(self): diff --git a/src/robot/parsing/parser/parser.py b/src/robot/parsing/parser/parser.py index 9bac6461ce5..1100e33f223 100644 --- a/src/robot/parsing/parser/parser.py +++ b/src/robot/parsing/parser/parser.py @@ -38,8 +38,11 @@ def get_model(source, data_only=False, curdir=None, lang=None): When not given, the variable is left as-is. Should only be given only if the model will be executed afterwards. If the model is saved back to disk, resolving ``${CURDIR}`` is typically not a good idea. - # FIXME: docs - :param lang: Additional languages to be supported during parsing + :param lang: Additional languages to be supported during parsing. + Can be a string matching any of the supported language codes or names, + an initialized :class:`~robot.conf.languages.Language` subsclass, + a list containing such strings or instances, or a + :class:`~robot.conf.languages.Languages` instance. Use :func:`get_resource_model` or :func:`get_init_model` when parsing resource or suite initialization files, respectively. diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index b0849f25a73..f5de33fa6b3 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -32,7 +32,7 @@ class TestSuiteBuilder: - Execute the created suite by using its :meth:`~robot.running.model.TestSuite.run` method. The suite can be - can be modified before execution if needed. + modified before execution if needed. - Inspect the suite to see, for example, what tests it has or what tags tests have. This can be more convenient than using the lower level @@ -56,9 +56,14 @@ def __init__(self, included_suites=None, included_extensions=('robot',), :param included_extensions: List of extensions of files to parse. Same as `--extension`. :param rpa: Explicit test execution mode. ``True`` for RPA and - ``False`` for test automation. By default mode is got from data file - headers and possible conflicting headers cause an error. - Same as `--rpa` or `--norpa`. + ``False`` for test automation. By default, mode is got from data file + headers and possible conflicting headers cause an error. + Same as `--rpa` or `--norpa`. + :param lang: Additional languages to be supported during parsing. + Can be a string matching any of the supported language codes or names, + an initialized :class:`~robot.conf.languages.Language` subsclass, + a list containing such strings or instances, or a + :class:`~robot.conf.languages.Languages` instance. :param allow_empty_suite: Specify is it an error if the built suite contains no tests. Same as `--runemptysuite`. diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index eec1a6b39ea..84bb4494ca7 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -4,7 +4,7 @@ from io import StringIO from pathlib import Path -from robot.conf import Languages +from robot.conf import Language, Languages from robot.utils.asserts import assert_equal from robot.parsing import get_tokens, get_init_tokens, get_resource_tokens, Token @@ -2196,11 +2196,24 @@ def _verify(self, data, expected, test=False): class TestLanguageConfig(unittest.TestCase): - def test_lang_as_string(self): + def test_lang_as_code(self): self._test('fi') + self._test('F-I') + + def test_lang_as_name(self): + self._test('Finnish') + self._test('FINNISH') + + def test_lang_as_Language(self): + self._test(Language.from_name('fi')) def test_lang_as_list(self): - self._test(['fi']) + self._test(['fi', Language.from_name('de')]) + self._test([Language.from_name('fi'), 'de']) + + def test_lang_as_tuple(self): + self._test(('f-i', Language.from_name('de'))) + self._test((Language.from_name('fi'), 'de')) def test_lang_as_Languages(self): self._test(Languages('fi')) From cb45cafca66f1551d2c2b05a992d1700f939756a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 16 Aug 2022 18:07:08 +0300 Subject: [PATCH 0152/1592] f-strings --- src/robot/running/builder/parsers.py | 3 +-- src/robot/running/model.py | 14 +++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index c8b76d1d708..b50856ac062 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -142,5 +142,4 @@ def visit_Error(self, node): LOGGER.error(self._format_message(error)) def _format_message(self, token): - return ("Error in file '%s' on line %s: %s" - % (self.source, token.lineno, token.error)) + return f"Error in file '{self.source}' on line {token.lineno}: {token.error}" diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 391313926c6..2d12eb77d34 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -419,9 +419,9 @@ def __init__(self, name, value, source=None, lineno=None, error=None): def report_invalid_syntax(self, message, level='ERROR'): source = self.source or '<unknown>' - line = ' on line %s' % self.lineno if self.lineno is not None else '' - LOGGER.write("Error in file '%s'%s: Setting variable '%s' failed: %s" - % (source, line, self.name, message), level) + line = f' on line {self.lineno}' if self.lineno else '' + LOGGER.write(f"Error in file '{source}'{line}: " + f"Setting variable '{self.name}' failed: {message}", level) class ResourceFile: @@ -502,8 +502,8 @@ class Import: def __init__(self, type, name, args=(), alias=None, source=None, lineno=None): if type not in self.ALLOWED_TYPES: - raise ValueError("Invalid import type '%s'. Should be one of %s." - % (type, seq2str(self.ALLOWED_TYPES, lastsep=' or '))) + raise ValueError(f"Invalid import type '{type}'. Should be one of " + f"{seq2str(self.ALLOWED_TYPES, lastsep=' or ')}.") self.type = type self.name = name self.args = args @@ -521,8 +521,8 @@ def directory(self): def report_invalid_syntax(self, message, level='ERROR'): source = self.source or '<unknown>' - line = ' on line %s' % self.lineno if self.lineno is not None else '' - LOGGER.write("Error in file '%s'%s: %s" % (source, line, message), level) + line = f' on line {self.lineno}' if self.lineno else '' + LOGGER.write(f"Error in file '{source}'{line}: {message}", level) class Imports(model.ItemList): From 545c48db1fc97b0fa4a3fde0774ffc2fe011e763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 17 Aug 2022 00:05:35 +0300 Subject: [PATCH 0153/1592] Enhance docs related to new `flags` argument. Related to #4429. --- src/robot/libraries/BuiltIn.py | 16 +++++++-------- src/robot/libraries/String.py | 37 ++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index c69a8b0b38c..378675505e7 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1316,14 +1316,10 @@ def should_match_regexp(self, string, pattern, msg=None, values=True, flags=None to denote the beginning and end of the string, respectively. For example, ``^ello$`` only matches the exact string ``ello``. - Possible flags altering how the expression is parsed (e.g. - ``re.IGNORECASE``, ``re.MULTILINE``) can be embedded to the - pattern like ``(?im)pattern``. The most useful flags are ``i`` - (case-insensitive), ``m`` (multiline mode), ``s`` (dotall mode) - and ``x`` (verbose). Alternatively, RobotFramework 5.1 introduced the - optional ``flags`` argument to specify the flags directly, - i.e. ``flags=MULTILINE`` or ``flags=IGNORECASE|DOTALL``. - All valid Python re.XXX flags are supported. + Possible flags altering how the expression is parsed (e.g. ``re.IGNORECASE``, + ``re.MULTILINE``) can be given using the ``flags`` argument (e.g. + ``flags=IGNORECASE | MULTILINE``) or embedded to the pattern (e.g. + ``(?im)pattern``). If this keyword passes, it returns the portion of the string that matched the pattern. Additionally, the possible captured groups are @@ -1335,8 +1331,8 @@ def should_match_regexp(self, string, pattern, msg=None, values=True, flags=None Examples: | Should Match Regexp | ${output} | \\\\d{6} | # Output contains six numbers | | Should Match Regexp | ${output} | ^\\\\d{6}$ | # Six numbers and nothing more | - | ${ret} = | Should Match Regexp | Foo: 42 | (?i)foo: \\\\d+ | | ${ret} = | Should Match Regexp | Foo: 42 | foo: \\\\d+ | flags=IGNORECASE | + | ${ret} = | Should Match Regexp | Foo: 42 | (?i)foo: \\\\d+ | | ${match} | ${group1} | ${group2} = | | ... | Should Match Regexp | Bar: 43 | (Foo|Bar): (\\\\d+) | => @@ -1344,6 +1340,8 @@ def should_match_regexp(self, string, pattern, msg=None, values=True, flags=None | ${match} = 'Bar: 43' | ${group1} = 'Bar' | ${group2} = '43' + + The ``flags`` argument is new in Robot Framework 5.1. """ res = re.search(pattern, string, flags=parse_re_flags(flags)) if res is None: diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index 99e3c973b3d..148f41fee33 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -342,7 +342,7 @@ def get_lines_matching_regexp(self, string, pattern, partial_match=False, flags= Python regular expression syntax in general and how to use it in Robot Framework data in particular. - By default lines match only if they match the pattern fully, but + Lines match only if they match the pattern fully by default, but partial matching can be enabled by giving the ``partial_match`` argument a true value. The value is considered true if it is a non-empty string that is not equal to ``false``, ``none`` or @@ -352,9 +352,10 @@ def get_lines_matching_regexp(self, string, pattern, partial_match=False, flags= If the pattern is empty, it matches only empty lines by default. When partial matching is enabled, empty pattern matches all lines. - To make the match case-insensitive, you can prefix - the pattern with case-insensitive flag ``(?i)`` or use the - ``flags=IGNORECASE`` argument (new in RobotFramework 5.1) + Possible flags altering how the expression is parsed (e.g. ``re.IGNORECASE``, + ``re.VERBOSE``) can be given using the ``flags`` argument (e.g. + ``flags=IGNORECASE | VERBOSE``) or embedded to the pattern (e.g. + ``(?ix)pattern``). Lines are returned as one string concatenated back together with newlines. Possible trailing newline is never returned. The @@ -366,9 +367,10 @@ def get_lines_matching_regexp(self, string, pattern, partial_match=False, flags= | ${ret} = | Get Lines Matching Regexp | ${ret} | (?i)FAIL: .* | | ${ret} = | Get Lines Matching Regexp | ${ret} | FAIL: .* | flags=IGNORECASE | - See `Get Lines Matching Pattern` and `Get Lines Containing - String` if you do not need full regular expression powers (and - complexity). + See `Get Lines Matching Pattern` and `Get Lines Containing String` if you + do not need the full regular expression powers (and complexity). + + The ``flags`` argument is new in Robot Framework 5.1. """ if is_truthy(partial_match): match = re.compile(pattern, flags=parse_re_flags(flags)).search @@ -396,6 +398,11 @@ def get_regexp_matches(self, string, pattern, *groups, flags=None): individual group contents. All groups can be given as indexes (starting from 1) and named groups also as names. + Possible flags altering how the expression is parsed (e.g. ``re.IGNORECASE``, + ``re.MULTILINE``) can be given using the ``flags`` argument (e.g. + ``flags=IGNORECASE | MULTILINE``) or embedded to the pattern (e.g. + ``(?im)pattern``). + Examples: | ${no match} = | Get Regexp Matches | the string | xxx | | ${matches} = | Get Regexp Matches | the string | t.. | @@ -410,7 +417,7 @@ def get_regexp_matches(self, string, pattern, *groups, flags=None): | ${named group} = ['he', 'ri'] | ${two groups} = [('h', 'e'), ('r', 'i')] - The ``flags`` option has been introduced in RobotFramework 5.1. + The ``flags`` argument is new in Robot Framework 5.1. """ regexp = re.compile(pattern, flags=parse_re_flags(flags)) groups = [self._parse_group(g) for g in groups] @@ -455,11 +462,18 @@ def replace_string_using_regexp(self, string, pattern, replace_with, count=-1, f information about Python regular expression syntax in general and how to use it in Robot Framework data in particular. + Possible flags altering how the expression is parsed (e.g. ``re.IGNORECASE``, + ``re.MULTILINE``) can be given using the ``flags`` argument (e.g. + ``flags=IGNORECASE | MULTILINE``) or embedded to the pattern (e.g. + ``(?im)pattern``). + If you need to just remove a string see `Remove String Using Regexp`. Examples: | ${str} = | Replace String Using Regexp | ${str} | 20\\\\d\\\\d-\\\\d\\\\d-\\\\d\\\\d | <DATE> | | ${str} = | Replace String Using Regexp | ${str} | (Hello|Hi) | ${EMPTY} | count=1 | + + The ``flags`` argument is new in Robot Framework 5.1. """ count = self._convert_to_integer(count, 'count') # re.sub handles 0 and negative counts differently than string.replace @@ -500,6 +514,13 @@ def remove_string_using_regexp(self, string, *patterns, flags=None): about the regular expression syntax. That keyword can also be used if there is a need to remove only a certain number of occurrences. + + Possible flags altering how the expression is parsed (e.g. ``re.IGNORECASE``, + ``re.MULTILINE``) can be given using the ``flags`` argument (e.g. + ``flags=IGNORECASE | MULTILINE``) or embedded to the pattern (e.g. + ``(?im)pattern``). + + The ``flags`` argument is new in Robot Framework 5.1. """ for pattern in patterns: string = self.replace_string_using_regexp(string, pattern, '', flags=flags) From edcf6e79c63da2edf6bcc01ec986aa70b1868854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 17 Aug 2022 12:07:17 +0300 Subject: [PATCH 0154/1592] Make sure all standard Language classes have name (i.e. docstring) Fixes #4436. --- src/robot/conf/languages.py | 1 + utest/api/test_languages.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 9260a751d27..c844eca8d0e 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -204,6 +204,7 @@ def settings(self): class En(Language): + """English""" setting_headers = {'Settings', 'Setting'} variable_headers = {'Variables', 'Variable'} test_case_headers = {'Test Cases', 'Test Case'} diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index c0d90524de2..723fad86ba9 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -31,6 +31,11 @@ class X(Language): X.__doc__ = None assert_equal(X().name, '') + def test_all_standard_languages_have_code_and_name(self): + for cls in Language.__subclasses__(): + lang = cls() + assert lang.code + assert lang.name class TestFromName(unittest.TestCase): From fc1773cedf425554c3fb00c5ed6baa07e0c5672e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Tue, 16 Aug 2022 21:56:19 +0300 Subject: [PATCH 0155/1592] Rename Language attributes The most important change is to allow only one form (plural) for section headers. There are also separate field for all the different BDD prefixes and consistent use of `_setting` postfix for settings. Relates to #4390 --- .../parsing/translations/custom/custom.py | 66 +- .../translations/finnish/resource.resource | 6 +- .../parsing/translations/finnish/tasks.robot | 2 +- src/robot/conf/languages.py | 1072 +++++++++-------- 4 files changed, 608 insertions(+), 538 deletions(-) diff --git a/atest/testdata/parsing/translations/custom/custom.py b/atest/testdata/parsing/translations/custom/custom.py index f6357b65e8f..2ff93fb6665 100644 --- a/atest/testdata/parsing/translations/custom/custom.py +++ b/atest/testdata/parsing/translations/custom/custom.py @@ -2,34 +2,38 @@ class Custom(Language): - setting_headers = {'H S'} - variable_headers = {'H v'} - test_case_headers = {'h te'} - task_headers = {'H Ta'} - keyword_headers = {'H k'} - comment_headers = {'h C'} - library = 'L' - resource = 'R' - variables = 'V' - documentation = 'D' - metadata = 'M' - suite_setup = 'S S' - suite_teardown = 'S T' - test_setup = 't s' - task_setup = 'ta s' - test_teardown = 'T tea' - task_teardown = 'TA tea' - test_template = 'T TEM' - task_template = 'TA TEM' - test_timeout = 't ti' - task_timeout = 'ta ti' - test_tags = 'T Ta' - task_tags = 'Ta Ta' - keyword_tags = 'K T' - setup = 'S' - teardown = 'TeA' - template = 'Tem' - tags = 'Ta' - timeout = 'ti' - arguments = 'A' - bdd_prefixes = set() + settings_header = 'H S' + variables_header = 'H v' + test_cases_header = 'h te' + tasks_header = 'H Ta' + keywords_header = 'H k' + comments_header = 'h C' + library_setting = 'L' + resource_setting = 'R' + variables_setting = 'V' + documentation_setting = 'D' + metadata_setting = 'M' + suite_setup_setting = 'S S' + suite_teardown_setting = 'S T' + test_setup_setting = 't s' + task_setup_setting = 'ta s' + test_teardown_setting = 'T tea' + task_teardown_setting = 'TA tea' + test_template_setting = 'T TEM' + task_template_setting = 'TA TEM' + test_timeout_setting = 't ti' + task_timeout_setting = 'ta ti' + test_tags_setting = 'T Ta' + task_tags_setting = 'Ta Ta' + keyword_tags_setting = 'K T' + setup_setting = 'S' + teardown_setting = 'TeA' + template_setting = 'Tem' + tags_setting = 'Ta' + timeout_setting = 'ti' + arguments_setting = 'A' + given_prefix = set() + when_prefix = set() + then_prefix = set() + and_prefix = set() + but_prefix = set() diff --git a/atest/testdata/parsing/translations/finnish/resource.resource b/atest/testdata/parsing/translations/finnish/resource.resource index 1a23730ed2a..4114f10384c 100644 --- a/atest/testdata/parsing/translations/finnish/resource.resource +++ b/atest/testdata/parsing/translations/finnish/resource.resource @@ -1,9 +1,9 @@ -*** Asetus *** +*** Asetukset *** Dokumentaatio Example documentation. -*** Muuttuja *** +*** Muuttujat *** ${RESOURCE FILE} variable in resource file -*** Avainsana *** +*** Avainsanat *** Keyword In Resource No Operation diff --git a/atest/testdata/parsing/translations/finnish/tasks.robot b/atest/testdata/parsing/translations/finnish/tasks.robot index b54df69fcf0..f0201dbe562 100644 --- a/atest/testdata/parsing/translations/finnish/tasks.robot +++ b/atest/testdata/parsing/translations/finnish/tasks.robot @@ -18,7 +18,7 @@ Task with settings [Aikaraja] NONE Log Nothing to see here -*** Avainsana *** +*** Avainsanat *** Task Setup No Operation diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index c844eca8d0e..3ac53e5e2a6 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -23,21 +23,22 @@ class Languages: def __init__(self, languages): self.languages = self._get_languages(languages) - self.setting_headers = set() - self.variable_headers = set() - self.test_case_headers = set() - self.task_headers = set() - self.keyword_headers = set() - self.comment_headers = set() + # The English singular forms are added for backwards compatibility + self.setting_headers = {'Setting'} + self.variable_headers = {'Variable'} + self.test_case_headers = {'Test Case'} + self.task_headers = {'Task'} + self.keyword_headers = {'Keyword'} + self.comment_headers = {'Comment'} self.settings = {} self.bdd_prefixes = set() for lang in self.languages: - self.setting_headers |= {h.title() for h in lang.setting_headers} - self.variable_headers |= {h.title() for h in lang.variable_headers} - self.test_case_headers |= {h.title() for h in lang.test_case_headers} - self.task_headers |= {h.title() for h in lang.task_headers} - self.keyword_headers |= {h.title() for h in lang.keyword_headers} - self.comment_headers |= {h.title() for h in lang.comment_headers} + self.setting_headers.add(lang.settings_header.title()) + self.variable_headers.add(lang.variables_header.title()) + self.test_case_headers.add(lang.test_cases_header.title()) + self.task_headers.add(lang.tasks_header.title()) + self.keyword_headers.add(lang.keywords_header.title()) + self.comment_headers.add(lang.comments_header.title()) self.settings.update( {name.title(): lang.settings[name] for name in lang.settings if name} ) @@ -102,47 +103,51 @@ class Language: Language :attr:`code` is got based on the class name and :attr:`name` based on the docstring. """ - setting_headers = set() - variable_headers = set() - test_case_headers = set() - task_headers = set() - keyword_headers = set() - comment_headers = set() - library = None - resource = None - variables = None - documentation = None - metadata = None - suite_setup = None - suite_teardown = None - test_setup = None - task_setup = None - test_teardown = None - task_teardown = None - test_template = None - task_template = None - test_timeout = None - task_timeout = None - test_tags = None - task_tags = None - keyword_tags = None - tags = None - setup = None - teardown = None - template = None - timeout = None - arguments = None - bdd_prefixes = set() + settings_header = "" + variables_header = "" + test_cases_header = "" + tasks_header = "" + keywords_header = "" + comments_header = "" + library_setting = None + resource_setting = None + variables_setting = None + documentation_setting = None + metadata_setting = None + suite_setup_setting = None + suite_teardown_setting = None + test_setup_setting = None + task_setup_setting = None + test_teardown_setting = None + task_teardown_setting = None + test_template_setting = None + task_template_setting = None + test_timeout_setting = None + task_timeout_setting = None + test_tags_setting = None + task_tags_setting = None + keyword_tags_setting = None + tags_setting = None + setup_setting = None + teardown_setting = None + template_setting = None + timeout_setting = None + arguments_setting = None + given_prefix = set() + when_prefix = set() + then_prefix = set() + and_prefix = set() + but_prefix = set() @classmethod def from_name(cls, name): - """Return langauge class based on given `name`. + """Return language class based on given `name`. Name can either be a language name (e.g. 'Finnish' or 'Brazilian Portuguese') or a language code (e.g. 'fi' or 'pt-BR'). Matching is case and space insensitive and the hyphen is ignored when matching language codes. - Raises `ValueError` if no matching langauge is found. + Raises `ValueError` if no matching language is found. """ normalized = normalize(name, ignore='-') for lang in cls.__subclasses__(): @@ -176,518 +181,579 @@ def name(self): @property def settings(self): return { - self.library: En.library, - self.resource: En.resource, - self.variables: En.variables, - self.documentation: En.documentation, - self.metadata: En.metadata, - self.suite_setup: En.suite_setup, - self.suite_teardown: En.suite_teardown, - self.test_setup: En.test_setup, - self.task_setup: En.task_setup, - self.test_teardown: En.test_teardown, - self.task_teardown: En.task_teardown, - self.test_template: En.test_template, - self.task_template: En.task_template, - self.test_timeout: En.test_timeout, - self.task_timeout: En.task_timeout, - self.test_tags: En.test_tags, - self.task_tags: En.task_tags, - self.keyword_tags: En.keyword_tags, - self.tags: En.tags, - self.setup: En.setup, - self.teardown: En.teardown, - self.template: En.template, - self.timeout: En.timeout, - self.arguments: En.arguments, + self.library_setting: En.library_setting, + self.resource_setting: En.resource_setting, + self.variables_setting: En.variables_setting, + self.documentation_setting: En.documentation_setting, + self.metadata_setting: En.metadata_setting, + self.suite_setup_setting: En.suite_setup_setting, + self.suite_teardown_setting: En.suite_teardown_setting, + self.test_setup_setting: En.test_setup_setting, + self.task_setup_setting: En.task_setup_setting, + self.test_teardown_setting: En.test_teardown_setting, + self.task_teardown_setting: En.task_teardown_setting, + self.test_template_setting: En.test_template_setting, + self.task_template_setting: En.task_template_setting, + self.test_timeout_setting: En.test_timeout_setting, + self.task_timeout_setting: En.task_timeout_setting, + self.test_tags_setting: En.test_tags_setting, + self.task_tags_setting: En.task_tags_setting, + self.keyword_tags_setting: En.keyword_tags_setting, + self.tags_setting: En.tags_setting, + self.setup_setting: En.setup_setting, + self.teardown_setting: En.teardown_setting, + self.template_setting: En.template_setting, + self.timeout_setting: En.timeout_setting, + self.arguments_setting: En.arguments_setting, } + @property + def bdd_prefixes(self): + return self.given_prefix | self.when_prefix | self.then_prefix \ + | self.and_prefix | self.but_prefix + class En(Language): """English""" - setting_headers = {'Settings', 'Setting'} - variable_headers = {'Variables', 'Variable'} - test_case_headers = {'Test Cases', 'Test Case'} - task_headers = {'Tasks', 'Task'} - keyword_headers = {'Keywords', 'Keyword'} - comment_headers = {'Comments', 'Comment'} - library = 'Library' - resource = 'Resource' - variables = 'Variables' - documentation = 'Documentation' - metadata = 'Metadata' - suite_setup = 'Suite Setup' - suite_teardown = 'Suite Teardown' - test_setup = 'Test Setup' - task_setup = 'Task Setup' - test_teardown = 'Test Teardown' - task_teardown = 'Task Teardown' - test_template = 'Test Template' - task_template = 'Task Template' - test_timeout = 'Test Timeout' - task_timeout = 'Task Timeout' - test_tags = 'Test Tags' - task_tags = 'Task Tags' - keyword_tags = 'Keyword Tags' - setup = 'Setup' - teardown = 'Teardown' - template = 'Template' - tags = 'Tags' - timeout = 'Timeout' - arguments = 'Arguments' - bdd_prefixes = {'Given', 'When', 'Then', 'And', 'But'} + settings_header = 'Settings' + variables_header = 'Variables' + test_cases_header = 'Test Cases' + tasks_header = 'Tasks' + keywords_header = 'Keywords' + comments_header = 'Comments' + library_setting = 'Library' + resource_setting = 'Resource' + variables_setting = 'Variables' + documentation_setting = 'Documentation' + metadata_setting = 'Metadata' + suite_setup_setting = 'Suite Setup' + suite_teardown_setting = 'Suite Teardown' + test_setup_setting = 'Test Setup' + task_setup_setting = 'Task Setup' + test_teardown_setting = 'Test Teardown' + task_teardown_setting = 'Task Teardown' + test_template_setting = 'Test Template' + task_template_setting = 'Task Template' + test_timeout_setting = 'Test Timeout' + task_timeout_setting = 'Task Timeout' + test_tags_setting = 'Test Tags' + task_tags_setting = 'Task Tags' + keyword_tags_setting = 'Keyword Tags' + setup_setting = 'Setup' + teardown_setting = 'Teardown' + template_setting = 'Template' + tags_setting = 'Tags' + timeout_setting = 'Timeout' + arguments_setting = 'Arguments' + given_prefix = {'Given'} + when_prefix = {'When'} + then_prefix = {'Then'} + and_prefix = {'And'} + but_prefix = {'But'} class Cs(Language): """Czech""" - setting_headers = {'Nastavení'} - variable_headers = {'Proměnná', 'Proměnné'} - test_case_headers = {'Testovací případ', 'Testovací případy'} - task_headers = {'Úlohy', 'Úloha'} - keyword_headers = {'Klíčové slovo', 'Klíčová slova'} - comment_headers = {'Komentáře', 'Komentář'} - library = 'Knihovna' - resource = 'Zdroj' - variables = 'Proměnná' - documentation = 'Dokumentace' - metadata = 'Metadata' - suite_setup = 'Příprava sady' - suite_teardown = 'Ukončení sady' - test_setup = 'Příprava testu' - test_teardown = 'Ukončení testu' - test_template = 'Šablona testu' - test_timeout = 'Časový limit testu' - test_tags = 'Štítky testů' - task_setup = 'Příprava úlohy' - task_teardown = 'Ukončení úlohy' - task_template = 'Šablona úlohy' - task_timeout = 'Časový limit úlohy' - task_tags = 'Štítky úloh' - keyword_tags = 'Štítky klíčových slov' - tags = 'Štítky' - setup = 'Příprava' - teardown = 'Ukončení' - template = 'Šablona' - timeout = 'Časový limit' - arguments = 'Argumenty' - bdd_prefixes = {'Pokud', 'Když', 'Pak', 'A', 'Ale'} + settings_header = 'Nastavení' + variables_header = 'Proměnné' + test_cases_header = 'Testovací případy' + tasks_header = 'Úlohy' + keywords_header = 'Klíčová slova' + comments_header = 'Komentáře' + library_setting = 'Knihovna' + resource_setting = 'Zdroj' + variables_setting = 'Proměnná' + documentation_setting = 'Dokumentace' + metadata_setting = 'Metadata' + suite_setup_setting = 'Příprava sady' + suite_teardown_setting = 'Ukončení sady' + test_setup_setting = 'Příprava testu' + test_teardown_setting = 'Ukončení testu' + test_template_setting = 'Šablona testu' + test_timeout_setting = 'Časový limit testu' + test_tags_setting = 'Štítky testů' + task_setup_setting = 'Příprava úlohy' + task_teardown_setting = 'Ukončení úlohy' + task_template_setting = 'Šablona úlohy' + task_timeout_setting = 'Časový limit úlohy' + task_tags_setting = 'Štítky úloh' + keyword_tags_setting = 'Štítky klíčových slov' + tags_setting = 'Štítky' + setup_setting = 'Příprava' + teardown_setting = 'Ukončení' + template_setting = 'Šablona' + timeout_setting = 'Časový limit' + arguments_setting = 'Argumenty' + given_prefix = {'Pokud'} + when_prefix = {'Když'} + then_prefix = {'Pak'} + and_prefix = {'A'} + but_prefix = {'Ale'} class Nl(Language): """Dutch""" - setting_headers = {'Instelling', 'Instellingen'} - variable_headers = {'Variabele', 'Variabelen'} - test_case_headers = {'Testgeval', 'Testgevallen'} - task_headers = {'Taak', 'Taken'} - keyword_headers = {'Sleutelwoord', 'Sleutelwoorden'} - comment_headers = {'Opmerking', 'Opmerkingen'} - library = 'Bibliotheek' - resource = 'Resource' - variables = 'Variabele' - documentation = 'Documentatie' - metadata = 'Metadata' - suite_setup = 'Suite Preconditie' - suite_teardown = 'Suite Postconditie' - test_setup = 'Test Preconditie' - test_teardown = 'Test Postconditie' - test_template = 'Test Sjabloon' - test_timeout = 'Test Time-out' - test_tags = 'Test Labels' - task_setup = 'Taak Preconditie' - task_teardown = 'Taak Postconditie' - task_template = 'Taak Sjabloon' - task_timeout = 'Taak Time-out' - task_tags = 'Taak Labels' - keyword_tags = 'Sleutelwoord Labels' - tags = 'Labels' - setup = 'Preconditie' - teardown = 'Postconditie' - template = 'Sjabloon' - timeout = 'Time-out' - arguments = 'Parameters' - bdd_prefixes = {'Stel', 'Als', 'Dan', 'En', 'Maar'} + settings_header = 'Instellingen' + variables_header = 'Variabelen' + test_cases_header = 'Testgevallen' + tasks_header = 'Taken' + keywords_header = 'Sleutelwoorden' + comments_header = 'Opmerkingen' + library_setting = 'Bibliotheek' + resource_setting = 'Resource' + variables_setting = 'Variabele' + documentation_setting = 'Documentatie' + metadata_setting = 'Metadata' + suite_setup_setting = 'Suite Preconditie' + suite_teardown_setting = 'Suite Postconditie' + test_setup_setting = 'Test Preconditie' + test_teardown_setting = 'Test Postconditie' + test_template_setting = 'Test Sjabloon' + test_timeout_setting = 'Test Time-out' + test_tags_setting = 'Test Labels' + task_setup_setting = 'Taak Preconditie' + task_teardown_setting = 'Taak Postconditie' + task_template_setting = 'Taak Sjabloon' + task_timeout_setting = 'Taak Time-out' + task_tags_setting = 'Taak Labels' + keyword_tags_setting = 'Sleutelwoord Labels' + tags_setting = 'Labels' + setup_setting = 'Preconditie' + teardown_setting = 'Postconditie' + template_setting = 'Sjabloon' + timeout_setting = 'Time-out' + arguments_setting = 'Parameters' + given_prefix = {'Stel'} + when_prefix = {'Als'} + then_prefix = {'Dan'} + and_prefix = {'En'} + but_prefix = {'Maar'} class Fi(Language): """Finnish""" - setting_headers = {'Asetus', 'Asetukset'} - variable_headers = {'Muuttuja', 'Muuttujat'} - test_case_headers = {'Testi', 'Testit'} - task_headers = {'Tehtävä', 'Tehtävät'} - keyword_headers = {'Avainsana', 'Avainsanat'} - comment_headers = {'Kommentti', 'Kommentit'} - library = 'Kirjasto' - resource = 'Resurssi' - variables = 'Muuttujat' - documentation = 'Dokumentaatio' - metadata = 'Metatiedot' - suite_setup = 'Setin Alustus' - suite_teardown = 'Setin Alasajo' - test_setup = 'Testin Alustus' - task_setup = 'Tehtävän Alustus' - test_teardown = 'Testin Alasajo' - task_teardown = 'Tehtävän Alasajo' - test_template = 'Testin Malli' - task_template = 'Tehtävän Malli' - test_timeout = 'Testin Aikaraja' - task_timeout = 'Tehtävän Aikaraja' - test_tags = 'Testin Tagit' - task_tags = 'Tehtävän Tagit' - keyword_tags = 'Avainsanan Tagit' - tags = 'Tagit' - setup = 'Alustus' - teardown = 'Alasajo' - template = 'Malli' - timeout = 'Aikaraja' - arguments = 'Argumentit' - bdd_prefixes = {'Oletetaan', 'Kun', 'Niin', 'Ja', 'Mutta'} + settings_header = 'Asetukset' + variables_header = 'Muuttujat' + test_cases_header = 'Testit' + tasks_header = 'Tehtävät' + keywords_header = 'Avainsanat' + comments_header = 'Kommentit' + library_setting = 'Kirjasto' + resource_setting = 'Resurssi' + variables_setting = 'Muuttujat' + documentation_setting = 'Dokumentaatio' + metadata_setting = 'Metatiedot' + suite_setup_setting = 'Setin Alustus' + suite_teardown_setting = 'Setin Alasajo' + test_setup_setting = 'Testin Alustus' + task_setup_setting = 'Tehtävän Alustus' + test_teardown_setting = 'Testin Alasajo' + task_teardown_setting = 'Tehtävän Alasajo' + test_template_setting = 'Testin Malli' + task_template_setting = 'Tehtävän Malli' + test_timeout_setting = 'Testin Aikaraja' + task_timeout_setting = 'Tehtävän Aikaraja' + test_tags_setting = 'Testin Tagit' + task_tags_setting = 'Tehtävän Tagit' + keyword_tags_setting = 'Avainsanan Tagit' + tags_setting = 'Tagit' + setup_setting = 'Alustus' + teardown_setting = 'Alasajo' + template_setting = 'Malli' + timeout_setting = 'Aikaraja' + arguments_setting = 'Argumentit' + given_prefix = {'Oletetaan'} + when_prefix = {'Kun'} + then_prefix = {'Niin'} + and_prefix = {'Ja'} + but_prefix = {'Mutta'} class Fr(Language): """French""" - setting_headers = {'Paramètre', 'Paramètres'} - variable_headers = {'Variable', 'Variables'} - test_case_headers = {'Unité de test', 'Unités de test'} - task_headers = {'Tâche', 'Tâches'} - keyword_headers = {'Mot-clé', 'Mots-clés'} - comment_headers = {'Commentaire', 'Commentaires'} - library = 'Bibliothèque' - resource = 'Ressource' - variables = 'Variable' - documentation = 'Documentation' - metadata = 'Méta-donnée' - suite_setup = 'Mise en place de suite' - suite_teardown = 'Démontage de suite' - test_setup = 'Mise en place de test' - test_teardown = 'Démontage de test' - test_template = 'Modèle de test' - test_timeout = 'Délai de test' - test_tags = 'Étiquette de test' - task_setup = 'Mise en place de tâche' - task_teardown = 'Démontage de test' - task_template = 'Modèle de tâche' - task_timeout = 'Délai de tâche' - task_tags = 'Étiquette de tâche' - keyword_tags = 'Etiquette de mot-clé' - tags = 'Étiquette' - setup = 'Mise en place' - teardown = 'Démontage' - template = 'Modèle' - timeout = "Délai d'attente" - arguments = 'Arguments' - bdd_prefixes = {'Étant donné', 'Lorsque', 'Alors', 'Et', 'Mais'} + settings_header = 'Paramètres' + variables_header = 'Variables' + test_cases_header = 'Unités de test' + tasks_header = 'Tâches' + keywords_header = 'Mots-clés' + comments_header = 'Commentaires' + library_setting = 'Bibliothèque' + resource_setting = 'Ressource' + variables_setting = 'Variable' + documentation_setting = 'Documentation' + metadata_setting = 'Méta-donnée' + suite_setup_setting = 'Mise en place de suite' + suite_teardown_setting = 'Démontage de suite' + test_setup_setting = 'Mise en place de test' + test_teardown_setting = 'Démontage de test' + test_template_setting = 'Modèle de test' + test_timeout_setting = 'Délai de test' + test_tags_setting = 'Étiquette de test' + task_setup_setting = 'Mise en place de tâche' + task_teardown_setting = 'Démontage de test' + task_template_setting = 'Modèle de tâche' + task_timeout_setting = 'Délai de tâche' + task_tags_setting = 'Étiquette de tâche' + keyword_tags_setting = 'Etiquette de mot-clé' + tags_setting = 'Étiquette' + setup_setting = 'Mise en place' + teardown_setting = 'Démontage' + template_setting = 'Modèle' + timeout_setting = "Délai d'attente" + arguments_setting = 'Arguments' + given_prefix = {'Étant donné'} + when_prefix = {'Lorsque'} + then_prefix = {'Alors'} + and_prefix = {'Et'} + but_prefix = {'Mais'} class De(Language): """German""" - setting_headers = {'Einstellung', 'Einstellungen'} - variable_headers = {'Variable', 'Variablen'} - test_case_headers = {'Testfall', 'Testfälle'} - task_headers = {'Aufgabe', 'Aufgaben'} - keyword_headers = {'Schlüsselwort', 'Schlüsselwörter'} - comment_headers = {'Kommentar', 'Kommentare'} - library = 'Bibliothek' - resource = 'Ressource' - variables = 'Variablen' - documentation = 'Dokumentation' - metadata = 'Metadaten' - suite_setup = 'Suitevorbereitung' - suite_teardown = 'Suitenachbereitung' - test_setup = 'Testvorbereitung' - test_teardown = 'Testnachbereitung' - test_template = 'Testvorlage' - test_timeout = 'Testzeitlimit' - test_tags = 'Test Marker' - task_setup = 'Aufgabenvorbereitung' - task_teardown = 'Aufgabennachbereitung' - task_template = 'Aufgabenvorlage' - task_timeout = 'Aufgabenzeitlimit' - task_tags = 'Aufgaben Marker' - keyword_tags = 'Schlüsselwort Marker' - tags = 'Marker' - setup = 'Vorbereitung' - teardown = 'Nachbereitung' - template = 'Vorlage' - timeout = 'Zeitlimit' - arguments = 'Argumente' - bdd_prefixes = {'Angenommen', 'Wenn', 'Dann', 'Und', 'Aber'} + settings_header = 'Einstellungen' + variables_header = 'Variablen' + test_cases_header = 'Testfälle' + tasks_header = 'Aufgaben' + keywords_header = 'Schlüsselwörter' + comments_header = 'Kommentare' + library_setting = 'Bibliothek' + resource_setting = 'Ressource' + variables_setting = 'Variablen' + documentation_setting = 'Dokumentation' + metadata_setting = 'Metadaten' + suite_setup_setting = 'Suitevorbereitung' + suite_teardown_setting = 'Suitenachbereitung' + test_setup_setting = 'Testvorbereitung' + test_teardown_setting = 'Testnachbereitung' + test_template_setting = 'Testvorlage' + test_timeout_setting = 'Testzeitlimit' + test_tags_setting = 'Test Marker' + task_setup_setting = 'Aufgabenvorbereitung' + task_teardown_setting = 'Aufgabennachbereitung' + task_template_setting = 'Aufgabenvorlage' + task_timeout_setting = 'Aufgabenzeitlimit' + task_tags_setting = 'Aufgaben Marker' + keyword_tags_setting = 'Schlüsselwort Marker' + tags_setting = 'Marker' + setup_setting = 'Vorbereitung' + teardown_setting = 'Nachbereitung' + template_setting = 'Vorlage' + timeout_setting = 'Zeitlimit' + arguments_setting = 'Argumente' + given_prefix = {'Angenommen'} + when_prefix = {'Wenn'} + then_prefix = {'Dann'} + and_prefix = {'Und'} + but_prefix = {'Aber'} class PtBr(Language): """Brazilian Portuguese""" - setting_headers = {'Configuração', 'Configurações'} - variable_headers = {'Variável', 'Variáveis'} - test_case_headers = {'Caso de Teste', 'Casos de Teste'} - task_headers = {'Tarefa', 'Tarefas'} - keyword_headers = {'Palavra-Chave', 'Palavras-Chave'} - comment_headers = {'Comentário', 'Comentários'} - library = 'Biblioteca' - resource = 'Recurso' - variables = 'Variável' - documentation = 'Documentação' - metadata = 'Metadados' - suite_setup = 'Configuração da Suíte' - suite_teardown = 'Finalização de Suíte' - test_setup = 'Inicialização de Teste' - test_teardown = 'Finalização de Teste' - test_template = 'Modelo de Teste' - test_timeout = 'Tempo Limite de Teste' - test_tags = 'Test Tags' - task_setup = 'Inicialização de Tarefa' - task_teardown = 'Finalização de Tarefa' - task_template = 'Modelo de Tarefa' - task_timeout = 'Tempo Limite de Tarefa' - task_tags = 'Task Tags' - keyword_tags = 'Keyword Tags' - tags = 'Etiquetas' - setup = 'Inicialização' - teardown = 'Finalização' - template = 'Modelo' - timeout = 'Tempo Limite' - arguments = 'Argumentos' - bdd_prefixes = {'Dado', 'Quando', 'Então', 'E', 'Mas'} + settings_header = 'Configurações' + variables_header = 'Variáveis' + test_cases_header = 'Casos de Teste' + tasks_header = 'Tarefas' + keywords_header = 'Palavras-Chave' + comments_header = 'Comentários' + library_setting = 'Biblioteca' + resource_setting = 'Recurso' + variables_setting = 'Variável' + documentation_setting = 'Documentação' + metadata_setting = 'Metadados' + suite_setup_setting = 'Configuração da Suíte' + suite_teardown_setting = 'Finalização de Suíte' + test_setup_setting = 'Inicialização de Teste' + test_teardown_setting = 'Finalização de Teste' + test_template_setting = 'Modelo de Teste' + test_timeout_setting = 'Tempo Limite de Teste' + test_tags_setting = 'Test Tags' + task_setup_setting = 'Inicialização de Tarefa' + task_teardown_setting = 'Finalização de Tarefa' + task_template_setting = 'Modelo de Tarefa' + task_timeout_setting = 'Tempo Limite de Tarefa' + task_tags_setting = 'Task Tags' + keyword_tags_setting = 'Keyword Tags' + tags_setting = 'Etiquetas' + setup_setting = 'Inicialização' + teardown_setting = 'Finalização' + template_setting = 'Modelo' + timeout_setting = 'Tempo Limite' + arguments_setting = 'Argumentos' + given_prefix = {'Dado'} + when_prefix = {'Quando'} + then_prefix = {'Então'} + and_prefix = {'E'} + but_prefix = {'Mas'} class Pt(Language): """Portuguese""" - setting_headers = {'Definição', 'Definições'} - variable_headers = {'Variável', 'Variáveis'} - test_case_headers = {'Caso de Teste', 'Casos de Teste'} - task_headers = {'Tarefa', 'Tarefas'} - keyword_headers = {'Palavra-Chave', 'Palavras-Chave'} - comment_headers = {'Comentário', 'Comentários'} - library = 'Biblioteca' - resource = 'Recurso' - variables = 'Variável' - documentation = 'Documentação' - metadata = 'Metadados' - suite_setup = 'Inicialização de Suíte' - suite_teardown = 'Finalização de Suíte' - test_setup = 'Inicialização de Teste' - test_teardown = 'Finalização de Teste' - test_template = 'Modelo de Teste' - test_timeout = 'Tempo Limite de Teste' - test_tags = 'Etiquetas de Testes' - task_setup = 'Inicialização de Tarefa' - task_teardown = 'Finalização de Tarefa' - task_template = 'Modelo de Tarefa' - task_timeout = 'Tempo Limite de Tarefa' - task_tags = 'Etiquetas de Tarefas' - keyword_tags = 'Etiquetas de Palavras-Chave' - tags = 'Etiquetas' - setup = 'Inicialização' - teardown = 'Finalização' - template = 'Modelo' - timeout = 'Tempo Limite' - arguments = 'Argumentos' - bdd_prefixes = {'Dado', 'Quando', 'Então', 'E', 'Mas'} + settings_header = 'Definições' + variables_header = 'Variáveis' + test_cases_header = 'Casos de Teste' + tasks_header = 'Tarefas' + keywords_header = 'Palavras-Chave' + comments_header = 'Comentários' + library_setting = 'Biblioteca' + resource_setting = 'Recurso' + variables_setting = 'Variável' + documentation_setting = 'Documentação' + metadata_setting = 'Metadados' + suite_setup_setting = 'Inicialização de Suíte' + suite_teardown_setting = 'Finalização de Suíte' + test_setup_setting = 'Inicialização de Teste' + test_teardown_setting = 'Finalização de Teste' + test_template_setting = 'Modelo de Teste' + test_timeout_setting = 'Tempo Limite de Teste' + test_tags_setting = 'Etiquetas de Testes' + task_setup_setting = 'Inicialização de Tarefa' + task_teardown_setting = 'Finalização de Tarefa' + task_template_setting = 'Modelo de Tarefa' + task_timeout_setting = 'Tempo Limite de Tarefa' + task_tags_setting = 'Etiquetas de Tarefas' + keyword_tags_setting = 'Etiquetas de Palavras-Chave' + tags_setting = 'Etiquetas' + setup_setting = 'Inicialização' + teardown_setting = 'Finalização' + template_setting = 'Modelo' + timeout_setting = 'Tempo Limite' + arguments_setting = 'Argumentos' + given_prefix = {'Dado'} + when_prefix = {'Quando'} + then_prefix = {'Então'} + and_prefix = {'E'} + but_prefix = {'Mas'} class Th(Language): """Thai""" - setting_headers = {'การตั้งค่า'} - variable_headers = {'กำหนดตัวแปร'} - test_case_headers = {'การทดสอบ'} - task_headers = {'งาน'} - keyword_headers = {'คำสั่งเพิ่มเติม'} - comment_headers = {'คำอธิบาย'} - library = 'ชุดคำสั่งที่ใช้' - resource = 'ไฟล์ที่ใช้' - variables = 'ชุดตัวแปร' - documentation = 'เอกสาร' - metadata = 'รายละเอียดเพิ่มเติม' - suite_setup = 'กำหนดค่าเริ่มต้นของชุดการทดสอบ' - suite_teardown = 'คืนค่าของชุดการทดสอบ' - test_setup = 'กำหนดค่าเริ่มต้นของการทดสอบ' - task_setup = 'กำหนดค่าเริ่มต้นของงาน' - test_teardown = 'คืนค่าของการทดสอบ' - task_teardown = 'คืนค่าของงาน' - test_template = 'โครงสร้างของการทดสอบ' - task_template = 'โครงสร้างของงาน' - test_timeout = 'เวลารอของการทดสอบ' - task_timeout = 'เวลารอของงาน' - test_tags = 'กลุ่มของการทดสอบ' - task_tags = 'กลุ่มของงาน' - keyword_tags = 'กลุ่มของคำสั่งเพิ่มเติม' - setup = 'กำหนดค่าเริ่มต้น' - teardown = 'คืนค่า' - template = 'โครงสร้าง' - tags = 'กลุ่ม' - timeout = 'หมดเวลา' - arguments = 'ค่าที่ส่งเข้ามา' - bdd_prefixes = {'กำหนดให้', 'เมื่อ', 'ดังนั้น', 'และ', 'แต่'} + settings_header = 'การตั้งค่า' + variables_header = 'กำหนดตัวแปร' + test_cases_header = 'การทดสอบ' + tasks_header = 'งาน' + keywords_header = 'คำสั่งเพิ่มเติม' + comments_header = 'คำอธิบาย' + library_setting = 'ชุดคำสั่งที่ใช้' + resource_setting = 'ไฟล์ที่ใช้' + variables_setting = 'ชุดตัวแปร' + documentation_setting = 'เอกสาร' + metadata_setting = 'รายละเอียดเพิ่มเติม' + suite_setup_setting = 'กำหนดค่าเริ่มต้นของชุดการทดสอบ' + suite_teardown_setting = 'คืนค่าของชุดการทดสอบ' + test_setup_setting = 'กำหนดค่าเริ่มต้นของการทดสอบ' + task_setup_setting = 'กำหนดค่าเริ่มต้นของงาน' + test_teardown_setting = 'คืนค่าของการทดสอบ' + task_teardown_setting = 'คืนค่าของงาน' + test_template_setting = 'โครงสร้างของการทดสอบ' + task_template_setting = 'โครงสร้างของงาน' + test_timeout_setting = 'เวลารอของการทดสอบ' + task_timeout_setting = 'เวลารอของงาน' + test_tags_setting = 'กลุ่มของการทดสอบ' + task_tags_setting = 'กลุ่มของงาน' + keyword_tags_setting = 'กลุ่มของคำสั่งเพิ่มเติม' + setup_setting = 'กำหนดค่าเริ่มต้น' + teardown_setting = 'คืนค่า' + template_setting = 'โครงสร้าง' + tags_setting = 'กลุ่ม' + timeout_setting = 'หมดเวลา' + arguments_setting = 'ค่าที่ส่งเข้ามา' + given_prefix = {'กำหนดให้'} + when_prefix = {'เมื่อ'} + then_prefix = {'ดังนั้น'} + and_prefix = {'และ'} + but_prefix = {'แต่'} class Pl(Language): """Polish""" - setting_headers = {'Ustawienia'} - variable_headers = {'Zmienna', 'Zmienne'} - test_case_headers = {'Przypadek testowy', 'Przypadki testowe', 'Test', 'Testy', 'Scenariusz', 'Scenariusze'} - task_headers = {'Zadanie', 'Zadania'} - keyword_headers = {'Słowo kluczowe', 'Słowa kluczowe', 'Funkcja', 'Funkcje'} - comment_headers = {'Komentarz', 'Komentarze'} - library = 'Biblioteka' - resource = 'Zasób' - variables = 'Zmienne' - documentation = 'Dokumentacja' - metadata = 'Metadane' - suite_setup = 'Inicjalizacja zestawu' - suite_teardown = 'Ukończenie zestawu' - test_setup = 'Inicjalizacja testu' - test_teardown = 'Ukończenie testu' - test_template = 'Szablon testu' - test_timeout = 'Limit czasowy testu' - test_tags = 'Znaczniki testu' - task_setup = 'Inicjalizacja zadania' - task_teardown = 'Ukończenie zadania' - task_template = 'Szablon zadania' - task_timeout = 'Limit czasowy zadania' - task_tags = 'Znaczniki zadania' - keyword_tags = 'Znaczniki słowa kluczowego' - tags = 'Znaczniki' - setup = 'Inicjalizacja' - teardown = 'Ukończenie' - template = 'Szablon' - timeout = 'Limit czasowy' - arguments = 'Argumenty' - bdd_prefixes = {'Zakładając', 'Zakładając, że', 'Mając', 'Jeżeli', 'Jeśli', 'Gdy', 'Kiedy', 'Wtedy', 'Oraz', 'I', 'Ale'} + settings_header = 'Ustawienia' + variables_header = 'Zmienne' + test_cases_header = 'Przypadki testowe' + tasks_header = 'Zadania' + keywords_header = 'Słowa kluczowe' + comments_header = 'Komentarze' + library_setting = 'Biblioteka' + resource_setting = 'Zasób' + variables_setting = 'Zmienne' + documentation_setting = 'Dokumentacja' + metadata_setting = 'Metadane' + suite_setup_setting = 'Inicjalizacja zestawu' + suite_teardown_setting = 'Ukończenie zestawu' + test_setup_setting = 'Inicjalizacja testu' + test_teardown_setting = 'Ukończenie testu' + test_template_setting = 'Szablon testu' + test_timeout_setting = 'Limit czasowy testu' + test_tags_setting = 'Znaczniki testu' + task_setup_setting = 'Inicjalizacja zadania' + task_teardown_setting = 'Ukończenie zadania' + task_template_setting = 'Szablon zadania' + task_timeout_setting = 'Limit czasowy zadania' + task_tags_setting = 'Znaczniki zadania' + keyword_tags_setting = 'Znaczniki słowa kluczowego' + tags_setting = 'Znaczniki' + setup_setting = 'Inicjalizacja' + teardown_setting = 'Ukończenie' + template_setting = 'Szablon' + timeout_setting = 'Limit czasowy' + arguments_setting = 'Argumenty' + given_prefix = {'Zakładając', 'Zakładając, że', 'Mając'} + when_prefix = {'Jeżeli', 'Jeśli', 'Gdy', 'Kiedy'} + then_prefix = {'Wtedy'} + and_prefix = {'Oraz', 'I'} + but_prefix = {'Ale'} class Uk(Language): """Ukrainian""" - setting_headers = {'Налаштування', 'Налаштування', 'Налаштування', 'Налаштування'} - variable_headers = {'Змінна', 'Змінні', 'Змінних', 'Змінних'} - test_case_headers = {'Тест-кейс', 'Тест-кейси', 'Тест-кейсів', 'Тест-кейси'} - task_headers = {'Завдання', 'Завадання', 'Завдань', 'Завдань'} - keyword_headers = {'Ключове слово', 'Ключових слова', 'Ключових слів', 'Ключових слова'} - comment_headers = {'Коментувати', 'Коментувати', 'Коментувати', 'Коментарів'} - library = 'Бібліотека' - resource = 'Ресурс' - variables = 'Змінна' - documentation = 'Документація' - metadata = 'Метадані' - suite_setup = 'Налаштування Suite' - suite_teardown = 'Розбірка Suite' - test_setup = 'Налаштування тесту' - test_teardown = 'Розбирання тестy' - test_template = 'Тестовий шаблон' - test_timeout = 'Час тестування' - test_tags = 'Тестові теги' - task_setup = 'Налаштування завдання' - task_teardown = 'Розбір завдання' - task_template = 'Шаблон завдання' - task_timeout = 'Час очікування завдання' - task_tags = 'Теги завдань' - keyword_tags = 'Теги ключових слів' - tags = 'Теги' - setup = 'Встановлення' - teardown = 'Cпростовувати пункт за пунктом' - template = 'Шаблон' - timeout = 'Час вийшов' - arguments = 'Аргументи' - bdd_prefixes = {'Дано', 'Коли', 'Тоді', 'Та', 'Але'} + settings_header = 'Налаштування' + variables_header = 'Змінні' + test_cases_header = 'Тест-кейси' + tasks_header = 'Завдань' + keywords_header = 'Ключових слова' + comments_header = 'Коментарів' + library_setting = 'Бібліотека' + resource_setting = 'Ресурс' + variables_setting = 'Змінна' + documentation_setting = 'Документація' + metadata_setting = 'Метадані' + suite_setup_setting = 'Налаштування Suite' + suite_teardown_setting = 'Розбірка Suite' + test_setup_setting = 'Налаштування тесту' + test_teardown_setting = 'Розбирання тестy' + test_template_setting = 'Тестовий шаблон' + test_timeout_setting = 'Час тестування' + test_tags_setting = 'Тестові теги' + task_setup_setting = 'Налаштування завдання' + task_teardown_setting = 'Розбір завдання' + task_template_setting = 'Шаблон завдання' + task_timeout_setting = 'Час очікування завдання' + task_tags_setting = 'Теги завдань' + keyword_tags_setting = 'Теги ключових слів' + tags_setting = 'Теги' + setup_setting = 'Встановлення' + teardown_setting = 'Cпростовувати пункт за пунктом' + template_setting = 'Шаблон' + timeout_setting = 'Час вийшов' + arguments_setting = 'Аргументи' + given_prefix = {'Дано'} + when_prefix = {'Коли'} + then_prefix = {'Тоді'} + and_prefix = {'Та'} + but_prefix = {'Але'} class Es(Language): """Spanish""" - setting_headers = {'Configuración', 'Configuraciones'} - variable_headers = {'Variable', 'Variables'} - test_case_headers = {'Caso de prueba', 'Casos de prueba'} - task_headers = {'Tarea', 'Tareas'} - keyword_headers = {'Palabra clave', 'Palabras clave'} - comment_headers = {'Comentario', 'Comentarios'} - library = 'Biblioteca' - resource = 'Recursos' - variables = 'Variable' - documentation = 'Documentación' - metadata = 'Metadatos' - suite_setup = 'Configuración de la Suite' - suite_teardown = 'Desmontaje de la Suite' - test_setup = 'Configuración de prueba' - test_teardown = 'Desmontaje de la prueba' - test_template = 'Plantilla de prueba' - test_timeout = 'Tiempo de espera de la prueba' - test_tags = 'Etiquetas de la prueba' - task_setup = 'Configuración de tarea' - task_teardown = 'Desmontaje de tareas' - task_template = 'Plantilla de tareas' - task_timeout = 'Tiempo de espera de las tareas' - task_tags = 'Etiquetas de las tareas' - keyword_tags = 'Etiquetas de palabras clave' - tags = 'Etiquetas' - setup = 'Configuración' - teardown = 'Desmontaje' - template = 'Plantilla' - timeout = 'Tiempo agotado' - arguments = 'Argumentos' - bdd_prefixes = {'Dado', 'Cuando', 'Entonces', 'Y', 'Pero'} + settings_header = 'Configuraciones' + variables_header = 'Variables' + test_cases_header = 'Casos de prueba' + tasks_header = 'Tareas' + keywords_header = 'Palabras clave' + comments_header = 'Comentarios' + library_setting = 'Biblioteca' + resource_setting = 'Recursos' + variables_setting = 'Variable' + documentation_setting = 'Documentación' + metadata_setting = 'Metadatos' + suite_setup_setting = 'Configuración de la Suite' + suite_teardown_setting = 'Desmontaje de la Suite' + test_setup_setting = 'Configuración de prueba' + test_teardown_setting = 'Desmontaje de la prueba' + test_template_setting = 'Plantilla de prueba' + test_timeout_setting = 'Tiempo de espera de la prueba' + test_tags_setting = 'Etiquetas de la prueba' + task_setup_setting = 'Configuración de tarea' + task_teardown_setting = 'Desmontaje de tareas' + task_template_setting = 'Plantilla de tareas' + task_timeout_setting = 'Tiempo de espera de las tareas' + task_tags_setting = 'Etiquetas de las tareas' + keyword_tags_setting = 'Etiquetas de palabras clave' + tags_setting = 'Etiquetas' + setup_setting = 'Configuración' + teardown_setting = 'Desmontaje' + template_setting = 'Plantilla' + timeout_setting = 'Tiempo agotado' + arguments_setting = 'Argumentos' + given_prefix = {'Dado'} + when_prefix = {'Cuando'} + then_prefix = {'Entonces'} + and_prefix = {'Y'} + but_prefix = {'Pero'} class Ru(Language): """Russian""" - setting_headers = {'Настройки'} - variable_headers = {'Переменная', 'Переменные'} - test_case_headers = {'Заголовки тестов'} - task_headers = {'Задача'} - keyword_headers = {'Ключевое слово', 'Ключевые слова'} - comment_headers = {'Комментарий', 'Комментарии'} - library = 'Библиотека' - resource = 'Ресурс' - variables = 'Переменные' - documentation = 'Документация' - metadata = 'Метаданные' - suite_setup = 'Инициализация комплекта тестов' - suite_teardown = 'Завершение комплекта тестов' - test_setup = 'Инициализация теста' - test_teardown = 'Завершение теста' - test_template = 'Шаблон теста' - test_timeout = 'Лимит выполнения теста' - test_tags = 'Теги тестов' - task_setup = 'Инициализация задания' - task_teardown = 'Завершение задания' - task_template = 'Шаблон задания' - task_timeout = 'Лимит задания' - task_tags = 'Метки заданий' - keyword_tags = 'Метки ключевых слов' - tags = 'Метки' - setup = 'Инициализация' - teardown = 'Завершение' - template = 'Шаблон' - timeout = 'Лимит' - arguments = 'Аргументы' - bdd_prefixes = {'Дано', 'Когда', 'Тогда', 'И', 'Но'} + settings_header = 'Настройки' + variables_header = 'Переменные' + test_cases_header = 'Заголовки тестов' + tasks_header = 'Задача' + keywords_header = 'Ключевые слова' + comments_header = 'Комментарии' + library_setting = 'Библиотека' + resource_setting = 'Ресурс' + variables_setting = 'Переменные' + documentation_setting = 'Документация' + metadata_setting = 'Метаданные' + suite_setup_setting = 'Инициализация комплекта тестов' + suite_teardown_setting = 'Завершение комплекта тестов' + test_setup_setting = 'Инициализация теста' + test_teardown_setting = 'Завершение теста' + test_template_setting = 'Шаблон теста' + test_timeout_setting = 'Лимит выполнения теста' + test_tags_setting = 'Теги тестов' + task_setup_setting = 'Инициализация задания' + task_teardown_setting = 'Завершение задания' + task_template_setting = 'Шаблон задания' + task_timeout_setting = 'Лимит задания' + task_tags_setting = 'Метки заданий' + keyword_tags_setting = 'Метки ключевых слов' + tags_setting = 'Метки' + setup_setting = 'Инициализация' + teardown_setting = 'Завершение' + template_setting = 'Шаблон' + timeout_setting = 'Лимит' + arguments_setting = 'Аргументы' + given_prefix = {'Дано'} + when_prefix = {'Когда'} + then_prefix = {'Тогда'} + and_prefix = {'И'} + but_prefix = {'Но'} class ZhCn(Language): """Chinese Simplified""" - setting_headers = {'设置'} - variable_headers = {'变量'} - test_case_headers = {'用例'} - task_headers = {'任务'} - keyword_headers = {'关键字'} - comment_headers = {'备注'} - library = '库' - resource = '资源' - variables = '变量' - documentation = '说明文档' - metadata = '元数据' - suite_setup = '用例集预置' - suite_teardown = '用例集收尾' - test_setup = '用例预置' - test_teardown = '用例收尾' - test_template = '测试模板' - test_timeout = '用例超时' - test_tags = '测试标签' - task_setup = '任务启程' - task_teardown = '任务收尾' - task_template = '任务模板' - task_timeout = '任务超时' - task_tags = '任务标签' - keyword_tags = '关键字标签' - tags = '标签' - setup = '预设' - teardown = '终程' - template = '模板' - timeout = '超时' - arguments = '参数' - bdd_prefixes = {'输入', '当', '则', '且', '但'} + settings_header = '设置' + variables_header = '变量' + test_cases_header = '用例' + tasks_header = '任务' + keywords_header = '关键字' + comments_header = '备注' + library_setting = '库' + resource_setting = '资源' + variables_setting = '变量' + documentation_setting = '说明文档' + metadata_setting = '元数据' + suite_setup_setting = '用例集预置' + suite_teardown_setting = '用例集收尾' + test_setup_setting = '用例预置' + test_teardown_setting = '用例收尾' + test_template_setting = '测试模板' + test_timeout_setting = '用例超时' + test_tags_setting = '测试标签' + task_setup_setting = '任务启程' + task_teardown_setting = '任务收尾' + task_template_setting = '任务模板' + task_timeout_setting = '任务超时' + task_tags_setting = '任务标签' + keyword_tags_setting = '关键字标签' + tags_setting = '标签' + setup_setting = '预设' + teardown_setting = '终程' + template_setting = '模板' + timeout_setting = '超时' + arguments_setting = '参数' + given_prefix = {'输入'} + when_prefix = {'当'} + then_prefix = {'则'} + and_prefix = {'且'} + but_prefix = {'但'} From 7b26359fc06323058a342a6a0c1d405c9d218ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 19 Aug 2022 10:35:34 +0300 Subject: [PATCH 0156/1592] Support per-file language configuration. Unfortunately configuration bleeds to the subsequently parsed suites. That happens because we need language info at execution time and that has been implemented using a shared Languages instance. The limitation is somewhat small and not worth fixing right now. --- atest/robot/parsing/translations.robot | 29 +++++++-- .../translations/per_file_config/fi.robot | 62 +++++++++++++++++++ .../translations/per_file_config/many.robot | 14 +++++ src/robot/conf/languages.py | 46 +++++++++----- src/robot/parsing/lexer/blocklexers.py | 4 +- src/robot/parsing/lexer/context.py | 3 + src/robot/parsing/lexer/statementlexers.py | 13 ++++ utest/api/test_languages.py | 27 +++++++- 8 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 atest/testdata/parsing/translations/per_file_config/fi.robot create mode 100644 atest/testdata/parsing/translations/per_file_config/many.robot diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index 101e64ad5d0..b041275063b 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -31,15 +31,32 @@ Invalid ... Invalid value for option '--language': Importing language file 'bad' failed: ModuleNotFoundError: No module named 'bad' ... Traceback \\(most recent call last\\): ... .*${USAGE TIP} - Should Match Regexp ${result.stderr} (?s)^\\[ ERROR \\] ${error}$ + Should Match Regexp ${result.stderr} ^\\[ ERROR \\] ${error}$ flags=DOTALL + +Per file configuration + Run Tests ${EMPTY} parsing/translations/per_file_config/fi.robot + Validate Translations + +Per file configuration with multiple languages + Run Tests ${EMPTY} parsing/translations/per_file_config/many.robot + Should Be Equal ${SUITE.doc} Exemplo + ${tc} = Check Test Case ตัวอย่าง + Should Be Equal ${tc.doc} приклад + +Per file configuration bleeds to other files + [Documentation] This is a technical limitation and will hopefully change! + Run Tests ${EMPTY} parsing/translations/per_file_config/fi.robot parsing/translations/finnish/tests.robot + Validate Translations ${SUITE.suites[0]} + Validate Translations ${SUITE.suites[1]} *** Keywords *** Validate Translations - Should Be Equal ${SUITE.doc} Suite documentation. - Should Be Equal ${SUITE.metadata}[Metadata] Value - Should Be Equal ${SUITE.setup.name} Suite Setup - Should Be Equal ${SUITE.teardown.name} Suite Teardown - Should Be Equal ${SUITE.status} PASS + [Arguments] ${suite}=${SUITE} + Should Be Equal ${suite.doc} Suite documentation. + Should Be Equal ${suite.metadata}[Metadata] Value + Should Be Equal ${suite.setup.name} Suite Setup + Should Be Equal ${suite.teardown.name} Suite Teardown + Should Be Equal ${suite.status} PASS ${tc} = Check Test Case Test without settings Should Be Equal ${tc.doc} ${EMPTY} Should Be Equal ${tc.tags} ${{['test', 'tags']}} diff --git a/atest/testdata/parsing/translations/per_file_config/fi.robot b/atest/testdata/parsing/translations/per_file_config/fi.robot new file mode 100644 index 00000000000..3ac09b1d617 --- /dev/null +++ b/atest/testdata/parsing/translations/per_file_config/fi.robot @@ -0,0 +1,62 @@ +language: fi + +*** Asetukset *** +Dokumentaatio Suite documentation. +Metatiedot Metadata Value +Setin Alustus Suite Setup +Setin Alasajo Suite Teardown +Testin Alustus Test Setup +Testin Alasajo Test Teardown +Testin Malli Test Template +Testin Aikaraja 1 minute +Testin Tagit test tags +Avainsanan Tagit keyword tags +Kirjasto OperatingSystem +Resurssi ../finnish/resource.resource +Muuttujat ../../variables.py + +*** Muuttujat *** +${VARIABLE} variable value + +*** Testit *** +Test without settings + Nothing to see here + +Test with settings + [Dokumentaatio] Test documentation. + [Tagit] own tag + [Alustus] NONE + [Alasajo] NONE + [Malli] NONE + [Aikaraja] NONE + Keyword ${VARIABLE} + +*** Avainsanat *** +Suite Setup + Directory Should Exist ${CURDIR} + +Suite Teardown + Keyword In Resource + +Test Setup + Should Be Equal ${VARIABLE} variable value + Should Be Equal ${RESOURCE FILE} variable in resource file + Should Be Equal ${VARIABLE FILE} variable in variable file + +Test Teardown + No Operation + +Test Template + [Argumentit] ${message} + Log ${message} + +Keyword + [Dokumentaatio] Keyword documentation. + [Argumentit] ${arg} + [Tagit] own tag + [Aikaraja] 1h + Should Be Equal ${arg} ${VARIABLE} + [Alasajo] No Operation + +*** Kommentit *** +Ignored comments. diff --git a/atest/testdata/parsing/translations/per_file_config/many.robot b/atest/testdata/parsing/translations/per_file_config/many.robot new file mode 100644 index 00000000000..a5bedbc115a --- /dev/null +++ b/atest/testdata/parsing/translations/per_file_config/many.robot @@ -0,0 +1,14 @@ +Language: DE +LANGUAGE: Brazilian Portuguese + +This is not language: config + +language: THAI language: ukrainian + +*** Einstellungen *** +Documentação Exemplo + +*** การทดสอบ *** +ตัวอย่าง + [Документація] приклад + Log Слава Україні! diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 3ac53e5e2a6..b634a92832e 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -22,7 +22,7 @@ class Languages: def __init__(self, languages): - self.languages = self._get_languages(languages) + self.languages = [] # The English singular forms are added for backwards compatibility self.setting_headers = {'Setting'} self.variable_headers = {'Variable'} @@ -32,17 +32,29 @@ def __init__(self, languages): self.comment_headers = {'Comment'} self.settings = {} self.bdd_prefixes = set() - for lang in self.languages: - self.setting_headers.add(lang.settings_header.title()) - self.variable_headers.add(lang.variables_header.title()) - self.test_case_headers.add(lang.test_cases_header.title()) - self.task_headers.add(lang.tasks_header.title()) - self.keyword_headers.add(lang.keywords_header.title()) - self.comment_headers.add(lang.comments_header.title()) - self.settings.update( - {name.title(): lang.settings[name] for name in lang.settings if name} - ) - self.bdd_prefixes |= {p.title() for p in lang.bdd_prefixes} + for lang in self._get_languages(languages): + self._add_language(lang) + + def _add_language(self, lang): + if lang in self.languages: + return + self.languages.append(lang) + # FIXME: Headers should be added only when defined. + self.setting_headers.add(lang.settings_header.title()) + self.variable_headers.add(lang.variables_header.title()) + self.test_case_headers.add(lang.test_cases_header.title()) + self.task_headers.add(lang.tasks_header.title()) + self.keyword_headers.add(lang.keywords_header.title()) + self.comment_headers.add(lang.comments_header.title()) + self.settings.update({n.title(): lang.settings[n] for n in lang.settings if n}) + self.bdd_prefixes |= {p.title() for p in lang.bdd_prefixes} + + def add_language(self, name): + try: + lang = Language.from_name(name) + except ValueError: + raise # FIXME + self._add_language(lang) def _get_languages(self, languages): languages = self._resolve_languages(languages) @@ -209,8 +221,14 @@ def settings(self): @property def bdd_prefixes(self): - return self.given_prefix | self.when_prefix | self.then_prefix \ - | self.and_prefix | self.but_prefix + return (self.given_prefix | self.when_prefix | self.then_prefix | + self.and_prefix | self.but_prefix) + + def __eq__(self, other): + return isinstance(other, type(self)) + + def __hash__(self): + return hash(type(self)) class En(Language): diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index ead837e0fd3..cfa7edfa896 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -22,7 +22,7 @@ TestCaseSectionHeaderLexer, TaskSectionHeaderLexer, KeywordSectionHeaderLexer, - CommentSectionHeaderLexer, CommentLexer, + CommentSectionHeaderLexer, CommentLexer, ImplicitCommentLexer, ErrorSectionHeaderLexer, TestOrKeywordSettingLexer, KeywordCallLexer, @@ -161,7 +161,7 @@ def handles(cls, statement, ctx): return True def lexer_classes(self): - return (CommentLexer,) + return (ImplicitCommentLexer,) class ErrorSectionLexer(SectionLexer): diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index eb69b1e3671..786d1af53fe 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -41,6 +41,9 @@ class FileContext(LexingContext): def __init__(self, settings=None, lang=None): super().__init__(settings, lang) + def add_language(self, lang): + self.languages.add_language(lang) + def keyword_context(self): return KeywordContext(settings=KeywordSettings(self.languages)) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index f4b210ccb9e..7f3197bd346 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re + from robot.utils import normalize_whitespace from robot.variables import is_assign @@ -112,6 +114,17 @@ class CommentLexer(SingleType): token_type = Token.COMMENT +class ImplicitCommentLexer(CommentLexer): + language = re.compile(r'language:(.+)', re.IGNORECASE) + + def input(self, statement): + super().input(statement) + for token in statement: + match = self.language.match(token.value) + if match: + self.ctx.add_language(match.group(1).strip()) + + class SettingLexer(StatementLexer): def lex(self): diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index 723fad86ba9..ad0e8d8c5b3 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -1,8 +1,8 @@ import unittest from robot.api import Language -from robot.conf.languages import Fi, PtBr -from robot.utils.asserts import assert_equal, assert_raises_with_msg +from robot.conf.languages import Languages, En, Fi, PtBr, Th +from robot.utils.asserts import assert_equal, assert_not_equal, assert_raises_with_msg class TestLanguage(unittest.TestCase): @@ -37,7 +37,17 @@ def test_all_standard_languages_have_code_and_name(self): assert lang.code assert lang.name -class TestFromName(unittest.TestCase): + def test_eq(self): + assert_equal(Fi(), Fi()) + assert_equal(Language.from_name('fi'), Fi()) + assert_not_equal(Fi(), PtBr()) + + def test_hash(self): + assert_equal(hash(Fi()), hash(Fi())) + assert_equal({Fi(): 'value'}[Fi()], 'value') + + +class TestLanguageFromName(unittest.TestCase): def test_code(self): assert isinstance(Language.from_name('fi'), Fi) @@ -60,5 +70,16 @@ def test_no_match(self): Language.from_name, 'no match') +class TestLanguages(unittest.TestCase): + + def test_duplicates_are_not_added(self): + langs = Languages(['Finnish', 'en', Fi(), 'pt-br']) + assert_equal(list(langs), [Fi(), En(), PtBr()]) + langs.add_language('en') + assert_equal(list(langs), [Fi(), En(), PtBr()]) + langs.add_language('th') + assert_equal(list(langs), [Fi(), En(), PtBr(), Th()]) + + if __name__ == '__main__': unittest.main() From 2a4f40497342795f8e471ff1822a25217537d896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 19 Aug 2022 11:34:21 +0300 Subject: [PATCH 0157/1592] Refactor handling header translations. --- src/robot/conf/languages.py | 47 +++++++++++++++++------------- src/robot/parsing/lexer/context.py | 21 ++++++------- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index b634a92832e..ccdd70d62a4 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -24,12 +24,14 @@ class Languages: def __init__(self, languages): self.languages = [] # The English singular forms are added for backwards compatibility - self.setting_headers = {'Setting'} - self.variable_headers = {'Variable'} - self.test_case_headers = {'Test Case'} - self.task_headers = {'Task'} - self.keyword_headers = {'Keyword'} - self.comment_headers = {'Comment'} + self.headers = { + 'Setting': 'Settings', + 'Variable': 'Variables', + 'Test Case': 'Test Cases', + 'Task': 'Tasks', + 'Keyword': 'Keywords', + 'Comment': 'Comments' + } self.settings = {} self.bdd_prefixes = set() for lang in self._get_languages(languages): @@ -39,13 +41,7 @@ def _add_language(self, lang): if lang in self.languages: return self.languages.append(lang) - # FIXME: Headers should be added only when defined. - self.setting_headers.add(lang.settings_header.title()) - self.variable_headers.add(lang.variables_header.title()) - self.test_case_headers.add(lang.test_cases_header.title()) - self.task_headers.add(lang.tasks_header.title()) - self.keyword_headers.add(lang.keywords_header.title()) - self.comment_headers.add(lang.comments_header.title()) + self.headers.update({n.title(): lang.headers[n] for n in lang.headers if n}) self.settings.update({n.title(): lang.settings[n] for n in lang.settings if n}) self.bdd_prefixes |= {p.title() for p in lang.bdd_prefixes} @@ -53,7 +49,7 @@ def add_language(self, name): try: lang = Language.from_name(name) except ValueError: - raise # FIXME + raise # FIXME: Proper error handling!! self._add_language(lang) def _get_languages(self, languages): @@ -115,12 +111,12 @@ class Language: Language :attr:`code` is got based on the class name and :attr:`name` based on the docstring. """ - settings_header = "" - variables_header = "" - test_cases_header = "" - tasks_header = "" - keywords_header = "" - comments_header = "" + settings_header = None + variables_header = None + test_cases_header = None + tasks_header = None + keywords_header = None + comments_header = None library_setting = None resource_setting = None variables_setting = None @@ -190,6 +186,17 @@ def name(self): """ return self.__doc__.splitlines()[0] if self.__doc__ else '' + @property + def headers(self): + return { + self.settings_header: En.settings_header, + self.variables_header: En.variables_header, + self.test_cases_header: En.test_cases_header, + self.tasks_header: En.tasks_header, + self.keywords_header: En.keywords_header, + self.comments_header: En.comments_header + } + @property def settings(self): return { diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 786d1af53fe..3cc0bf02fc4 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -48,10 +48,10 @@ def keyword_context(self): return KeywordContext(settings=KeywordSettings(self.languages)) def setting_section(self, statement): - return self._handles_section(statement, self.languages.setting_headers) + return self._handles_section(statement, 'Settings') def variable_section(self, statement): - return self._handles_section(statement, self.languages.variable_headers) + return self._handles_section(statement, 'Variables') def test_case_section(self, statement): return False @@ -60,10 +60,10 @@ def task_section(self, statement): return False def keyword_section(self, statement): - return self._handles_section(statement, self.languages.keyword_headers) + return self._handles_section(statement, 'Keywords') def comment_section(self, statement): - return self._handles_section(statement, self.languages.comment_headers) + return self._handles_section(statement, 'Comments') def lex_invalid_section(self, statement): message, fatal = self._get_invalid_section_error(statement[0].value) @@ -74,9 +74,10 @@ def lex_invalid_section(self, statement): def _get_invalid_section_error(self, header): raise NotImplementedError - def _handles_section(self, statement, headers): + def _handles_section(self, statement, header): marker = statement[0].value - return marker.startswith('*') and self._normalize(marker) in headers + return (marker[:1] == '*' and + self.languages.headers.get(self._normalize(marker)) == header) def _normalize(self, marker): return normalize_whitespace(marker).strip('* ').title() @@ -89,10 +90,10 @@ def test_case_context(self): return TestCaseContext(settings=TestCaseSettings(self.settings, self.languages)) def test_case_section(self, statement): - return self._handles_section(statement, self.languages.test_case_headers) + return self._handles_section(statement, 'Test Cases') def task_section(self, statement): - return self._handles_section(statement, self.languages.task_headers) + return self._handles_section(statement, 'Tasks') def _get_invalid_section_error(self, header): return (f"Unrecognized section header '{header}'. Valid sections: " @@ -105,7 +106,7 @@ class ResourceFileContext(FileContext): def _get_invalid_section_error(self, header): name = self._normalize(header) - if name in self.languages.test_case_headers | self.languages.task_headers: + if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): message = f"Resource file with '{name}' section is invalid." fatal = True else: @@ -120,7 +121,7 @@ class InitFileContext(FileContext): def _get_invalid_section_error(self, header): name = self._normalize(header) - if name in self.languages.test_case_headers | self.languages.task_headers: + if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): message = f"'{name}' section is not allowed in suite initialization file." else: message = (f"Unrecognized section header '{header}'. Valid sections: " From 752a709e4b9bcab55f57591c5bd69913a36874e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 19 Aug 2022 11:50:36 +0300 Subject: [PATCH 0158/1592] Error handling for per-file language config. --- atest/robot/parsing/translations.robot | 4 ++++ .../parsing/translations/per_file_config/many.robot | 2 +- src/robot/conf/languages.py | 5 +---- src/robot/parsing/lexer/statementlexers.py | 10 +++++++++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index b041275063b..e5be8da9d92 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -43,6 +43,10 @@ Per file configuration with multiple languages ${tc} = Check Test Case ตัวอย่าง Should Be Equal ${tc.doc} приклад +Invalid per file configuration + Error in file 0 parsing/translations/per_file_config/many.robot 6 + ... Invalid language configuration: No language with name 'invalid' found. + Per file configuration bleeds to other files [Documentation] This is a technical limitation and will hopefully change! Run Tests ${EMPTY} parsing/translations/per_file_config/fi.robot parsing/translations/finnish/tests.robot diff --git a/atest/testdata/parsing/translations/per_file_config/many.robot b/atest/testdata/parsing/translations/per_file_config/many.robot index a5bedbc115a..69b5d7c208b 100644 --- a/atest/testdata/parsing/translations/per_file_config/many.robot +++ b/atest/testdata/parsing/translations/per_file_config/many.robot @@ -3,7 +3,7 @@ LANGUAGE: Brazilian Portuguese This is not language: config -language: THAI language: ukrainian +language: THAI language: invalid language: ukrainian *** Einstellungen *** Documentação Exemplo diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index ccdd70d62a4..7559ad20fce 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -46,10 +46,7 @@ def _add_language(self, lang): self.bdd_prefixes |= {p.title() for p in lang.bdd_prefixes} def add_language(self, name): - try: - lang = Language.from_name(name) - except ValueError: - raise # FIXME: Proper error handling!! + lang = Language.from_name(name) self._add_language(lang) def _get_languages(self, languages): diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 7f3197bd346..7a5bf02053f 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -122,7 +122,15 @@ def input(self, statement): for token in statement: match = self.language.match(token.value) if match: - self.ctx.add_language(match.group(1).strip()) + try: + self.ctx.add_language(match.group(1).strip()) + except ValueError as err: + token.set_error(f'Invalid language configuration: {err}') + + def lex(self): + for token in self.statement: + if not token.type: + token.type = self.token_type class SettingLexer(StatementLexer): From 969013b2596fa8d6e35f27614859efbfcfa34169 Mon Sep 17 00:00:00 2001 From: Ossi R <ossi@robocorp.com> Date: Fri, 19 Aug 2022 12:00:13 +0300 Subject: [PATCH 0159/1592] Expose attributes from control structures to listeners (#4341) Fixes #4335 --- .../keyword_attributes.robot | 0 .../listener_interface/listener_methods.robot | 12 +- .../listeners/VerifyAttributes.py | 138 ++++++++++++++++++ .../listeners/attributeverifyinglistener.py | 111 -------------- .../ListenerInterface.rst | 40 ++++- src/robot/output/listenerarguments.py | 16 +- 6 files changed, 201 insertions(+), 116 deletions(-) create mode 100644 atest/robot/output/listener_interface/keyword_attributes.robot create mode 100644 atest/testresources/listeners/VerifyAttributes.py delete mode 100644 atest/testresources/listeners/attributeverifyinglistener.py diff --git a/atest/robot/output/listener_interface/keyword_attributes.robot b/atest/robot/output/listener_interface/keyword_attributes.robot new file mode 100644 index 00000000000..e69de29bb2d diff --git a/atest/robot/output/listener_interface/listener_methods.robot b/atest/robot/output/listener_interface/listener_methods.robot index ff96e7c101a..46b40ede58c 100644 --- a/atest/robot/output/listener_interface/listener_methods.robot +++ b/atest/robot/output/listener_interface/listener_methods.robot @@ -21,7 +21,7 @@ Listen Some Correct Attributes To Listener Methods ${status} = Log File %{TEMPDIR}/${ATTR_TYPE_FILE} - Stderr Should Not Contain attributeverifyinglistener + Stderr Should Not Contain VerifyAttributes Should Not Contain ${status} FAILED Keyword Tags @@ -56,13 +56,19 @@ Test Template Stderr Should Be Empty Keyword Arguments Are Always Strings - ${result} = Run Tests --listener attributeverifyinglistener ${LISTENER DIR}/keyword_argument_types.robot + ${result} = Run Tests --listener VerifyAttributes ${LISTENER DIR}/keyword_argument_types.robot Should Be Empty ${result.stderr} Check Test Tags Run Keyword with already resolved non-string arguments in test data 1 2 Check Test Case Run Keyword with non-string arguments in library ${status} = Log File %{TEMPDIR}/${ATTR_TYPE_FILE} Should Not Contain ${status} FAILED +Keyword Attributes For Control Structures + Run Tests --listener VerifyAttributes misc/for_loops.robot misc/while.robot misc/try_except.robot + Stderr Should Be Empty + ${status} = Log File %{TEMPDIR}/${ATTR_TYPE_FILE} + Should Not Contain ${status} FAILED + TimeoutError occurring during listener method is propagaged [Documentation] Timeouts can only occur inside `log_message`. ... Cannot reliable set timeouts to occur during it, so the listener @@ -79,7 +85,7 @@ Run Tests With Listeners ... --listener ListenAll:%{TEMPDIR}${/}${ALL_FILE2} ... --listener module_listener ... --listener listeners.ListenSome - ... --listener attributeverifyinglistener + ... --listener VerifyAttributes ... --metadata ListenerMeta:Hello Run Tests ${args} misc/pass_and_fail.robot diff --git a/atest/testresources/listeners/VerifyAttributes.py b/atest/testresources/listeners/VerifyAttributes.py new file mode 100644 index 00000000000..64d94e01c58 --- /dev/null +++ b/atest/testresources/listeners/VerifyAttributes.py @@ -0,0 +1,138 @@ +import os + +try: + basestring + long +except NameError: + basestring = str + long = int + + +OUTFILE = open(os.path.join(os.getenv('TEMPDIR'), 'listener_attrs.txt'), 'w') +START = 'doc starttime ' +END = START + 'endtime elapsedtime status ' +SUITE = 'id longname metadata source tests suites totaltests ' +TEST = 'id longname tags template originalname source lineno ' +KW = 'kwname libname args assign tags type lineno source status ' +KW_TYPES = {'FOR': 'variables flavor values', + 'WHILE': 'condition limit', + 'IF': 'condition', + 'ELSE IF': 'condition', + 'EXCEPT': 'patterns pattern_type variable', + 'RETURN': 'values'} +EXPECTED_TYPES = {'tags': [basestring], + 'args': [basestring], + 'assign': [basestring], + 'metadata': {basestring: basestring}, + 'tests': [basestring], + 'suites': [basestring], + 'totaltests': int, + 'elapsedtime': (int, long), + 'lineno': (int, type(None)), + 'source': (basestring, type(None)), + 'variables': (dict, list), + 'flavor': basestring, + 'values': (list, dict), + 'condition': basestring, + 'limit': (basestring, type(None)), + 'patterns': (basestring, list), + 'pattern_type': (basestring, type(None)), + 'variable': (basestring, type(None))} + + +def verify_attrs(method_name, attrs, names): + names = set(names.split()) + OUTFILE.write(method_name + '\n') + if len(names) != len(attrs): + OUTFILE.write('FAILED: wrong number of attributes\n') + OUTFILE.write('Expected: %s\nActual: %s\n' % (names, attrs.keys())) + return + for name in names: + value = attrs[name] + exp_type = EXPECTED_TYPES.get(name, basestring) + if isinstance(exp_type, list): + verify_attr(name, value, list) + for index, item in enumerate(value): + verify_attr('%s[%s]' % (name, index), item, exp_type[0]) + elif isinstance(exp_type, dict): + verify_attr(name, value, dict) + key_type, value_type = dict(exp_type).popitem() + for key, value in value.items(): + verify_attr('%s[%s] (key)' % (name, key), key, key_type) + verify_attr('%s[%s] (value)' % (name, key), value, value_type) + else: + verify_attr(name, value, exp_type) + + +def verify_attr(name, value, exp_type): + if isinstance(value, exp_type): + OUTFILE.write('PASSED | %s: %s\n' % (name, format_value(value))) + else: + OUTFILE.write('FAILED | %s: %r, Expected: %s, Actual: %s\n' + % (name, value, exp_type, type(value))) + + +def format_value(value): + if isinstance(value, basestring): + return value + if isinstance(value, (int, long)): + return str(value) + if isinstance(value, list): + return '[%s]' % ', '.join(format_value(item) for item in value) + if isinstance(value, dict): + return '{%s}' % ', '.join('%s: %s' % (format_value(k), format_value(v)) + for k, v in value.items()) + if value is None: + return 'None' + return 'FAILED! Invalid argument type %s.' % type(value) + + +def verify_name(name, kwname=None, libname=None, **ignored): + if libname: + if name != '%s.%s' % (libname, kwname): + OUTFILE.write("FAILED | KW NAME: '%s' != '%s.%s'\n" % (name, libname, kwname)) + else: + if name != kwname: + OUTFILE.write("FAILED | KW NAME: '%s' != '%s'\n" % (name, kwname)) + if libname != '': + OUTFILE.write("FAILED | LIB NAME: '%s' != ''\n" % libname) + + +class VerifyAttributes: + ROBOT_LISTENER_API_VERSION = '2' + + def __init__(self): + self._keyword_stack = [] + + def start_suite(self, name, attrs): + verify_attrs('START SUITE', attrs, START + SUITE) + + def end_suite(self, name, attrs): + verify_attrs('END SUITE', attrs, END + SUITE + 'statistics message') + + def start_test(self, name, attrs): + verify_attrs('START TEST', attrs, START + TEST) + + def end_test(self, name, attrs): + verify_attrs('END TEST', attrs, END + TEST + 'message') + + def start_keyword(self, name, attrs): + type_ = attrs['type'] + extra = KW_TYPES.get(type_, '') + if type_ == 'ITERATION' and self._keyword_stack[-1] == 'FOR': + extra += ' variables' + verify_attrs('START ' + type_, attrs, START + KW + extra) + verify_name(name, **attrs) + self._keyword_stack.append(type_) + + def end_keyword(self, name, attrs): + self._keyword_stack.pop() + type_ = attrs['type'] + extra = KW_TYPES.get(type_, '') + if type_ == 'ITERATION' and self._keyword_stack[-1] == 'FOR': + extra += ' variables' + verify_attrs('END ' + type_, attrs, END + KW + extra) + verify_name(name, **attrs) + + def close(self): + OUTFILE.close() diff --git a/atest/testresources/listeners/attributeverifyinglistener.py b/atest/testresources/listeners/attributeverifyinglistener.py deleted file mode 100644 index 079f43b64d5..00000000000 --- a/atest/testresources/listeners/attributeverifyinglistener.py +++ /dev/null @@ -1,111 +0,0 @@ -import os - -try: - basestring - long -except NameError: - basestring = str - long = int - - -ROBOT_LISTENER_API_VERSION = '2' - -OUTFILE = open(os.path.join(os.getenv('TEMPDIR'), 'listener_attrs.txt'), 'w') -START = 'doc starttime ' -END = START + 'endtime elapsedtime status ' -SUITE = 'id longname metadata source tests suites totaltests ' -TEST = 'id longname tags template originalname source lineno ' -KW = 'kwname libname args assign tags type lineno source status ' -EXPECTED_TYPES = {'tags': [basestring], 'args': [basestring], - 'assign': [basestring], 'metadata': {basestring: basestring}, - 'tests': [basestring], 'suites': [basestring], - 'totaltests': int, 'elapsedtime': (int, long), - 'lineno': (int, type(None)), 'source': (basestring, type(None))} - - -def start_suite(name, attrs): - _verify_attrs('START SUITE', attrs, START + SUITE) - - -def end_suite(name, attrs): - _verify_attrs('END SUITE', attrs, END + SUITE + 'statistics message') - - -def start_test(name, attrs): - _verify_attrs('START TEST', attrs, START + TEST) - - -def end_test(name, attrs): - _verify_attrs('END TEST', attrs, END + TEST + 'message') - - -def start_keyword(name, attrs): - _verify_attrs('START KEYWORD', attrs, START + KW) - _verify_name(name, **attrs) - - -def end_keyword(name, attrs): - _verify_attrs('END KEYWORD', attrs, END + KW) - _verify_name(name, **attrs) - - -def _verify_attrs(method_name, attrs, names): - names = set(names.split()) - OUTFILE.write(method_name + '\n') - if len(names) != len(attrs): - OUTFILE.write('FAILED: wrong number of attributes\n') - OUTFILE.write('Expected: %s\nActual: %s\n' % (names, attrs.keys())) - return - for name in names: - value = attrs[name] - exp_type = EXPECTED_TYPES.get(name, basestring) - if isinstance(exp_type, list): - _verify_attr(name, value, list) - for index, item in enumerate(value): - _verify_attr('%s[%s]' % (name, index), item, exp_type[0]) - elif isinstance(exp_type, dict): - _verify_attr(name, value, dict) - key_type, value_type = dict(exp_type).popitem() - for key, value in value.items(): - _verify_attr('%s[%s] (key)' % (name, key), key, key_type) - _verify_attr('%s[%s] (value)' % (name, key), value, value_type) - else: - _verify_attr(name, value, exp_type) - - -def _verify_attr(name, value, exp_type): - if isinstance(value, exp_type): - OUTFILE.write('PASSED | %s: %s\n' % (name, _format(value))) - else: - OUTFILE.write('FAILED | %s: %r, Expected: %s, Actual: %s\n' - % (name, value, exp_type, type(value))) - - -def _format(value): - if isinstance(value, basestring): - return value - if isinstance(value, (int, long)): - return str(value) - if isinstance(value, list): - return '[%s]' % ', '.join(_format(item) for item in value) - if isinstance(value, dict): - return '{%s}' % ', '.join('%s: %s' % (_format(k), _format(v)) - for k, v in value.items()) - if value is None: - return 'None' - return 'FAILED! Invalid argument type %s.' % type(value) - - -def _verify_name(name, kwname=None, libname=None, **ignored): - if libname: - if name != '%s.%s' % (libname, kwname): - OUTFILE.write("FAILED | KW NAME: '%s' != '%s.%s'\n" % (name, libname, kwname)) - else: - if name != kwname: - OUTFILE.write("FAILED | KW NAME: '%s' != '%s'\n" % (name, kwname)) - if libname != '': - OUTFILE.write("FAILED | LIB NAME: '%s' != ''\n" % libname) - - -def close(): - OUTFILE.close() diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 8cbe745ebeb..8190d7accc9 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -212,12 +212,15 @@ it. If that is needed, `listener version 3`_ can be used instead. | start_keyword | name, attributes | Called when a keyword or a control structure such as `IF/ELSE` | | | | or `TRY/EXCEPT` starts. | | | | | + | | | Control structures have additional attributes, which change | + | | | based on the `type` attribute. | + | | | | | | | With keywords `name` is the full keyword name containing | | | | possible library or resource name as a prefix like | | | | `MyLibrary.Example Keyword`. With control structures `name` | | | | contains string representation of parameters. | | | | | - | | | Contents of the attribute dictionary: | + | | | Shared contents of the attribute dictionary: | | | | | | | | * `type`: String specifying type of the started item. Possible | | | | values are: `KEYWORD`, `SETUP`, `TEARDOWN`, `FOR`, `WHILE`, | @@ -243,6 +246,37 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | not executed (e.g. due to an earlier failure), `NOT SET` | | | | otherwise. New in RF 4.0. | | | | * `starttime`: Keyword execution start time. | + | | | | + | | | Additional contents for `FOR` types: | + | | | | + | | | * `variables`: Assigned variables for each loop iteration | + | | | * `flavor`: Type of loop (e.g. `IN RANGE`) | + | | | * `values`: List of values being looped over | + | | | | + | | | Additional contents for `ITERATION` types: | + | | | | + | | | * `variables`: Variables and string representations of their | + | | | contents for one `FOR` loop iteration | + | | | | + | | | Additional contents for `WHILE` types: | + | | | | + | | | * `condition`: The looping condition | + | | | * `limit`: The maximum iteration limit | + | | | | + | | | Additional contents for `IF` and `ELSE_IF` types: | + | | | | + | | | * `condition`: The conditional expression being evaluated | + | | | | + | | | Additional contents for `EXCEPT` types: | + | | | | + | | | * `patterns`: The exception pattern being matched | + | | | * `pattern_type`: The type of pattern match (e.g. `GLOB`) | + | | | * `variable`: The variable containing the captured exception | + | | | | + | | | Additional contents for `RETURN` types: | + | | | | + | | | * `values`: Return values from a keyword | + | | | | +------------------+------------------+----------------------------------------------------------------+ | end_keyword | name, attributes | Called when a keyword ends. | | | | | @@ -250,6 +284,10 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | possible library or resource name as a prefix. | | | | For example, `MyLibrary.Example Keyword`. | | | | | + | | | Control structures have additional attributes, which change | + | | | based on the `type` attribute. For descriptions of all | + | | | possible attributes, see the `start_keyword` section. | + | | | | | | | Contents of the attribute dictionary: | | | | | | | | * `type`: Same as with `start_keyword`. | diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 817bb5141e6..f2ee7402c0d 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from robot.model import BodyItem from robot.utils import is_list_like, is_dict_like, is_string, safe_str @@ -128,10 +129,23 @@ class EndTestArguments(StartTestArguments): class StartKeywordArguments(_ListenerArgumentsFromItem): _attribute_names = ('doc', 'assign', 'tags', 'lineno', 'source', 'type', 'status', 'starttime') + _type_attributes = { + BodyItem.FOR: ('variables', 'flavor', 'values'), + BodyItem.IF: ('condition',), + BodyItem.ELSE_IF: ('condition'), + BodyItem.EXCEPT: ('patterns', 'pattern_type', 'variable'), + BodyItem.WHILE: ('condition', 'limit'), + BodyItem.RETURN: ('values',), + BodyItem.ITERATION: ('variables',)} def _get_extra_attributes(self, kw): args = [a if is_string(a) else safe_str(a) for a in kw.args] - return {'kwname': kw.kwname or '', 'libname': kw.libname or '', 'args': args} + attrs = {'kwname': kw.kwname or '', 'libname': kw.libname or '', 'args': args} + if kw.type in self._type_attributes: + attrs.update({name: self._get_attribute_value(kw, name) + for name in self._type_attributes[kw.type] + if hasattr(kw, name)}) + return attrs class EndKeywordArguments(StartKeywordArguments): From a9403e890f858976558cbcba6042d07c59ed342f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 19 Aug 2022 12:03:03 +0300 Subject: [PATCH 0160/1592] Remove Python 2 compatibility from test code --- .../listeners/VerifyAttributes.py | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/atest/testresources/listeners/VerifyAttributes.py b/atest/testresources/listeners/VerifyAttributes.py index 64d94e01c58..bc26632761f 100644 --- a/atest/testresources/listeners/VerifyAttributes.py +++ b/atest/testresources/listeners/VerifyAttributes.py @@ -1,13 +1,5 @@ import os -try: - basestring - long -except NameError: - basestring = str - long = int - - OUTFILE = open(os.path.join(os.getenv('TEMPDIR'), 'listener_attrs.txt'), 'w') START = 'doc starttime ' END = START + 'endtime elapsedtime status ' @@ -20,24 +12,24 @@ 'ELSE IF': 'condition', 'EXCEPT': 'patterns pattern_type variable', 'RETURN': 'values'} -EXPECTED_TYPES = {'tags': [basestring], - 'args': [basestring], - 'assign': [basestring], - 'metadata': {basestring: basestring}, - 'tests': [basestring], - 'suites': [basestring], +EXPECTED_TYPES = {'tags': [str], + 'args': [str], + 'assign': [str], + 'metadata': {str: str}, + 'tests': [str], + 'suites': [str], 'totaltests': int, - 'elapsedtime': (int, long), + 'elapsedtime': int, 'lineno': (int, type(None)), - 'source': (basestring, type(None)), + 'source': (str, type(None)), 'variables': (dict, list), - 'flavor': basestring, + 'flavor': str, 'values': (list, dict), - 'condition': basestring, - 'limit': (basestring, type(None)), - 'patterns': (basestring, list), - 'pattern_type': (basestring, type(None)), - 'variable': (basestring, type(None))} + 'condition': str, + 'limit': (str, type(None)), + 'patterns': (str, list), + 'pattern_type': (str, type(None)), + 'variable': (str, type(None))} def verify_attrs(method_name, attrs, names): @@ -49,7 +41,7 @@ def verify_attrs(method_name, attrs, names): return for name in names: value = attrs[name] - exp_type = EXPECTED_TYPES.get(name, basestring) + exp_type = EXPECTED_TYPES.get(name, str) if isinstance(exp_type, list): verify_attr(name, value, list) for index, item in enumerate(value): @@ -73,9 +65,9 @@ def verify_attr(name, value, exp_type): def format_value(value): - if isinstance(value, basestring): + if isinstance(value, str): return value - if isinstance(value, (int, long)): + if isinstance(value, int): return str(value) if isinstance(value, list): return '[%s]' % ', '.join(format_value(item) for item in value) From 9f763a4011e6c6485e793752f077531eb24648a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 19 Aug 2022 12:11:25 +0300 Subject: [PATCH 0161/1592] Documentation tuning related to #4335. Enhance documentation related to new attributes passed to start/end_keyword listener methods with various control structures a bit. Most importantly, add a note that this is new in RF 5.1. --- .../ListenerInterface.rst | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 8190d7accc9..3c771fe3949 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -212,15 +212,16 @@ it. If that is needed, `listener version 3`_ can be used instead. | start_keyword | name, attributes | Called when a keyword or a control structure such as `IF/ELSE` | | | | or `TRY/EXCEPT` starts. | | | | | - | | | Control structures have additional attributes, which change | - | | | based on the `type` attribute. | - | | | | | | | With keywords `name` is the full keyword name containing | | | | possible library or resource name as a prefix like | | | | `MyLibrary.Example Keyword`. With control structures `name` | | | | contains string representation of parameters. | | | | | - | | | Shared contents of the attribute dictionary: | + | | | Keywords and control structures share most of attributes, but | + | | | control structures can have additional attributes depending | + | | | on their `type`. | + | | | | + | | | Shared attributes: | | | | | | | | * `type`: String specifying type of the started item. Possible | | | | values are: `KEYWORD`, `SETUP`, `TEARDOWN`, `FOR`, `WHILE`, | @@ -247,35 +248,37 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | otherwise. New in RF 4.0. | | | | * `starttime`: Keyword execution start time. | | | | | - | | | Additional contents for `FOR` types: | + | | | Additional attributes for `FOR` types: | | | | | - | | | * `variables`: Assigned variables for each loop iteration | - | | | * `flavor`: Type of loop (e.g. `IN RANGE`) | - | | | * `values`: List of values being looped over | + | | | * `variables`: Assigned variables for each loop iteration. | + | | | * `flavor`: Type of loop (e.g. `IN RANGE`). | + | | | * `values`: List of values being looped over. | | | | | - | | | Additional contents for `ITERATION` types: | + | | | Additional attributes for `ITERATION` types: | | | | | | | | * `variables`: Variables and string representations of their | - | | | contents for one `FOR` loop iteration | + | | | contents for one `FOR` loop iteration. | + | | | | + | | | Additional attributes for `WHILE` types: | | | | | - | | | Additional contents for `WHILE` types: | + | | | * `condition`: The looping condition. | + | | | * `limit`: The maximum iteration limit. | | | | | - | | | * `condition`: The looping condition | - | | | * `limit`: The maximum iteration limit | + | | | Additional attributes for `IF` and `ELSE_IF` types: | | | | | - | | | Additional contents for `IF` and `ELSE_IF` types: | + | | | * `condition`: The conditional expression being evaluated. | | | | | - | | | * `condition`: The conditional expression being evaluated | + | | | Additional attributes for `EXCEPT` types: | | | | | - | | | Additional contents for `EXCEPT` types: | + | | | * `patterns`: The exception pattern being matched. | + | | | * `pattern_type`: The type of pattern match (e.g. `GLOB`). | + | | | * `variable`: The variable containing the captured exception. | | | | | - | | | * `patterns`: The exception pattern being matched | - | | | * `pattern_type`: The type of pattern match (e.g. `GLOB`) | - | | | * `variable`: The variable containing the captured exception | + | | | Additional attributes for `RETURN` types: | | | | | - | | | Additional contents for `RETURN` types: | + | | | * `values`: Return values from a keyword. | | | | | - | | | * `values`: Return values from a keyword | + | | | Additional attributes for control structures are new in RF 5.1.| | | | | +------------------+------------------+----------------------------------------------------------------+ | end_keyword | name, attributes | Called when a keyword ends. | From 2c72cda61957427e0c0383eb169896b174f0d4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 19 Aug 2022 15:07:01 +0300 Subject: [PATCH 0162/1592] Make Languages part of the public API and enhance it. --- src/robot/api/__init__.py | 2 +- src/robot/conf/languages.py | 15 +++++++-------- src/robot/parsing/lexer/settings.py | 2 +- utest/api/test_languages.py | 19 +++++++++++++++++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/robot/api/__init__.py b/src/robot/api/__init__.py index ea80fc8e4a7..f4654e3852c 100644 --- a/src/robot/api/__init__.py +++ b/src/robot/api/__init__.py @@ -70,7 +70,7 @@ via the :mod:`robot` root package. """ -from robot.conf.languages import Language +from robot.conf.languages import Language, Languages from robot.model import SuiteVisitor from robot.parsing import (get_tokens, get_resource_tokens, get_init_tokens, get_model, get_resource_model, get_init_model, diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 7559ad20fce..1c15637c0c4 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -21,7 +21,7 @@ class Languages: - def __init__(self, languages): + def __init__(self, languages=None): self.languages = [] # The English singular forms are added for backwards compatibility self.headers = { @@ -37,6 +37,12 @@ def __init__(self, languages): for lang in self._get_languages(languages): self._add_language(lang) + def reset(self, languages=None): + self.__init__(languages) + + def add_language(self, name): + self._add_language(Language.from_name(name)) + def _add_language(self, lang): if lang in self.languages: return @@ -45,10 +51,6 @@ def _add_language(self, lang): self.settings.update({n.title(): lang.settings[n] for n in lang.settings if n}) self.bdd_prefixes |= {p.title() for p in lang.bdd_prefixes} - def add_language(self, name): - lang = Language.from_name(name) - self._add_language(lang) - def _get_languages(self, languages): languages = self._resolve_languages(languages) available = self._get_available_languages() @@ -92,9 +94,6 @@ def is_language(member): module = Importer('language file').import_module(lang) return [value() for _, value in inspect.getmembers(module, is_language)] - def translate_setting(self, name): - return self.settings.get(name, name) - def __iter__(self): return iter(self.languages) diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 8d774f863d7..0c74511337f 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -59,7 +59,7 @@ def lex(self, statement): setting = statement[0] orig = self._format_name(setting.value) name = normalize_whitespace(orig).title() - name = self.languages.translate_setting(name) + name = self.languages.settings.get(name, name) if name in self.aliases: name = self.aliases[name] try: diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index ad0e8d8c5b3..226d3df3da5 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -1,7 +1,7 @@ import unittest -from robot.api import Language -from robot.conf.languages import Languages, En, Fi, PtBr, Th +from robot.api import Language, Languages +from robot.conf.languages import En, Fi, PtBr, Th from robot.utils.asserts import assert_equal, assert_not_equal, assert_raises_with_msg @@ -72,6 +72,21 @@ def test_no_match(self): class TestLanguages(unittest.TestCase): + def test_init(self): + assert_equal(list(Languages()), [En()]) + assert_equal(list(Languages('fi')), [Fi(), En()]) + assert_equal(list(Languages(['fi'])), [Fi(), En()]) + assert_equal(list(Languages(['fi', PtBr()])), [Fi(), PtBr(), En()]) + + def test_reset(self): + langs = Languages(['fi']) + langs.reset() + assert_equal(list(langs), [En()]) + langs.reset('fi') + assert_equal(list(langs), [Fi(), En()]) + langs.reset(['fi', PtBr()]) + assert_equal(list(langs), [Fi(), PtBr(), En()]) + def test_duplicates_are_not_added(self): langs = Languages(['Finnish', 'en', Fi(), 'pt-br']) assert_equal(list(langs), [Fi(), En(), PtBr()]) From 13e60820862099f61436dc8418a61ad61dee6bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 23 Aug 2022 10:40:58 +0300 Subject: [PATCH 0163/1592] Refactor test data a bit, TRY/EXCEPT FTW! --- .../keywords/type_conversion/conversion.resource | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/atest/testdata/keywords/type_conversion/conversion.resource b/atest/testdata/keywords/type_conversion/conversion.resource index 9336e3e25bc..3d13ec3000b 100644 --- a/atest/testdata/keywords/type_conversion/conversion.resource +++ b/atest/testdata/keywords/type_conversion/conversion.resource @@ -2,11 +2,14 @@ Conversion Should Fail [Arguments] ${kw} @{args} ${error}= ${type}=${kw.lower()} ${arg_type}= &{kwargs} ${arg} = Evaluate (list($args) + list($kwargs.values()))[0] - ${arg_type} = Set Variable If $arg_type ${SPACE}(${arg_type}) ${EMPTY} - ${end} = Set Variable If $error : ${error} . - Run Keyword And Expect Error - ... ${{'GLOB' if '*' in $error else 'EQUALS'}}: ValueError: Argument 'argument' got value '${arg}'${arg_type} that cannot be converted to ${type}${end} - ... ${kw} @{args} &{kwargs} + ${message} = Catenate + ... Argument 'argument' got value '${arg}'${{" (${arg_type})" if $arg_type else ""}} + ... that cannot be converted to ${type}${{": ${error}" if $error else "."}} + TRY + Run Keyword ${kw} @{args} &{kwargs} + EXCEPT ValueError: ${message} type=${{'GLOB' if '*' in $error else 'LITERAL'}} + No Operation + END String None is converted to None object [Arguments] ${kw} From a570682134485016c47b956c0473bc3035d72999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 23 Aug 2022 11:11:01 +0300 Subject: [PATCH 0164/1592] Support `None` as an argument converter to enable type validation. Implements #4440. --- .../type_conversion/custom_converters.robot | 3 ++ .../type_conversion/CustomConverters.py | 9 ++++ .../type_conversion/custom_converters.robot | 5 +++ .../CreatingTestLibraries.rst | 44 +++++++++++++++++++ .../running/arguments/customconverters.py | 4 ++ 5 files changed, 65 insertions(+) diff --git a/atest/robot/keywords/type_conversion/custom_converters.robot b/atest/robot/keywords/type_conversion/custom_converters.robot index 3c55d0f498f..4e329dacca7 100644 --- a/atest/robot/keywords/type_conversion/custom_converters.robot +++ b/atest/robot/keywords/type_conversion/custom_converters.robot @@ -24,6 +24,9 @@ Accept subscripted generics Failing conversion Check Test Case ${TESTNAME} +`None` as strict converter + Check Test Case ${TESTNAME} + Invalid converters Check Test Case ${TESTNAME} Validate Errors diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index 693766490de..dbe56008fbf 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -56,6 +56,10 @@ def __init__(self, numbers: List[int]): self.sum = sum(numbers) +class Strict: + pass + + class Invalid: pass @@ -81,6 +85,7 @@ def __init__(self, arg, *, kwo): ClassAsConverter: ClassAsConverter, ClassWithHintsAsConverter: ClassWithHintsAsConverter, AcceptSubscriptedGenerics: AcceptSubscriptedGenerics, + Strict: None, Invalid: 666, TooFewArgs: TooFewArgs, TooManyArgs: TooManyArgs, @@ -133,6 +138,10 @@ def int_or_number(number: Union[int, Number]): assert number == 1 +def strict(argument: Strict): + assert isinstance(argument, Strict) + + def invalid(a: Invalid, b: TooFewArgs, c: TooManyArgs, d: KwOnlyNotOk): assert (a, b, c, d) == ('a', 'b', 'c', 'd') diff --git a/atest/testdata/keywords/type_conversion/custom_converters.robot b/atest/testdata/keywords/type_conversion/custom_converters.robot index f8727bf48d7..94c6cf43a35 100644 --- a/atest/testdata/keywords/type_conversion/custom_converters.robot +++ b/atest/testdata/keywords/type_conversion/custom_converters.robot @@ -52,6 +52,11 @@ Failing conversion Class with hints as converter ... ${1.2} type=ClassWithHintsAsConverter arg_type=float +`None` as strict converter + Strict ${{CustomConverters.Strict()}} + Conversion should fail Strict wrong type + ... type=Strict error=TypeError: Only Strict instances are accepted, got string. + Invalid converters Invalid a b c d diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 24929d323d2..70a67a38909 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1669,6 +1669,50 @@ the default date_ conversion: def any(self, arg: Union[FiDate, UsDate, date]): print(f'year: {arg.year}, month: {arg.month}, day: {arg.day}') + +Strict type validation +`````````````````````` + +Converters are not used at all if the argument is of the specified type to +begin with. It is thus easy to enable strict type validation with a custom +converter that does not accept any value. For example, the :name:`Example` +keyword accepts only `Strict` instances: + +.. sourcecode:: python + + class Strict: + pass + + + def strict(arg): + raise TypeError(f'Only Strict instances accepted, got {type(arg).__name__}.') + + + ROBOT_LIBRARY_CONVERTERS = {Strict: strict} + + + def example(argument: Strict): + assert isinstance(argument, Strict) + +As a convenience, Robot Framework allows setting converter to `None` to get +the same effect. For example, this code behaves example the same way as +the code above: + +.. sourcecode:: python + + class Strict: + pass + + + ROBOT_LIBRARY_CONVERTERS = {Strict: None} + + + def example(argument: Strict): + assert isinstance(argument, Strict) + +.. note:: Using `None` as a strict converter is new in Robot Framework 5.1. + With earlier versions it causes and error. + Converter documentation ``````````````````````` diff --git a/src/robot/running/arguments/customconverters.py b/src/robot/running/arguments/customconverters.py index 726205ca86a..20f9b76f423 100644 --- a/src/robot/running/arguments/customconverters.py +++ b/src/robot/running/arguments/customconverters.py @@ -69,6 +69,10 @@ def for_converter(cls, type_, converter): if not isinstance(type_, type): raise TypeError(f'Custom converters must be specified using types, ' f'got {type_name(type_)} {type_!r}.') + if converter is None: + def converter(arg): + raise TypeError(f'Only {type_.__name__} instances are accepted, ' + f'got {type_name(arg)}.') if not callable(converter): raise TypeError(f'Custom converters must be callable, converter for ' f'{type_name(type_)} is {type_name(converter)}.') From 0bf44823b56b3e858b7b0cacece7753d29339f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 26 Aug 2022 18:45:01 +0300 Subject: [PATCH 0165/1592] Typo fix and other doc tuning based on comments. --- .../CreatingTestLibraries.rst | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 70a67a38909..8218ce125ab 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1676,42 +1676,42 @@ Strict type validation Converters are not used at all if the argument is of the specified type to begin with. It is thus easy to enable strict type validation with a custom converter that does not accept any value. For example, the :name:`Example` -keyword accepts only `Strict` instances: +keyword accepts only `StrictType` instances: .. sourcecode:: python - class Strict: + class StrictType: pass - def strict(arg): - raise TypeError(f'Only Strict instances accepted, got {type(arg).__name__}.') + def strict_converter(arg): + raise TypeError(f'Only StrictType instances accepted, got {type(arg).__name__}.') - ROBOT_LIBRARY_CONVERTERS = {Strict: strict} + ROBOT_LIBRARY_CONVERTERS = {StrictType: strict_converter} - def example(argument: Strict): - assert isinstance(argument, Strict) + def example(argument: StrictType): + assert isinstance(argument, StrictType) As a convenience, Robot Framework allows setting converter to `None` to get -the same effect. For example, this code behaves example the same way as +the same effect. For example, this code behaves exactly the same way as the code above: .. sourcecode:: python - class Strict: + class StrictType: pass - ROBOT_LIBRARY_CONVERTERS = {Strict: None} + ROBOT_LIBRARY_CONVERTERS = {StrictType: None} - def example(argument: Strict): - assert isinstance(argument, Strict) + def example(argument: StrictType): + assert isinstance(argument, StrictType) .. note:: Using `None` as a strict converter is new in Robot Framework 5.1. - With earlier versions it causes and error. + An explicit converter function needs to be used with earlier versions. Converter documentation ``````````````````````` From 2ca83716b51060a4066f3ac51cdbfc134b1b6b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 26 Aug 2022 19:01:47 +0300 Subject: [PATCH 0166/1592] Cleanup --- src/robot/parsing/lexer/blocklexers.py | 4 ++-- src/robot/parsing/parser/fileparser.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index cfa7edfa896..2b448477c9a 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -57,8 +57,8 @@ def lexer_for(self, statement): if cls.handles(statement, self.ctx): lexer = cls(self.ctx) return lexer - raise TypeError("%s did not find lexer for statement %s." - % (type(self).__name__, statement)) + raise TypeError(f"{type(self).__name__} does not have lexer for " + f"statement {statement}.") def lexer_classes(self): return () diff --git a/src/robot/parsing/parser/fileparser.py b/src/robot/parsing/parser/fileparser.py index 75ce3bc3ff9..5c42ef664ae 100644 --- a/src/robot/parsing/parser/fileparser.py +++ b/src/robot/parsing/parser/fileparser.py @@ -27,7 +27,7 @@ class FileParser(Parser): def __init__(self, source=None): - Parser.__init__(self, File(source=self._get_path(source))) + super().__init__(File(source=self._get_path(source))) def _get_path(self, source): if not source: @@ -62,7 +62,7 @@ class SectionParser(Parser): model_class = None def __init__(self, header): - Parser.__init__(self, self.model_class(header)) + super().__init__(self.model_class(header)) def handles(self, statement): return statement.type not in Token.HEADER_TOKENS From 905745055e25373d20917abd1f8a691263b611de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 27 Aug 2022 00:01:00 +0300 Subject: [PATCH 0167/1592] Enhancements for per-file language config. - Config tokens and statements in model have separate type (CONFIG) and node (Config), respectively. - Config must start a row and no other content, except explicit comments, are allowed on same row. - File node has `languages` attribute that contains detected languages as a tuple of language codes. --- atest/robot/parsing/translations.robot | 2 +- .../translations/per_file_config/many.robot | 6 +- src/robot/api/parsing.py | 6 +- src/robot/parsing/lexer/statementlexers.py | 16 +-- src/robot/parsing/lexer/tokens.py | 1 + src/robot/parsing/model/blocks.py | 5 +- src/robot/parsing/model/statements.py | 18 ++++ src/robot/parsing/parser/fileparser.py | 1 + src/robot/parsing/parser/parser.py | 14 ++- utest/parsing/test_lexer.py | 100 ++++++++++++++++-- utest/parsing/test_model.py | 58 +++++++++- 11 files changed, 199 insertions(+), 28 deletions(-) diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index e5be8da9d92..5c178405264 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -44,7 +44,7 @@ Per file configuration with multiple languages Should Be Equal ${tc.doc} приклад Invalid per file configuration - Error in file 0 parsing/translations/per_file_config/many.robot 6 + Error in file 0 parsing/translations/per_file_config/many.robot 4 ... Invalid language configuration: No language with name 'invalid' found. Per file configuration bleeds to other files diff --git a/atest/testdata/parsing/translations/per_file_config/many.robot b/atest/testdata/parsing/translations/per_file_config/many.robot index 69b5d7c208b..bad92b36422 100644 --- a/atest/testdata/parsing/translations/per_file_config/many.robot +++ b/atest/testdata/parsing/translations/per_file_config/many.robot @@ -1,9 +1,11 @@ Language: DE LANGUAGE: Brazilian Portuguese -This is not language: config +language: invalid +language: bad again but not recognized due to this text -language: THAI language: invalid language: ukrainian +language: THAI # comment here is fine +language:ukrainian *** Einstellungen *** Documentação Exemplo diff --git a/src/robot/api/parsing.py b/src/robot/api/parsing.py index 7ec3de74b7f..ce8af1cd8c0 100644 --- a/src/robot/api/parsing.py +++ b/src/robot/api/parsing.py @@ -239,6 +239,7 @@ class were exposed directly via the :mod:`robot.api` package, but other - :class:`~robot.parsing.model.statements.Break` - :class:`~robot.parsing.model.statements.Continue` - :class:`~robot.parsing.model.statements.Comment` +- :class:`~robot.parsing.model.statements.Config` (new in 5.1) - :class:`~robot.parsing.model.statements.Error` - :class:`~robot.parsing.model.statements.EmptyLine` @@ -257,7 +258,7 @@ class were exposed directly via the :mod:`robot.api` package, but other class TestNamePrinter(ModelVisitor): def visit_File(self, node): - print(f"File '{node.source}' has following tests:") + print(f"File '{node.source}' has the following tests:") # Call `generic_visit` to visit also child nodes. self.generic_visit(node) @@ -272,7 +273,7 @@ def visit_TestCaseName(self, node): When the above code is run using the earlier :file:`example.robot`, the output is this:: - File 'example.robot' has following tests: + File 'example.robot' has the following tests: - Example (on line 2) - Second example (on line 5) @@ -544,6 +545,7 @@ def visit_File(self, node): Continue, Break, Comment, + Config, Error, EmptyLine ) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 7a5bf02053f..2c5f4334606 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -115,17 +115,17 @@ class CommentLexer(SingleType): class ImplicitCommentLexer(CommentLexer): - language = re.compile(r'language:(.+)', re.IGNORECASE) def input(self, statement): super().input(statement) - for token in statement: - match = self.language.match(token.value) - if match: - try: - self.ctx.add_language(match.group(1).strip()) - except ValueError as err: - token.set_error(f'Invalid language configuration: {err}') + if len(statement) == 1 and statement[0].value.lower().startswith('language:'): + lang = statement[0].value.split(':', 1)[1].strip() + try: + self.ctx.add_language(lang) + except ValueError as err: + statement[0].set_error(f'Invalid language configuration: {err}') + else: + statement[0].type = Token.CONFIG def lex(self): for token in self.statement: diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 69a01db3bd9..fef909165f9 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -99,6 +99,7 @@ class Token: SEPARATOR = 'SEPARATOR' COMMENT = 'COMMENT' CONTINUATION = 'CONTINUATION' + CONFIG = 'CONFIG' EOL = 'EOL' EOS = 'EOS' diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 80d1508f00d..47f9a02a6ed 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -71,11 +71,12 @@ def __init__(self, header, body=None, errors=()): class File(Block): _fields = ('sections',) - _attributes = ('source',) + Block._attributes + _attributes = ('source', 'languages') + Block._attributes - def __init__(self, sections=None, source=None): + def __init__(self, sections=None, source=None, languages=()): self.sections = sections or [] self.source = source + self.languages = languages def save(self, output=None): """Save model to the given ``output`` or to the original source file. diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 1461bb4c7a6..5af603a8834 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -16,6 +16,7 @@ import ast import re +from robot.conf import Language from robot.running.arguments import UserKeywordArgumentParser from robot.utils import is_list_like, normalize_whitespace, seq2str, split_from_equals from robot.variables import is_scalar_assign, is_dict_variable, search_variable @@ -1057,6 +1058,23 @@ def from_params(cls, comment, indent=FOUR_SPACES, eol=EOL): ]) +@Statement.register +class Config(Statement): + type = Token.CONFIG + + @classmethod + def from_params(cls, config, eol=EOL): + return cls([ + Token(Token.CONFIG, config), + Token(Token.EOL, eol) + ]) + + @property + def language(self): + value = self.get_value(Token.CONFIG) + return Language.from_name(value[len('language:'):]) if value else None + + @Statement.register class Error(Statement): type = Token.ERROR diff --git a/src/robot/parsing/parser/fileparser.py b/src/robot/parsing/parser/fileparser.py index 5c42ef664ae..296d0a8f522 100644 --- a/src/robot/parsing/parser/fileparser.py +++ b/src/robot/parsing/parser/fileparser.py @@ -49,6 +49,7 @@ def parse(self, statement): Token.TASK_HEADER: TestCaseSectionParser, Token.KEYWORD_HEADER: KeywordSectionParser, Token.COMMENT_HEADER: CommentSectionParser, + Token.CONFIG: ImplicitCommentSectionParser, Token.COMMENT: ImplicitCommentSectionParser, Token.ERROR: ImplicitCommentSectionParser, Token.EOL: ImplicitCommentSectionParser diff --git a/src/robot/parsing/parser/parser.py b/src/robot/parsing/parser/parser.py index 1100e33f223..458f0d34dd4 100644 --- a/src/robot/parsing/parser/parser.py +++ b/src/robot/parsing/parser/parser.py @@ -14,7 +14,7 @@ # limitations under the License. from ..lexer import Token, get_tokens, get_resource_tokens, get_init_tokens -from ..model import Statement +from ..model import Statement, ModelVisitor from .fileparser import FileParser @@ -100,4 +100,16 @@ def _statements_to_model(statements, source=None): parser = stack[-1].parse(statement) if parser: stack.append(parser) + # Implicit comment sections have no header. + if model.sections and model.sections[0].header is None: + SetLanguages(model).visit(model.sections[0]) return model + + +class SetLanguages(ModelVisitor): + + def __init__(self, file): + self.file = file + + def visit_Config(self, node): + self.file.languages += (node.language.code,) diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 84bb4494ca7..34ced00af38 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -2197,28 +2197,28 @@ def _verify(self, data, expected, test=False): class TestLanguageConfig(unittest.TestCase): def test_lang_as_code(self): - self._test('fi') - self._test('F-I') + self._test_explicit_config('fi') + self._test_explicit_config('F-I') def test_lang_as_name(self): - self._test('Finnish') - self._test('FINNISH') + self._test_explicit_config('Finnish') + self._test_explicit_config('FINNISH') def test_lang_as_Language(self): - self._test(Language.from_name('fi')) + self._test_explicit_config(Language.from_name('fi')) def test_lang_as_list(self): - self._test(['fi', Language.from_name('de')]) - self._test([Language.from_name('fi'), 'de']) + self._test_explicit_config(['fi', Language.from_name('de')]) + self._test_explicit_config([Language.from_name('fi'), 'de']) def test_lang_as_tuple(self): - self._test(('f-i', Language.from_name('de'))) - self._test((Language.from_name('fi'), 'de')) + self._test_explicit_config(('f-i', Language.from_name('de'))) + self._test_explicit_config((Language.from_name('fi'), 'de')) def test_lang_as_Languages(self): - self._test(Languages('fi')) + self._test_explicit_config(Languages('fi')) - def _test(self, lang): + def _test_explicit_config(self, lang): data = '''\ *** Asetukset *** Dokumentaatio Documentation @@ -2237,6 +2237,84 @@ def _test(self, lang): assert_tokens(data, expected, get_init_tokens, lang=lang) assert_tokens(data, expected, get_resource_tokens, lang=lang) + def test_per_file_config(self): + data = '''\ +language: pt not recognized +language: fi +ignored language: pt +Language:German # ok! +*** Asetukset *** +Dokumentaatio Documentation +''' + expected = [ + (T.COMMENT, 'language: pt', 1, 0), + (T.SEPARATOR, ' ', 1, 12), + (T.COMMENT, 'not recognized', 1, 16), + (T.EOL, '\n', 1, 30), + (T.EOS, '', 1, 31), + (T.CONFIG, 'language: fi', 2, 0), + (T.EOL, '\n', 2, 12), + (T.EOS, '', 2, 13), + (T.COMMENT, 'ignored', 3, 0), + (T.SEPARATOR, ' ', 3, 7), + (T.COMMENT, 'language: pt', 3, 11), + (T.EOL, '\n', 3, 23), + (T.EOS, '', 3, 24), + (T.CONFIG, 'Language:German', 4, 0), + (T.SEPARATOR, ' ', 4, 15), + (T.COMMENT, '# ok!', 4, 19), + (T.EOL, '\n', 4, 24), + (T.EOS, '', 4, 25), + (T.SETTING_HEADER, '*** Asetukset ***', 5, 0), + (T.EOL, '\n', 5, 17), + (T.EOS, '', 5, 18), + (T.DOCUMENTATION, 'Dokumentaatio', 6, 0), + (T.SEPARATOR, ' ', 6, 13), + (T.ARGUMENT, 'Documentation', 6, 17), + (T.EOL, '\n', 6, 30), + (T.EOS, '', 6, 31), + ] + assert_tokens(data, expected, get_tokens) + lang = Languages() + assert_tokens(data, expected, get_init_tokens, lang=lang) + assert_equal(lang.languages, + [Language.from_name(lang) for lang in ('en', 'fi', 'de')]) + + def test_invalid_per_file_config(self): + data = '''\ +language: in:va:lid +language: bad again but not recognized as config and ignored +Language: Finnish +*** Asetukset *** +Dokumentaatio Documentation +''' + expected = [ + (T.ERROR, 'language: in:va:lid', 1, 0, + "Invalid language configuration: No language with name 'in:va:lid' found."), + (T.EOL, '\n', 1, 19), + (T.EOS, '', 1, 20), + (T.COMMENT, 'language: bad again', 2, 0), + (T.SEPARATOR, ' ', 2, 19), + (T.COMMENT, 'but not recognized as config and ignored', 2, 23), + (T.EOL, '\n', 2, 63), + (T.EOS, '', 2, 64), + (T.CONFIG, 'Language: Finnish', 3, 0), + (T.EOL, '\n', 3, 17), + (T.EOS, '', 3, 18), + (T.SETTING_HEADER, '*** Asetukset ***', 4, 0), + (T.EOL, '\n', 4, 17), + (T.EOS, '', 4, 18), + (T.DOCUMENTATION, 'Dokumentaatio', 5, 0), + (T.SEPARATOR, ' ', 5, 13), + (T.ARGUMENT, 'Documentation', 5, 17), + (T.EOL, '\n', 5, 30), + (T.EOS, '', 5, 31), + ] + assert_tokens(data, expected, get_tokens) + lang = Languages() + assert_tokens(data, expected, get_init_tokens, lang=lang) + assert_equal(lang.languages, + [Language.from_name(lang) for lang in ('en', 'fi')]) if __name__ == '__main__': unittest.main() diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index c4b9f9da5b5..2820618c790 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -10,7 +10,7 @@ Keyword, KeywordSection, SettingSection, TestCase, TestCaseSection, VariableSection ) from robot.parsing.model.statements import ( - Arguments, Break, Comment, Continue, Documentation, ForHeader, End, ElseHeader, + Arguments, Break, Comment, Config, Continue, Documentation, ForHeader, End, ElseHeader, ElseIfHeader, EmptyLine, Error, IfHeader, InlineIfHeader, TryHeader, ExceptHeader, FinallyHeader, KeywordCall, KeywordName, ReturnStatement, SectionHeader, TestCaseName, Variable, WhileHeader @@ -1266,5 +1266,61 @@ def visit_Block(self, node): assert_model(model, expected) +class TestLanguageConfig(unittest.TestCase): + + def test_valid(self): + model = get_model('''\ +language: fi +language: bad +language: bad but ignored +language: de # ok +*** Einstellungen *** +Dokumentaatio Header is de and setting is fi. +''') + expected = File( + languages=('fi', 'de'), + sections=[ + CommentSection(body=[ + Config([ + Token('CONFIG', 'language: fi', 1, 0), + Token('EOL', '\n', 1, 12) + ]), + Error([ + Token('ERROR', 'language: bad', 2, 0, + "Invalid language configuration: No language with name 'bad' found."), + Token('EOL', '\n', 2, 13) + ]), + Comment([ + Token('COMMENT', 'language: bad', 3, 0), + Token('SEPARATOR', ' ', 3, 13), + Token('COMMENT', 'but ignored', 3, 17), + Token('EOL', '\n', 3, 28) + ]), + Config([ + Token('CONFIG', 'language: de', 4, 0), + Token('SEPARATOR', ' ', 4, 12), + Token('COMMENT', '# ok', 4, 17), + Token('EOL', '\n', 4, 21) + ]), + ]), + SettingSection( + header=SectionHeader([ + Token('SETTING HEADER', '*** Einstellungen ***', 5, 0), + Token('EOL', '\n', 5, 21) + ]), + body=[ + Documentation([ + Token('DOCUMENTATION', 'Dokumentaatio', 6, 0), + Token('SEPARATOR', ' ', 6, 13), + Token('ARGUMENT', 'Header is de and setting is fi.', 6, 17), + Token('EOL', '\n', 6, 48) + ]) + ] + ) + ] + ) + assert_model(model, expected) + + if __name__ == '__main__': unittest.main() From 34faee28035f4b56231a4f343705badea5dedede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Tue, 30 Aug 2022 14:52:02 +0300 Subject: [PATCH 0168/1592] Add possibility to translate true/false strings Does not have runtime effect yet. Relates to #4400 --- src/robot/conf/languages.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 1c15637c0c4..26eebfadd92 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -34,6 +34,8 @@ def __init__(self, languages=None): } self.settings = {} self.bdd_prefixes = set() + self.true_strings = {'1'} + self.false_strings = {'0', ''} for lang in self._get_languages(languages): self._add_language(lang) @@ -50,6 +52,8 @@ def _add_language(self, lang): self.headers.update({n.title(): lang.headers[n] for n in lang.headers if n}) self.settings.update({n.title(): lang.settings[n] for n in lang.settings if n}) self.bdd_prefixes |= {p.title() for p in lang.bdd_prefixes} + self.true_strings |= {s.upper() for s in lang.true_strings} + self.false_strings |= {s.upper() for s in lang.false_strings} def _get_languages(self, languages): languages = self._resolve_languages(languages) @@ -142,6 +146,8 @@ class Language: then_prefix = set() and_prefix = set() but_prefix = set() + true_strings = set() + false_strings = set() @classmethod def from_name(cls, name): @@ -271,6 +277,8 @@ class En(Language): then_prefix = {'Then'} and_prefix = {'And'} but_prefix = {'But'} + true_strings = {'TRUE', 'YES', 'ON'} + false_strings = {'FALSE', 'NO', 'OFF', 'NONE'} class Cs(Language): @@ -388,6 +396,8 @@ class Fi(Language): then_prefix = {'Niin'} and_prefix = {'Ja'} but_prefix = {'Mutta'} + true_strings = {'TOSI', 'KYLLÄ', 'PÄÄLLÄ'} + false_strings = {'EPÄTOSI', 'EI', 'POIS'} class Fr(Language): From db969e616ca4967401b36da44d82fd4a90ee12da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 31 Aug 2022 15:55:42 +0300 Subject: [PATCH 0169/1592] Make evaluation namespace mutable again. Fixes #4447. --- atest/robot/standard_libraries/builtin/evaluate.robot | 3 +++ .../testdata/standard_libraries/builtin/evaluate.robot | 8 ++++++++ src/robot/variables/evaluation.py | 10 ++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/evaluate.robot b/atest/robot/standard_libraries/builtin/evaluate.robot index d43a7f03d7d..c642d379c0b 100644 --- a/atest/robot/standard_libraries/builtin/evaluate.robot +++ b/atest/robot/standard_libraries/builtin/evaluate.robot @@ -36,6 +36,9 @@ Explicit modules can override builtins Explicit modules used in lambda Check Test Case ${TESTNAME} +Evaluation namespace is mutable + Check Test Case ${TESTNAME} + Custom namespace Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/builtin/evaluate.robot b/atest/testdata/standard_libraries/builtin/evaluate.robot index 2a34d9e0286..85611fadd4b 100644 --- a/atest/testdata/standard_libraries/builtin/evaluate.robot +++ b/atest/testdata/standard_libraries/builtin/evaluate.robot @@ -103,6 +103,14 @@ Explicit modules used in lambda ${result} = Evaluate ''.join(filter(lambda s: re.match('^He',s), $HELLO)) modules=re Should Be Equal ${result} Hello +Evaluation namespace is mutable + [Documentation] FAIL + ... Evaluating expression 'locals().__setitem__('var', 1) or locals().__delitem__('var') or var' failed: \ + ... NameError: name 'var' is not defined nor importable as module + ${variable} = Evaluate locals().__setitem__('variable', 'value') or variable + Should Be Equal ${variable} value + Evaluate locals().__setitem__('var', 1) or locals().__delitem__('var') or var + Custom namespace ${ns} = Create Dictionary a=x b=${2} c=2 ${result} = Evaluate a*3 if b==2 and c!=2 else a namespace=${ns} diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py index 7ee73636608..8c5f5b6bb09 100644 --- a/src/robot/variables/evaluation.py +++ b/src/robot/variables/evaluation.py @@ -15,7 +15,7 @@ import builtins import token -from collections.abc import Mapping +from collections.abc import MutableMapping from io import StringIO from tokenize import generate_tokens, untokenize @@ -91,7 +91,7 @@ def _import_modules(module_names): return modules -class EvaluationNamespace(Mapping): +class EvaluationNamespace(MutableMapping): def __init__(self, variable_store, namespace): self.namespace = namespace @@ -104,6 +104,12 @@ def __getitem__(self, key): return self.namespace[key] return self._import_module(key) + def __setitem__(self, key, value): + self.namespace[key] = value + + def __delitem__(self, key): + self.namespace.pop(key) + def _import_module(self, name): if name in PYTHON_BUILTINS: raise KeyError From b4aa8fa3507ced9acf4ddd80f75027758ea7353f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 31 Aug 2022 15:57:45 +0300 Subject: [PATCH 0170/1592] Add Slack to PyPI project URLs. Related to #4312. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index a77e1648b9e..0dcfec675a2 100755 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ 'Issue Tracker': 'https://github.com/robotframework/robotframework/issues', 'Documentation': 'https://robotframework.org/robotframework', 'Release Notes': f'https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-{VERSION}.rst', + 'Slack': 'http://slack.robotframework.org', 'Twitter': 'https://twitter.com/robotframework', }, download_url = 'https://pypi.org/project/robotframework', From c87a71579f5b48251006d52ec33bfaa5706d7806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 31 Aug 2022 11:38:38 +0300 Subject: [PATCH 0171/1592] support custom true/false strings in execution relates to #4400 --- .../translated_boolean_values.robot | 7 +++++ .../translated_boolean_values.robot | 12 ++++++++ .../running/arguments/argumentconverter.py | 23 ++++++++------- src/robot/running/arguments/argumentspec.py | 9 +++--- src/robot/running/arguments/typeconverters.py | 28 +++++++++++-------- src/robot/running/handlers.py | 21 +++++++------- src/robot/running/handlerstore.py | 4 +-- src/robot/running/librarykeywordrunner.py | 8 ++++-- src/robot/running/namespace.py | 3 +- src/robot/running/usererrorhandler.py | 2 +- src/robot/running/userkeyword.py | 4 +-- 11 files changed, 76 insertions(+), 45 deletions(-) create mode 100644 atest/robot/keywords/type_conversion/translated_boolean_values.robot create mode 100644 atest/testdata/keywords/type_conversion/translated_boolean_values.robot diff --git a/atest/robot/keywords/type_conversion/translated_boolean_values.robot b/atest/robot/keywords/type_conversion/translated_boolean_values.robot new file mode 100644 index 00000000000..355807ae733 --- /dev/null +++ b/atest/robot/keywords/type_conversion/translated_boolean_values.robot @@ -0,0 +1,7 @@ +*** Settings *** +Suite Setup Run Tests --lang fi keywords/type_conversion/translated_boolean_values.robot +Resource atest_resource.robot + +*** Test Cases *** +Boolean + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/translated_boolean_values.robot b/atest/testdata/keywords/type_conversion/translated_boolean_values.robot new file mode 100644 index 00000000000..4a5aa42744e --- /dev/null +++ b/atest/testdata/keywords/type_conversion/translated_boolean_values.robot @@ -0,0 +1,12 @@ +*** Settings *** +Library Annotations.py +Library OperatingSystem + +*** Test cases *** +Boolean + Boolean Tosi True + Boolean Kyllä True + Boolean Päällä True + Boolean EpäTOSI False + Boolean EI False + Boolean Pois False diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 5a361e58ab8..98a79bde68c 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -26,25 +26,26 @@ def __init__(self, argspec, converters, dry_run=False): self._converters = converters self._dry_run = dry_run - def convert(self, positional, named): - return self._convert_positional(positional), self._convert_named(named) + def convert(self, positional, named, languages): + return self._convert_positional(positional, languages), \ + self._convert_named(named, languages) - def _convert_positional(self, positional): + def _convert_positional(self, positional, languages): names = self._argspec.positional - converted = [self._convert(name, value) + converted = [self._convert(name, value, languages) for name, value in zip(names, positional)] if self._argspec.var_positional: - converted.extend(self._convert(self._argspec.var_positional, value) + converted.extend(self._convert(self._argspec.var_positional, value, languages) for value in positional[len(names):]) return converted - def _convert_named(self, named): + def _convert_named(self, named, languages): names = set(self._argspec.positional) | set(self._argspec.named_only) var_named = self._argspec.var_named - return [(name, self._convert(name if name in names else var_named, value)) + return [(name, self._convert(name if name in names else var_named, value, languages)) for name, value in named] - def _convert(self, name, value): + def _convert(self, name, value, languages=None): spec = self._argspec if (spec.types is None or self._dry_run and contains_variable(value, identifiers='$@&%')): @@ -57,14 +58,16 @@ def _convert(self, name, value): if value is None and name in spec.defaults and spec.defaults[name] is None: return value if name in spec.types: - converter = TypeConverter.converter_for(spec.types[name], self._converters) + converter = TypeConverter.converter_for(spec.types[name], self._converters, + languages) if converter: try: return converter.convert(name, value) except ValueError as err: conversion_error = err if name in spec.defaults: - converter = TypeConverter.converter_for(type(spec.defaults[name])) + converter = TypeConverter.converter_for(type(spec.defaults[name]), + languages=languages) if converter: try: return converter.convert(name, value, explicit_type=False, diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index bde9325609d..4fb491e874c 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -65,16 +65,17 @@ def argument_names(self): def resolve(self, arguments, variables=None, converters=None, resolve_named=True, resolve_variables_until=None, - dict_to_kwargs=False): + dict_to_kwargs=False, languages=None): resolver = ArgumentResolver(self, resolve_named, resolve_variables_until, dict_to_kwargs) positional, named = resolver.resolve(arguments, variables) - return self.convert(positional, named, converters, dry_run=not variables) + return self.convert(positional, named, converters, dry_run=not variables, + languages=languages) - def convert(self, positional, named, converters=None, dry_run=False): + def convert(self, positional, named, converters=None, dry_run=False, languages=None): if self.types or self.defaults: converter = ArgumentConverter(self, converters, dry_run) - positional, named = converter.convert(positional, named) + positional, named = converter.convert(positional, named, languages) return positional, named def map(self, positional, named, replace_defaults=True): diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 7e1e2d21f2f..1619736589f 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -22,9 +22,10 @@ from enum import Enum from numbers import Integral, Real +from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time -from robot.utils import (FALSE_STRINGS, TRUE_STRINGS, eq, get_error_message, - is_string, is_union, safe_str, seq2str, type_name) +from robot.utils import (eq, get_error_message, is_string, is_union, + safe_str, seq2str, type_name) NoneType = type(None) @@ -40,9 +41,10 @@ class TypeConverter: _converters = OrderedDict() _type_aliases = {} - def __init__(self, used_type, custom_converters=None): + def __init__(self, used_type, custom_converters=None, languages=None): self.used_type = used_type self.custom_converters = custom_converters + self.languages = languages or Languages() @classmethod def register(cls, converter): @@ -53,7 +55,7 @@ def register(cls, converter): return converter @classmethod - def converter_for(cls, type_, custom_converters=None): + def converter_for(cls, type_, custom_converters=None, languages=None): try: hash(type_) except TypeError: @@ -70,10 +72,10 @@ def converter_for(cls, type_, custom_converters=None): if info: return CustomConverter(type_, info) if type_ in cls._converters: - return cls._converters[type_](type_) + return cls._converters[type_](type_, languages=languages) for converter in cls._converters.values(): if converter.handles(type_): - return converter(type_, custom_converters) + return converter(type_, custom_converters, languages) return None @classmethod @@ -223,11 +225,13 @@ def _non_string_convert(self, value, explicit_type=True): def _convert(self, value, explicit_type=True): upper = value.upper() + true_strings = self.languages.true_strings + false_strings = self.languages.false_strings if upper == 'NONE': return None - if upper in TRUE_STRINGS: + if upper in true_strings: return True - if upper in FALSE_STRINGS: + if upper in false_strings: return False return value @@ -464,9 +468,9 @@ def _convert(self, value, explicit_type=True): class CombinedConverter(TypeConverter): type = Union - def __init__(self, union, custom_converters): + def __init__(self, union, custom_converters, languages=None): super().__init__(self._get_types(union)) - self.converters = [TypeConverter.converter_for(t, custom_converters) + self.converters = [TypeConverter.converter_for(t, custom_converters, languages) for t in self.used_type] def _get_types(self, union): @@ -506,8 +510,8 @@ def _convert(self, value, explicit_type=True): class CustomConverter(TypeConverter): - def __init__(self, used_type, converter_info): - super().__init__(used_type) + def __init__(self, used_type, converter_info, languages=None): + super().__init__(used_type, languages=languages) self.converter_info = converter_info @property diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py index aff633c718d..0dce2ba9050 100644 --- a/src/robot/running/handlers.py +++ b/src/robot/running/handlers.py @@ -82,8 +82,9 @@ def _get_initial_handler(self, library, name, method): return self._get_global_handler(method, name) return None - def resolve_arguments(self, args, variables=None): - return self.arguments.resolve(args, variables, self.library.converters) + def resolve_arguments(self, args, variables=None, languages=None): + return self.arguments.resolve(args, variables, self.library.converters, + languages=languages) @property def doc(self): @@ -109,8 +110,8 @@ def source(self): def lineno(self): return -1 - def create_runner(self, name): - return LibraryKeywordRunner(self) + def create_runner(self, name, languages=None): + return LibraryKeywordRunner(self, languages=languages) def current_handler(self): if self._method: @@ -216,8 +217,8 @@ def lineno(self): self._source_info = self._get_source_info() return self._source_info[1] - def resolve_arguments(self, arguments, variables=None): - positional, named = super().resolve_arguments(arguments, variables) + def resolve_arguments(self, arguments, variables=None, languages=None): + positional, named = super().resolve_arguments(arguments, variables, languages) if not self._supports_kwargs: positional, named = self.arguments.map(positional, named) return positional, named @@ -240,7 +241,7 @@ def handler(*positional, **kwargs): class _RunKeywordHandler(_PythonHandler): - def create_runner(self, name): + def create_runner(self, name, languages=None): default_dry_run_keywords = ('name' in self.arguments.positional and self._args_to_process) return RunKeywordRunner(self, default_dry_run_keywords) @@ -250,7 +251,7 @@ def _args_to_process(self): return RUN_KW_REGISTER.get_args_to_process(self.library.orig_name, self.name) - def resolve_arguments(self, args, variables=None): + def resolve_arguments(self, args, variables=None, languages=None): return self.arguments.resolve(args, variables, self.library.converters, resolve_named=False, resolve_variables_until=self._args_to_process) @@ -303,10 +304,10 @@ def library(self, library): def matches(self, name): return self.embedded.match(name) is not None - def create_runner(self, name): + def create_runner(self, name, languages=None): return EmbeddedArgumentsRunner(self, name) - def resolve_arguments(self, args, variables=None): + def resolve_arguments(self, args, variables=None, languages=None): argspec = self._orig_handler.arguments if variables: if argspec.var_positional: diff --git a/src/robot/running/handlerstore.py b/src/robot/running/handlerstore.py index 5b5959205fa..8a4e7e35bcf 100644 --- a/src/robot/running/handlerstore.py +++ b/src/robot/running/handlerstore.py @@ -55,8 +55,8 @@ def __contains__(self, name): return True return any(template.matches(name) for template in self._embedded) - def create_runner(self, name): - return self[name].create_runner(name) + def create_runner(self, name, languages=None): + return self[name].create_runner(name, languages) def __getitem__(self, name): try: diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index 4aa820991cd..a271d906cd7 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -28,10 +28,11 @@ class LibraryKeywordRunner: - def __init__(self, handler, name=None): + def __init__(self, handler, name=None, languages=None): self._handler = handler self.name = name or handler.name self.pre_run_messages = () + self.languages = languages @property def library(self): @@ -70,7 +71,8 @@ def _run(self, context, args): for message in self.pre_run_messages: context.output.message(message) variables = context.variables if not context.dry_run else None - positional, named = self._handler.resolve_arguments(args, variables) + positional, named = self._handler.resolve_arguments(args, variables, + self.languages) context.output.trace(lambda: self._trace_log_args(positional, named)) runner = self._runner_for(context, self._handler.current_handler(), positional, dict(named)) @@ -116,7 +118,7 @@ def _dry_run(self, context, args): if self._executed_in_dry_run(self._handler): self._run(context, args) else: - self._handler.resolve_arguments(args) + self._handler.resolve_arguments(args, languages=self.languages) def _executed_in_dry_run(self, handler): keywords_to_execute = ('BuiltIn.Import Library', diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index ea2bab0f279..96f19b0bbd4 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -355,7 +355,8 @@ def _get_runner_from_resource_files(self, name): self._raise_multiple_keywords_found(found, name) def _get_runner_from_libraries(self, name): - found = [lib.handlers.create_runner(name) for lib in self.libraries.values() + found = [lib.handlers.create_runner(name, self.languages) + for lib in self.libraries.values() if name in lib.handlers] if not found: return None diff --git a/src/robot/running/usererrorhandler.py b/src/robot/running/usererrorhandler.py index 38c6f3b0d42..4f0c49380fb 100644 --- a/src/robot/running/usererrorhandler.py +++ b/src/robot/running/usererrorhandler.py @@ -58,7 +58,7 @@ def doc(self): def shortdoc(self): return self.doc.splitlines()[0] - def create_runner(self, name): + def create_runner(self, name, languages=None): return self def run(self, kw, context, run=True): diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index d6ec16aa9c2..1c3913dc847 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -95,7 +95,7 @@ def shortdoc(self): def private(self): return bool(self.tags and self.tags.robot('private')) - def create_runner(self, name): + def create_runner(self, name, languages=None): return UserKeywordRunner(self) @@ -109,5 +109,5 @@ def __init__(self, keyword, libname, embedded): def matches(self, name): return self.embedded.match(name) is not None - def create_runner(self, name): + def create_runner(self, name, languages=None): return EmbeddedArgumentsRunner(self, name) From 5e683e3ab43293f0c6b7e92cf23ca4a3af6806dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 14:05:26 +0300 Subject: [PATCH 0172/1592] Minor tuning for passing lang info to arg converters. --- .../running/arguments/argumentconverter.py | 24 +++++++++---------- src/robot/running/arguments/argumentspec.py | 4 ++-- src/robot/running/arguments/typeconverters.py | 6 ++--- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 98a79bde68c..ff7be2ad032 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -20,32 +20,32 @@ class ArgumentConverter: - def __init__(self, argspec, converters, dry_run=False): + def __init__(self, argspec, converters, dry_run=False, languages=None): """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" self._argspec = argspec self._converters = converters self._dry_run = dry_run + self._languages = languages - def convert(self, positional, named, languages): - return self._convert_positional(positional, languages), \ - self._convert_named(named, languages) + def convert(self, positional, named): + return self._convert_positional(positional), self._convert_named(named) - def _convert_positional(self, positional, languages): + def _convert_positional(self, positional): names = self._argspec.positional - converted = [self._convert(name, value, languages) + converted = [self._convert(name, value) for name, value in zip(names, positional)] if self._argspec.var_positional: - converted.extend(self._convert(self._argspec.var_positional, value, languages) + converted.extend(self._convert(self._argspec.var_positional, value) for value in positional[len(names):]) return converted - def _convert_named(self, named, languages): + def _convert_named(self, named): names = set(self._argspec.positional) | set(self._argspec.named_only) var_named = self._argspec.var_named - return [(name, self._convert(name if name in names else var_named, value, languages)) + return [(name, self._convert(name if name in names else var_named, value)) for name, value in named] - def _convert(self, name, value, languages=None): + def _convert(self, name, value): spec = self._argspec if (spec.types is None or self._dry_run and contains_variable(value, identifiers='$@&%')): @@ -59,7 +59,7 @@ def _convert(self, name, value, languages=None): return value if name in spec.types: converter = TypeConverter.converter_for(spec.types[name], self._converters, - languages) + self._languages) if converter: try: return converter.convert(name, value) @@ -67,7 +67,7 @@ def _convert(self, name, value, languages=None): conversion_error = err if name in spec.defaults: converter = TypeConverter.converter_for(type(spec.defaults[name]), - languages=languages) + languages=self._languages) if converter: try: return converter.convert(name, value, explicit_type=False, diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index 4fb491e874c..5946b44d607 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -74,8 +74,8 @@ def resolve(self, arguments, variables=None, converters=None, def convert(self, positional, named, converters=None, dry_run=False, languages=None): if self.types or self.defaults: - converter = ArgumentConverter(self, converters, dry_run) - positional, named = converter.convert(positional, named, languages) + converter = ArgumentConverter(self, converters, dry_run, languages) + positional, named = converter.convert(positional, named) return positional, named def map(self, positional, named, replace_defaults=True): diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 1619736589f..6d4d7655eff 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -225,13 +225,11 @@ def _non_string_convert(self, value, explicit_type=True): def _convert(self, value, explicit_type=True): upper = value.upper() - true_strings = self.languages.true_strings - false_strings = self.languages.false_strings if upper == 'NONE': return None - if upper in true_strings: + if upper in self.languages.true_strings: return True - if upper in false_strings: + if upper in self.languages.false_strings: return False return value From 18636c1aeabfd55276d84ac7084e37e29394beb9 Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Fri, 2 Sep 2022 14:26:01 +0200 Subject: [PATCH 0173/1592] Update languages.py (#4450) - Bosnian by @Delilovic - Boolean translations for Dutch --- src/robot/conf/languages.py | 47 +++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 26eebfadd92..20d1fe32318 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -352,12 +352,55 @@ class Nl(Language): template_setting = 'Sjabloon' timeout_setting = 'Time-out' arguments_setting = 'Parameters' - given_prefix = {'Stel'} + given_prefix = {'Stel', 'Gegeven'} when_prefix = {'Als'} then_prefix = {'Dan'} and_prefix = {'En'} but_prefix = {'Maar'} - + true_strings = {'WAAR', 'JA', 'AAN'} + false_strings = {'ONWAAR', 'NEE', 'UIT', 'GEEN'} + + +class Bs(Language): + """Bosnian""" + setting_headers = {'Postavka', 'Postavke', 'Postavke'} + variable_headers = {'Varijabla', 'Varijable', 'Varijable'} + test_case_headers = {'Test Case', 'Test Cases', 'Test Cases'} + task_headers = {'Task', 'Taskovi', 'Taskovi'} + keyword_headers = {'Keyword', 'Keywords', 'Keywords'} + comment_headers = {'Komentar', 'Komentari', 'Komentari'} + library = 'Biblioteka' + resource = 'Resursi' + variables = 'Varijabla' + documentation = 'Dokumentacija' + metadata = 'Metadata' + suite_setup = 'Suite Postavke' + suite_teardown = 'Suite Teardown' + test_setup = 'Test Postavke' + test_teardown = 'Test Teardown' + test_template = 'Test Template' + test_timeout = 'Test Timeout' + test_tags = 'Test Tagovi' + task_setup = 'Task Postavke' + task_teardown = 'Task Teardown' + task_template = 'Task Template' + task_timeout = 'Task Timeout' + task_tags = 'Task Tagovi' + keyword_tags = 'Keyword Tagovi' + tags = 'Tagovi' + setup = 'Postavke' + teardown = 'Teardown' + template = 'Template' + timeout = 'Timeout' + arguments = 'Argumenti' + given_prefix = {'Uslovno'} + when_prefix = {'Kada'} + then_prefix = {'Tada'} + and_prefix = {'I'} + but_prefix = {'Ali'} + true_strings = {'TRUE', 'YES', 'ON'} + false_strings = {'FALSE', 'NO', 'OFF', 'NONE'} + class Fi(Language): """Finnish""" From 17adb7c10aef201684d438dadfe4264bb707aba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 14:21:28 +0300 Subject: [PATCH 0174/1592] Move NONE false string from En class to default values. We don't in general want people to translate that so let's not have it in En as an example. --- src/robot/conf/languages.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 20d1fe32318..16dfc08dc32 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -35,7 +35,7 @@ def __init__(self, languages=None): self.settings = {} self.bdd_prefixes = set() self.true_strings = {'1'} - self.false_strings = {'0', ''} + self.false_strings = {'0', 'NONE', ''} for lang in self._get_languages(languages): self._add_language(lang) @@ -278,7 +278,7 @@ class En(Language): and_prefix = {'And'} but_prefix = {'But'} true_strings = {'TRUE', 'YES', 'ON'} - false_strings = {'FALSE', 'NO', 'OFF', 'NONE'} + false_strings = {'FALSE', 'NO', 'OFF'} class Cs(Language): From 7d2e2fab3a7eea400c45fa9070e8b0b57d2fb50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 16:01:49 +0300 Subject: [PATCH 0175/1592] Update Bs(Language) to match new API. --- src/robot/conf/languages.py | 64 ++++++++++++++++++------------------- utest/api/test_languages.py | 7 ++++ 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 16dfc08dc32..f88f457f5a9 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -363,44 +363,42 @@ class Nl(Language): class Bs(Language): """Bosnian""" - setting_headers = {'Postavka', 'Postavke', 'Postavke'} - variable_headers = {'Varijabla', 'Varijable', 'Varijable'} - test_case_headers = {'Test Case', 'Test Cases', 'Test Cases'} - task_headers = {'Task', 'Taskovi', 'Taskovi'} - keyword_headers = {'Keyword', 'Keywords', 'Keywords'} - comment_headers = {'Komentar', 'Komentari', 'Komentari'} - library = 'Biblioteka' - resource = 'Resursi' - variables = 'Varijabla' - documentation = 'Dokumentacija' - metadata = 'Metadata' - suite_setup = 'Suite Postavke' - suite_teardown = 'Suite Teardown' - test_setup = 'Test Postavke' - test_teardown = 'Test Teardown' - test_template = 'Test Template' - test_timeout = 'Test Timeout' - test_tags = 'Test Tagovi' - task_setup = 'Task Postavke' - task_teardown = 'Task Teardown' - task_template = 'Task Template' - task_timeout = 'Task Timeout' - task_tags = 'Task Tagovi' - keyword_tags = 'Keyword Tagovi' - tags = 'Tagovi' - setup = 'Postavke' - teardown = 'Teardown' - template = 'Template' - timeout = 'Timeout' - arguments = 'Argumenti' + settings_header = 'Postavke' + variables_header = 'Varijable' + test_cases_header = 'Test Cases' + tasks_header = 'Taskovi' + keywords_header = 'Keywords' + comments_header = 'Komentari' + library_setting = 'Biblioteka' + resource_setting = 'Resursi' + variables_setting = 'Varijabla' + documentation_setting = 'Dokumentacija' + metadata_setting = 'Metadata' + suite_setup_setting = 'Suite Postavke' + suite_teardown_setting = 'Suite Teardown' + test_setup_setting = 'Test Postavke' + test_teardown_setting = 'Test Teardown' + test_template_setting = 'Test Template' + test_timeout_setting = 'Test Timeout' + test_tags_setting = 'Test Tagovi' + task_setup_setting = 'Task Postavke' + task_teardown_setting = 'Task Teardown' + task_template_setting = 'Task Template' + task_timeout_setting = 'Task Timeout' + task_tags_setting = 'Task Tagovi' + keyword_tags_setting = 'Keyword Tagovi' + tags_setting = 'Tagovi' + setup_setting = 'Postavke' + teardown_setting = 'Teardown' + template_setting = 'Template' + timeout_setting = 'Timeout' + arguments_setting = 'Argumenti' given_prefix = {'Uslovno'} when_prefix = {'Kada'} then_prefix = {'Tada'} and_prefix = {'I'} but_prefix = {'Ali'} - true_strings = {'TRUE', 'YES', 'ON'} - false_strings = {'FALSE', 'NO', 'OFF', 'NONE'} - + class Fi(Language): """Finnish""" diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index 226d3df3da5..1cc4d59dafd 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -46,6 +46,13 @@ def test_hash(self): assert_equal(hash(Fi()), hash(Fi())) assert_equal({Fi(): 'value'}[Fi()], 'value') + def test_subclasses_dont_have_wrong_attributes(self): + for cls in Language.__subclasses__(): + for attr in dir(cls): + if not hasattr(Language, attr): + raise AssertionError(f"Language class '{cls}' has attribute " + f"'{attr}' not found on the base class.") + class TestLanguageFromName(unittest.TestCase): From 9ec7fc5e4d015aec7d32904eddb129e88c6ca980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 17:20:53 +0300 Subject: [PATCH 0176/1592] Localization updates - Czech and German true/false strings. - Change Variables setting to plural in Bosnian. --- src/robot/conf/languages.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index f88f457f5a9..379da821cca 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -318,6 +318,8 @@ class Cs(Language): then_prefix = {'Pak'} and_prefix = {'A'} but_prefix = {'Ale'} + true_strings = {'PRAVDA', 'ANO', 'ZAPNUTO'} + false_strings = {'NEPRAVDA', 'NE', 'VYPNUTO', 'NIC'} class Nl(Language): @@ -371,7 +373,7 @@ class Bs(Language): comments_header = 'Komentari' library_setting = 'Biblioteka' resource_setting = 'Resursi' - variables_setting = 'Varijabla' + variables_setting = 'Varijable' documentation_setting = 'Dokumentacija' metadata_setting = 'Metadata' suite_setup_setting = 'Suite Postavke' @@ -517,6 +519,8 @@ class De(Language): then_prefix = {'Dann'} and_prefix = {'Und'} but_prefix = {'Aber'} + true_strings = {'WAHR', 'JA', 'AN', 'EIN'} + false_strings = {'FALSCH', 'NEIN', 'AUS', 'UNWAHR'} class PtBr(Language): From 35be590c46c269c490daa54a27e36f0bb7d663d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 18:58:09 +0300 Subject: [PATCH 0177/1592] Pt and PtBr true/false_strings --- src/robot/conf/languages.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 379da821cca..d89691e447c 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -560,6 +560,8 @@ class PtBr(Language): then_prefix = {'Então'} and_prefix = {'E'} but_prefix = {'Mas'} + true_strings = {'VERDADEIRO', 'VERDADE', 'SIM', 'LIGADO'} + false_strings = {'FALSO', 'NÃO', 'DESLIGADO', 'DESATIVADO', 'NADA'} class Pt(Language): @@ -599,6 +601,8 @@ class Pt(Language): then_prefix = {'Então'} and_prefix = {'E'} but_prefix = {'Mas'} + true_strings = {'VERDADEIRO', 'VERDADE', 'SIM', 'LIGADO'} + false_strings = {'FALSO', 'NÃO', 'DESLIGADO', 'DESATIVADO', 'NADA'} class Th(Language): From cd3757a3af690770931a30a69ae927dd231ee5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 19:23:30 +0300 Subject: [PATCH 0178/1592] Release notes for 5.1b1 --- doc/releasenotes/rf-5.1b1.rst | 581 ++++++++++++++++++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 doc/releasenotes/rf-5.1b1.rst diff --git a/doc/releasenotes/rf-5.1b1.rst b/doc/releasenotes/rf-5.1b1.rst new file mode 100644 index 00000000000..77b34fc4456 --- /dev/null +++ b/doc/releasenotes/rf-5.1b1.rst @@ -0,0 +1,581 @@ +========================== +Robot Framework 5.1 beta 1 +========================== + +.. default-role:: code + +`Robot Framework`_ 5.1 is a new feature release that starts Robot Framework's +localization efforts and also brings in other nice enhancements. +Robot Framework 5.1 preview releases are targeted especially +for people interested in translations. + +All issues targeted for Robot Framework 5.1 can be found +from the `issue tracker milestone`_. +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==5.1b1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 5.1 beta 1 was released on Friday September 2, 2022. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Robot Framework Slack: https://robotframework-slack-invite.herokuapp.com +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Localization +------------ + +Robot Framework 5.1 starts our localization efforts by making it possible to translate +various markers used in the data. It is possible to translate headers (e.g. `Test Cases`) +and settings (e.g. `Documentation`) (`#4096`_), `Given/When/Then` prefixes used in BDD +(`#519`_), as well as true and false strings used in Boolean argument conversion (`#4400`_). +Future versions may allow translating syntax like `IF` and `FOR`, contents of logs and +reports, error messages, and so on. + +Languages to use are specified when starting execution using the `--language` command +line option. With languages supported by Robot Framework out-of-the-box, it is possible +to use just a language code or name like `--language fi` or `--language Finnish`. +It is also possible to create a custom language file and use it like `--language MyLang.py`. +If there is a need to support multiple languages, the `--language` option can be +used multiple times like `--language de --language uk`. + +In addition to specifying the language from the command line, it is possible to +specify it in the data file itself using `language: <lang>` syntax, where `<lang>` is +a language code or name, before the first section:: + + language: fi + + *** Asetukset *** + Dokumentaatio Example using Finnish. + +Due to technical reasons this per-file language configuration affects also parsing +subsequent files, but that behavior is likely to change and *should not* be dependent +on. Either use `language: <lang>` in each parsed file or specify the language to +use from the command line. + +Robot Framework 5.1 beta 1 contains built-in support for these languages in addition +to English that is automatically supported: + +- Bosnian (BS) +- Czech (CS) +- Dutch (NL) +- Finnish (FI) +- French (FR) +- German (DE) +- Polish (PL) +- Portuguese (PT) and Brazilian Portuguese (PT-BR) +- Russian (RU) +- Simplified Chinese (ZH-CN) +- Spanish (ES) +- Thai (TH) +- Ukrainian (UK) + +All these translations have been provided by our awesome community and we hope to get +more community contributed translations still before Robot Framework 5.1 final +release. If you are interested to help, head to Crowdin__ that we use +for collaboration. For more instructions see issue `#4390`__ and for general +discussion and questions join the `#localization` channel on our Slack. + +__ https://robotframework.crowdin.com/robot-framework +__ https://github.com/robotframework/robotframework/issues/4390 + +Enhancements for setting keyword and test tags +---------------------------------------------- + +It is now possible to set tags for all keywords in a certain file by using +the new `Keyword Tags` setting (`#4373`_). It works in resource files and also +in test case and suite initialization files. When used in initialization files, +it only affects keywords in that file and does not propagate to lower level suites. + +The `Force Tags` setting has been renamed to `Test Tags` (`#4368`_). The motivation +is to make settings related to tests more consistent (`Test Setup`, `Test Timeout`, +`Test Tags`, ...) and to better separate settings for specifying test and keyword tags. +Consistent naming also easies translations. The old `Force Tags` setting still works but it +will be `deprecated in the future`__. When creating tasks, it is possible to use +`Task Tags` alias instead of `Test Tags`. + +To simplify setting tags, the `Default Tags` setting will `also be deprecated`__. +The functionality it provides, setting tags that some but no all tests get, +will be enabled in the future by using `-tag` syntax with the `[Tags]` setting +to indicate that a test should not get tag `tag`. This syntax will then work +also in combination with the new `Keyword Tags`. For more details see `#4374`__. + +__ `Force Tags and Default Tags settings`_ +__ `Force Tags and Default Tags settings`_ +__ https://github.com/robotframework/robotframework/issues/4374 + +Enhancements to keyword namespaces +---------------------------------- + +It is possible to mark keywords in resource files as private by adding +`robot:private` tag to them (`#430`_). If such a keyword is used by keywords +outside that resource file, there will be a warning. These keywords are also +excluded from HTML library documentation generated by Libdoc. + +If a keyword exists in the same resource file as a keyword using it, it will +be used even if there would be keyword with the same name in another resource +file (`#4366`_). Earlier this situation caused a conflict. + +If a keyword exists in the same resource file as a keyword using it and there +is a keyword with the same name in the test case file, the keyword in the test +case file will be used as it has been used earlier. This behavior is nowadays +deprecated__, though, and in the future local keywords will have precedence also +in these cases. + +__ `Keywords in test case files having precedence over local keywords in resource files`_ + +Possibility to disable continue-on-failure mode +----------------------------------------------- + +Robot Framework generally stops executing a keyword or a test case if there +is a failure. Exceptions to this rule include teardowns, templates and +cases where the continue-on-failure mode has been explicitly enabled with +`robot:continue-on-failure` or `robot:recursive-continue-on-failure` +tags. Robot Framework 5.1 makes it possible to disable the implicit or explicit +continue-on-failure mode when needed by using `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags (`#4303`_). + +`start/end_keyword` listener methods get more information about control structures +---------------------------------------------------------------------------------- + +When using the listener API v2, `start_keyword` and `end_keyword` methods are not +only used with keywords but also with all control structures. Earlier these methods +always got exactly the same information, but nowadays there is additional context +specific details with control structures (`#4335`_). + +Python 3.11 support +-------------------- + +Robot Framework 5.1 officially supports the forthcoming Python 3.11 +release (`#4401`_). Incompatibilities were not too big, so also the earlier +versions work fairly well. + +At the other end of the spectrum, Python 3.6 is deprecated and will not +anymore be supported by Robot Framework 6.0 (`#4295`_). + +Performance enhancements for executing user keywords +---------------------------------------------------- + +The overhead in executing user keywords has been reduced. The difference +can be seen especially if user keywords fail often, for example, when using +`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) + + +Backwards incompatible changes +============================== + +- Space is required after `Given/When/Then` prefixes used with BDD scenarios. (`#4379`_) +- Dictionary related keywords in `Collections` require dictionaries to inherit `Mapping`. (`#4413`_) +- `Dictionary Should Contain Item` from the Collections library does not anymore convert + values to strings before comparison. (`#4408`_) +- When keywords accepting embedded arguments are used so that arguments are passed as + variables, variable values are checked against possible custom regular expressions (`#4069`_). + This more strict behavior causes failures if values do not match. +- Generation time in XML and JSON spec files generated by Libdoc has been changed to + `2022-05-27T19:07:15+00:00`. With XML specs the format used to be `2022-05-27T19:07:15Z` + that is equivalent with the new format. JSON spec files did not include the timezone + information at all and the format was `2022-05-27 19:07:15`. (`#4262`_) + +Deprecated features +=================== + +`Force Tags` and `Default Tags` settings +---------------------------------------- + +As `discussed above`__, new `Test Tags` setting has been added to replace `Force Tags` +and there is a plan to remove `Default Tags` altogether. Both of these settings still +work but they are considered deprecated. There is not visible deprecation warning yet, +but such a warning will be emitted starting from Robot Framework 6.0 and eventually these +settings will be removed. (`#4368`_) + +The plan is to add new `-tag` syntax that can be used with the `[Tags]` setting +to enable similar functionality that `Default Tags` provide. As the result +using tags starting with a hyphen with the `[Tags]` setting is deprecated. +If such literal values are needed, it is possible to use escaped format like +`\-tag`. (`#4380`_) + +__ `Enhancements for setting keyword and test tags`_ + +Python 3.6 +---------- + +Python 3.6 `reached end-of-life`__ in December 2021. It will be still supported +by Robot Framework 5.1 and all future RF 5.x releases, but not anymore by +Robot Framework 6.0 (`#4295`_). Users are recommended to upgrade to newer +versions already now. + +__ https://endoflife.date/python + +Keywords in test case files having precedence over local keywords in resource files +----------------------------------------------------------------------------------- + +Keywords in test cases files currently always have the highest precedence. They +are used even when a keyword in a resource file uses a keyword that would exist also +in the same resource file. This will change in Robot Framework 5.2 so that local +keywords always have highest precedence and the current behavior is deprecated. (`#4366`_) + +`WITH NAME` deprecated in favor of `AS` when giving alias to imported library +----------------------------------------------------------------------------- + +`WITH NAME` marker that is used when giving an alias to an imported library +will be renamed to `AS` (`#4371`_). The motivation is to be consistent with +Python that uses `as` for similar purpose. We also already use `AS` with +`TRY/EXCEPT` and reusing the same marker and internally used token simplifies +the syntax. Having less markers will also ease translations (but these markers +cannot yet be translated). + +In Robot Framework 5.1 both `AS` and `WITH NAME` work when setting an alias +for a library. `WITH NAME` is considered deprecated, but there will not be +visible deprecation warnings until Robot Framework 6.0. + +Singular section headers like `Test Case` are deprecated +-------------------------------------------------------- + +Robot Framework has earlier accepted both plural (e.g. `Test Cases`) and singular +(e.g. `Test Case`) section headers. The singular variants are now deprecated +and their support will eventually be removed (`#4431`_). The is no visible +deprecation warning yet, but they will most likely be emitted starting from +Robot Framework 6.0. + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its close to 50 member organizations. Robot Framework 5.1 team funded by +them consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the wider open source community has provided several +great contributions: + +- `Elout van Leeuwen <https://github.com/leeuwe>`_ has lead the localization efforts + (`#4390`__). Individual translations have been provided by the following people: + + - Bosnian by `Namik <https://github.com/Delilovic>`_ + - Czech by `Václav Fuksa <https://github.com/MoreFamed>`_ + - Dutch by `Pim Jansen <https://github.com/pimjansen>`_ and + `Elout van Leeuwen <https://github.com/leeuwe>`_ + - French by `@lesnake <https://github.com/lesnake>`_ + - German by `René <https://github.com/Snooz82>`_ and `Markus <https://github.com/Noordsestern>`_ + - Polish by `Bartłomiej Hirsz <https://github.com/bhirsz>`_ + - Portuguese and Brazilian Portuguese by `Hélio Guilherme <https://github.com/HelioGuilherme66>`_ + - Russian by `Anatoly Kolpakov <https://github.com/axxyhtrx>`_ + - Simplified Chinese by `charis <https://github.com/mawentao119>`_ and `@nixuewei <https://github.com/nixuewei>`_ + - Spanish by Miguel Angel Apolayo Mendoza + - Thai by `Somkiat Puisungnoen <https://github.com/up1>`_ + - Ukrainian by `@Sunshine0000000 <https://github.com/Sunshine0000000>`_ + +- `Oliver Boehmer <https://github.com/oboehmer>`_ provide several contributions: + + - Support to disable the continue-on-failure mode using `robot:stop-on-failure` and + `robot:recursive-stop-on-failure` tags. (`#4303`_) + - Document that failing test setup stops execution even if the continue-on-failure + mode is active. (`#4404`_) + - Default value to `Get From Dictionary` keyword. (`#4398`_) + - Allow passing explicit flags to regexp related keywords. (`#4429`_) + +- `Ossi R. <https://github.com/osrjv>`_ added more information to `start/end_keyword` + listener methods when they are used with control structures (`#4335`_) + +- `Fabio Zadrozny <https://github.com/fabioz>`_ provided a pull request speeding up + user keyword execution. (`#4353`_). + +- `@Apteryks <https://github.com/Apteryks>`_ added support to generate deterministic + library documentation by using `SOURCE_DATE_EPOCH`__ environment variable. (`#4262`_) + +__ https://github.com/robotframework/robotframework/issues/4390 +__ https://reproducible-builds.org/specs/source-date-epoch/ + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#4096`_ + - enhancement + - critical + - Multilanguage support for markers used in data + - alpha 1 + * - `#519`_ + - enhancement + - critical + - Given/When/Then should support other languages than English + - alpha 1 + * - `#4295`_ + - enhancement + - high + - Deprecate Python 3.6 + - alpha 1 + * - `#430`_ + - enhancement + - high + - Keyword visibility modifiers for resource files + - alpha 1 + * - `#4303`_ + - enhancement + - high + - Support disabling continue-on-failure mode using `robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags + - alpha 1 + * - `#4335`_ + - enhancement + - high + - Pass more information about control structures to `start/end_keyword` listener methods + - beta 1 + * - `#4366`_ + - enhancement + - high + - Give local keywords precedence over imported keywords in resource files + - alpha 1 + * - `#4368`_ + - enhancement + - high + - New `Test Tags` setting as an alias for `Force Tags` + - alpha 1 + * - `#4373`_ + - enhancement + - high + - Support adding tags for all keywords using `Keyword Tags` setting + - alpha 1 + * - `#4380`_ + - enhancement + - high + - Deprecate setting tags starting with a hyphen like `-tag` using the `[Tags]` setting + - alpha 1 + * - `#4388`_ + - enhancement + - high + - Enhance performance of executing user keywords especially when they fail + - alpha 1 + * - `#4400`_ + - enhancement + - high + - Allow translating True and False words used in Boolean argument conversion + - beta 1 + * - `#4401`_ + - enhancement + - high + - Python 3.11 compatibility + - alpha 1 + * - `#4351`_ + - bug + - medium + - Libdoc can give bad error message if library argument has extension matching resource files + - alpha 1 + * - `#4355`_ + - bug + - medium + - Continuable failures terminate WHILE loops + - alpha 1 + * - `#4357`_ + - bug + - medium + - Parsing model: Creating `TRY` and `WHILE` statements using `from_params` is not possible + - alpha 1 + * - `#4359`_ + - bug + - medium + - Parsing model: `Variable.from_params` doesn't handle list values properly + - alpha 1 + * - `#4364`_ + - bug + - medium + - `@{list}` used as embedded argument not anymore expanded if keyword accepts varargs + - beta 1 + * - `#4381`_ + - bug + - medium + - Parsing errors are recognized as EmptyLines + - alpha 1 + * - `#4384`_ + - bug + - medium + - RPA aliases for settings do not work in suite initialization files + - alpha 1 + * - `#4387`_ + - bug + - medium + - Libdoc: Fix storing information about deprecated keywords to spec files + - alpha 1 + * - `#4408`_ + - bug + - medium + - Collection: `Dictionary Should Contain Item` incorrectly casts values to strings before comparison + - alpha 1 + * - `#4418`_ + - bug + - medium + - Dictionaries insider lists in YAML variable files not converted to DotDict objects + - beta 1 + * - `#4447`_ + - bug + - medium + - Evaluating expressions that modify evaluation namespace (locals) fail + - beta 1 + * - `#4069`_ + - enhancement + - medium + - Variables acting as embedded arguments are not checked against regular expression + - beta 1 + * - `#4262`_ + - enhancement + - medium + - Honor `SOURCE_DATE_EPOCH` environment variable when generating library documentation + - alpha 1 + * - `#4312`_ + - enhancement + - medium + - Add project URLs to PyPI + - alpha 1 + * - `#4353`_ + - enhancement + - medium + - Performance enhancements to parsing + - alpha 1 + * - `#4354`_ + - enhancement + - medium + - When merging suites with Rebot, copy documentation and metadata from merged suites + - beta 1 + * - `#4371`_ + - enhancement + - medium + - Add `AS` alias for `WITH NAME` in library imports + - alpha 1 + * - `#4379`_ + - enhancement + - medium + - Require space after Given/When/Then prefixes + - alpha 1 + * - `#4398`_ + - enhancement + - medium + - Collections: `Get From Dictionary` should accept a default value + - alpha 1 + * - `#4404`_ + - enhancement + - medium + - Document that failing test setup stops execution even if continue-on-failure mode is active + - alpha 1 + * - `#4413`_ + - enhancement + - medium + - Dictionary related keywords in `Collections` are more script about accepted values + - alpha 1 + * - `#4429`_ + - enhancement + - medium + - Allow passing flags to regexp related keywords using explicit `flags` argument + - beta 1 + * - `#4431`_ + - enhancement + - medium + - Deprecate using singular section headers + - beta 1 + * - `#4440`_ + - enhancement + - medium + - Allow using `None` as custom argument converter to enable strict type validation + - beta 1 + * - `#4349`_ + - bug + - low + - User Guide: Example related to YAML variable files is buggy + - alpha 1 + * - `#4358`_ + - bug + - low + - User Guide: Errors in examples related to TRY/EXCEPT + - alpha 1 + * - `#4346`_ + - enhancement + - low + - Enhance documentation of the `--timestampoutputs` option + - alpha 1 + * - `#4372`_ + - enhancement + - low + - Document how to import resource files bundled into Python packages + - alpha 1 + * - `#4394`_ + - bug + - --- + - Error when `--doc` or `--metadata` value matches an existing directory + - alpha 1 + +Altogether 42 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.1>`__. + +.. _#4096: https://github.com/robotframework/robotframework/issues/4096 +.. _#519: https://github.com/robotframework/robotframework/issues/519 +.. _#4295: https://github.com/robotframework/robotframework/issues/4295 +.. _#430: https://github.com/robotframework/robotframework/issues/430 +.. _#4303: https://github.com/robotframework/robotframework/issues/4303 +.. _#4335: https://github.com/robotframework/robotframework/issues/4335 +.. _#4366: https://github.com/robotframework/robotframework/issues/4366 +.. _#4368: https://github.com/robotframework/robotframework/issues/4368 +.. _#4373: https://github.com/robotframework/robotframework/issues/4373 +.. _#4380: https://github.com/robotframework/robotframework/issues/4380 +.. _#4388: https://github.com/robotframework/robotframework/issues/4388 +.. _#4400: https://github.com/robotframework/robotframework/issues/4400 +.. _#4401: https://github.com/robotframework/robotframework/issues/4401 +.. _#4351: https://github.com/robotframework/robotframework/issues/4351 +.. _#4355: https://github.com/robotframework/robotframework/issues/4355 +.. _#4357: https://github.com/robotframework/robotframework/issues/4357 +.. _#4359: https://github.com/robotframework/robotframework/issues/4359 +.. _#4364: https://github.com/robotframework/robotframework/issues/4364 +.. _#4381: https://github.com/robotframework/robotframework/issues/4381 +.. _#4384: https://github.com/robotframework/robotframework/issues/4384 +.. _#4387: https://github.com/robotframework/robotframework/issues/4387 +.. _#4408: https://github.com/robotframework/robotframework/issues/4408 +.. _#4418: https://github.com/robotframework/robotframework/issues/4418 +.. _#4447: https://github.com/robotframework/robotframework/issues/4447 +.. _#4069: https://github.com/robotframework/robotframework/issues/4069 +.. _#4262: https://github.com/robotframework/robotframework/issues/4262 +.. _#4312: https://github.com/robotframework/robotframework/issues/4312 +.. _#4353: https://github.com/robotframework/robotframework/issues/4353 +.. _#4354: https://github.com/robotframework/robotframework/issues/4354 +.. _#4371: https://github.com/robotframework/robotframework/issues/4371 +.. _#4379: https://github.com/robotframework/robotframework/issues/4379 +.. _#4398: https://github.com/robotframework/robotframework/issues/4398 +.. _#4404: https://github.com/robotframework/robotframework/issues/4404 +.. _#4413: https://github.com/robotframework/robotframework/issues/4413 +.. _#4429: https://github.com/robotframework/robotframework/issues/4429 +.. _#4431: https://github.com/robotframework/robotframework/issues/4431 +.. _#4440: https://github.com/robotframework/robotframework/issues/4440 +.. _#4349: https://github.com/robotframework/robotframework/issues/4349 +.. _#4358: https://github.com/robotframework/robotframework/issues/4358 +.. _#4346: https://github.com/robotframework/robotframework/issues/4346 +.. _#4372: https://github.com/robotframework/robotframework/issues/4372 +.. _#4394: https://github.com/robotframework/robotframework/issues/4394 From 6a2e9790e43ea4e0028f20c31ba57f397b7d6095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 21:06:08 +0300 Subject: [PATCH 0179/1592] Release notes tuning. --- doc/releasenotes/rf-5.1b1.rst | 64 +++++++++++++++++------------------ 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/doc/releasenotes/rf-5.1b1.rst b/doc/releasenotes/rf-5.1b1.rst index 77b34fc4456..9e9357a6787 100644 --- a/doc/releasenotes/rf-5.1b1.rst +++ b/doc/releasenotes/rf-5.1b1.rst @@ -171,6 +171,13 @@ only used with keywords but also with all control structures. Earlier these meth always got exactly the same information, but nowadays there is additional context specific details with control structures (`#4335`_). +Performance enhancements for executing user keywords +---------------------------------------------------- + +The overhead in executing user keywords has been reduced. The difference +can be seen especially if user keywords fail often, for example, when using +`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) + Python 3.11 support -------------------- @@ -181,13 +188,6 @@ versions work fairly well. At the other end of the spectrum, Python 3.6 is deprecated and will not anymore be supported by Robot Framework 6.0 (`#4295`_). -Performance enhancements for executing user keywords ----------------------------------------------------- - -The overhead in executing user keywords has been reduced. The difference -can be seen especially if user keywords fail often, for example, when using -`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) - Backwards incompatible changes ============================== @@ -204,46 +204,36 @@ Backwards incompatible changes that is equivalent with the new format. JSON spec files did not include the timezone information at all and the format was `2022-05-27 19:07:15`. (`#4262`_) -Deprecated features -=================== +Deprecations +============ `Force Tags` and `Default Tags` settings ---------------------------------------- As `discussed above`__, new `Test Tags` setting has been added to replace `Force Tags` and there is a plan to remove `Default Tags` altogether. Both of these settings still -work but they are considered deprecated. There is not visible deprecation warning yet, +work but they are considered deprecated. There is no visible deprecation warning yet, but such a warning will be emitted starting from Robot Framework 6.0 and eventually these settings will be removed. (`#4368`_) The plan is to add new `-tag` syntax that can be used with the `[Tags]` setting -to enable similar functionality that `Default Tags` provide. As the result -using tags starting with a hyphen with the `[Tags]` setting is deprecated. -If such literal values are needed, it is possible to use escaped format like -`\-tag`. (`#4380`_) +to enable similar functionality that the `Default Tags` setting provides. Because +of that, using tags starting with a hyphen with the `[Tags]` setting is now deprecated. +If such literal values are needed, it is possible to use escaped format like `\-tag`. +(`#4380`_) __ `Enhancements for setting keyword and test tags`_ -Python 3.6 ----------- - -Python 3.6 `reached end-of-life`__ in December 2021. It will be still supported -by Robot Framework 5.1 and all future RF 5.x releases, but not anymore by -Robot Framework 6.0 (`#4295`_). Users are recommended to upgrade to newer -versions already now. - -__ https://endoflife.date/python - Keywords in test case files having precedence over local keywords in resource files ----------------------------------------------------------------------------------- Keywords in test cases files currently always have the highest precedence. They are used even when a keyword in a resource file uses a keyword that would exist also -in the same resource file. This will change in Robot Framework 5.2 so that local -keywords always have highest precedence and the current behavior is deprecated. (`#4366`_) +in the same resource file. This will change so that local keywords always have +highest precedence and the current behavior is deprecated. (`#4366`_) -`WITH NAME` deprecated in favor of `AS` when giving alias to imported library ------------------------------------------------------------------------------ +`WITH NAME` in favor of `AS` when giving alias to imported library +------------------------------------------------------------------ `WITH NAME` marker that is used when giving an alias to an imported library will be renamed to `AS` (`#4371`_). The motivation is to be consistent with @@ -256,8 +246,8 @@ In Robot Framework 5.1 both `AS` and `WITH NAME` work when setting an alias for a library. `WITH NAME` is considered deprecated, but there will not be visible deprecation warnings until Robot Framework 6.0. -Singular section headers like `Test Case` are deprecated --------------------------------------------------------- +Singular section headers like `Test Case` +----------------------------------------- Robot Framework has earlier accepted both plural (e.g. `Test Cases`) and singular (e.g. `Test Case`) section headers. The singular variants are now deprecated @@ -265,12 +255,22 @@ and their support will eventually be removed (`#4431`_). The is no visible deprecation warning yet, but they will most likely be emitted starting from Robot Framework 6.0. +Python 3.6 support +------------------ + +Python 3.6 `reached end-of-life`__ in December 2021. It will be still supported +by Robot Framework 5.1 and all future RF 5.x releases, but not anymore by +Robot Framework 6.0 (`#4295`_). Users are recommended to upgrade to newer +versions already now. + +__ https://endoflife.date/python + Acknowledgements ================ Robot Framework development is sponsored by the `Robot Framework Foundation`_ -and its close to 50 member organizations. Robot Framework 5.1 team funded by -them consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and +and its ~50 member organizations. Robot Framework 5.1 team funded by the foundation +consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and `Janne Härkönen <https://github.com/yanne>`_ (part time). In addition to that, the wider open source community has provided several great contributions: From 9cb3a7ae26806123d44fbec3530bb24e220b0547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 21:06:24 +0300 Subject: [PATCH 0180/1592] Updated version to 5.1b1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 0dcfec675a2..8d78791d507 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1a3.dev1' +VERSION = '5.1b1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 0dcbc2070ee..7db057dca86 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1a3.dev1' +VERSION = '5.1b1' def get_version(naked=False): From cbd45077e4e82bd8b9362470b1dbf9c86bb1c77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Sep 2022 21:07:14 +0300 Subject: [PATCH 0181/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 8d78791d507..e8dc1684bb9 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1b1' +VERSION = '5.1b2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 7db057dca86..6be5c52995b 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1b1' +VERSION = '5.1b2.dev1' def get_version(naked=False): From 41a8f608e3ce6758de152b9ed4decbc8ea8183df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 4 Sep 2022 09:46:54 +0300 Subject: [PATCH 0182/1592] Faster searching of variables in strings. Based on `timeit` this implementation is considerably faster. For example, ``` python -m timeit 'from robot.variables.search import search_variable' 'search_variable("${var}")' ``` reports ~3.5usec per loop with the old implemenation and only ~2.0usec per loop with the new one. Although the difference in percentages is huge, also the old code was so was that this doesn't affect the actual execution times too much. For example, the execution time of the following test dropped from ~910msec to ~880msec. Not a huge drop, but the difference is measurable and adds up with longer suites. ``` *** Variables *** ${var} var @{list} one two *** Test Cases *** Example FOR ${i} IN RANGE 1000 Log ${var} Log Some text with ${var} and more with ${i + ${1}} still more with ${list}[0] END ``` One reason for the speedup is having the core logic in loop without any function calls. That results with somewhat complicated code, but the old state machine wasn't that easy to understand either and this implementation is quite a bit shorter. --- src/robot/variables/search.py | 182 +++++++++++++--------------------- 1 file changed, 70 insertions(+), 112 deletions(-) diff --git a/src/robot/variables/search.py b/src/robot/variables/search.py index 3ed4bfd50c1..5339227a0d6 100644 --- a/src/robot/variables/search.py +++ b/src/robot/variables/search.py @@ -22,7 +22,7 @@ def search_variable(string, identifiers='$@&%*', ignore_errors=False): if not (is_string(string) and '{' in string): return VariableMatch(string) - return VariableSearcher(identifiers, ignore_errors).search(string) + return _search_variable(string, identifiers, ignore_errors) def contains_variable(string, identifiers='$@&'): @@ -142,119 +142,77 @@ def __str__(self): return '%s{%s}%s' % (self.identifier, self.base, items) -class VariableSearcher: +def _search_variable(string, identifiers, ignore_errors=False): + start = _find_variable_start(string, identifiers) + if start < 0: + return VariableMatch(string) - def __init__(self, identifiers, ignore_errors=False): - self.identifiers = identifiers - self._ignore_errors = ignore_errors - self.start = -1 - self.variable_chars = [] - self.item_chars = [] - self.items = [] - self._open_brackets = 0 # Used both with curly and square brackets - self._escaped = False - - def search(self, string): - if not self._search(string): + match = VariableMatch(string, identifier=string[start], start=start) + left_brace, right_brace = '{', '}' + open_braces = 1 + escaped = False + items = [] + indices_and_chars = enumerate(string[start+2:], start=start+2) + + for index, char in indices_and_chars: + if char == left_brace and not escaped: + open_braces += 1 + + elif char == right_brace and not escaped: + open_braces -= 1 + + if open_braces == 0: + next_char = string[index+1] if index+1 < len(string) else None + + if left_brace == '{': # Parsing name. + match.base = string[start+2:index] + if match.identifier not in '$@&' or next_char != '[': + match.end = index + 1 + break + left_brace, right_brace = '[', ']' + + else: # Parsing items. + items.append(string[start+1:index]) + if next_char != '[': + match.end = index + 1 + match.items = tuple(items) + break + + next(indices_and_chars) # Consume '['. + start = index + 1 # Start of the next item. + open_braces = 1 + + else: + escaped = False if char != '\\' else not escaped + + if open_braces: + if ignore_errors: return VariableMatch(string) - match = VariableMatch(string=string, - identifier=self.variable_chars[0], - base=''.join(self.variable_chars[2:-1]), - start=self.start, - end=self.start + len(self.variable_chars)) - if self.items: - match.items = tuple(self.items) - match.end += sum(len(i) for i in self.items) + 2 * len(self.items) - return match - - def _search(self, string): - start = self._find_variable_start(string) - if start == -1: - return False - self.start = start - self._open_brackets += 1 - self.variable_chars = [string[start], '{'] - start += 2 - state = self.variable_state - for char in string[start:]: - state = state(char) - self._escaped = False if char != '\\' else not self._escaped - if state is None: - break - if state: - try: - self._validate_end_state(state) - except VariableError: - if self._ignore_errors: - return False - raise - return True - - def _find_variable_start(self, string): - start = 1 - while True: - start = string.find('{', start) - 1 - if start < 0: - return -1 - if self._start_index_is_ok(string, start): - return start - start += 2 - - def _start_index_is_ok(self, string, index): - return (string[index] in self.identifiers - and not self._is_escaped(string, index)) - - def _is_escaped(self, string, index): - escaped = False - while index > 0 and string[index-1] == '\\': - index -= 1 - escaped = not escaped - return escaped - - def variable_state(self, char): - self.variable_chars.append(char) - if char == '}' and not self._escaped: - self._open_brackets -= 1 - if self._open_brackets == 0: - if not self._can_have_items(): - return None - return self.waiting_item_state - elif char == '{' and not self._escaped: - self._open_brackets += 1 - return self.variable_state - - def _can_have_items(self): - return self.variable_chars[0] in '$@&' - - def waiting_item_state(self, char): - if char == '[': - self._open_brackets += 1 - return self.item_state - return None - - def item_state(self, char): - if char == ']' and not self._escaped: - self._open_brackets -= 1 - if self._open_brackets == 0: - self.items.append(''.join(self.item_chars)) - self.item_chars = [] - return self.waiting_item_state - elif char == '[' and not self._escaped: - self._open_brackets += 1 - self.item_chars.append(char) - return self.item_state - - def _validate_end_state(self, state): - if state == self.variable_state: - incomplete = ''.join(self.variable_chars) - raise VariableError("Variable '%s' was not closed properly." - % incomplete) - if state == self.item_state: - variable = ''.join(self.variable_chars) - items = ''.join('[%s]' % i for i in self.items) - incomplete = ''.join(self.item_chars) - raise VariableError("Variable item '%s%s[%s' was not closed " - "properly." % (variable, items, incomplete)) + incomplete = string[match.start:] + if left_brace == '{': + raise VariableError(f"Variable '{incomplete}' was not closed properly.") + raise VariableError(f"Variable item '{incomplete}' was not closed properly.") + + return match if match else VariableMatch(match) + + +def _find_variable_start(string, identifiers): + index = 1 + while True: + index = string.find('{', index) - 1 + if index < 0: + return -1 + if string[index] in identifiers and _not_escaped(string, index): + return index + index += 2 + + +def _not_escaped(string, index): + escaped = False + while index > 0 and string[index-1] == '\\': + index -= 1 + escaped = not escaped + return not escaped def unescape_variable_syntax(item): From 7d50028dde311daecd5b6f033c1afdaa943f351a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 8 Sep 2022 23:10:13 +0300 Subject: [PATCH 0183/1592] Fix Run Keywords in teardown if kw name contains invalid variable. Now execution is continued in this case. Fixes #4453. --- .../builtin/run_keywords.robot | 6 +++ .../builtin/run_keywords.robot | 41 +++++++++++++++++++ src/robot/libraries/BuiltIn.py | 13 ++++-- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/run_keywords.robot b/atest/robot/standard_libraries/builtin/run_keywords.robot index 39049cea76d..1cd83a90ab3 100644 --- a/atest/robot/standard_libraries/builtin/run_keywords.robot +++ b/atest/robot/standard_libraries/builtin/run_keywords.robot @@ -43,6 +43,12 @@ In test setup In test teardown Check Test Case ${TESTNAME} +In test teardown with non-existing variable in keyword name (with AND) + Check Test Case ${TESTNAME} + +In test teardown with non-existing variable in keyword name (without AND) + Check Test Case ${TESTNAME} + In test teardown with ExecutionPassed exception Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/builtin/run_keywords.robot b/atest/testdata/standard_libraries/builtin/run_keywords.robot index f3b2d94cd98..4a4deb54fb0 100644 --- a/atest/testdata/standard_libraries/builtin/run_keywords.robot +++ b/atest/testdata/standard_libraries/builtin/run_keywords.robot @@ -84,6 +84,44 @@ In test teardown ... Non-existing Variable Fail Non-Existing Keyword ... Syntax Error Not Executed After Previous Syntax Error +In test teardown with non-existing variable in keyword name (with AND) + [Documentation] + ... FAIL Teardown failed: + ... Several failures occurred: + ... + ... 1) No keyword with name '\${bad}' found. + ... + ... 2) Executed + ... + ... 3) Variable '\${bad}' not found. + ... + ... 4) Executed${ATD ERR} + No Operation + [Teardown] Run keywords + ... ${bad} AND + ... ${{'Fail'}} Executed AND + ... Embedded ${bad} AND + ... Fail Executed + +In test teardown with non-existing variable in keyword name (without AND) + [Documentation] + ... FAIL Teardown failed: + ... Several failures occurred: + ... + ... 1) No keyword with name '\${bad}' found. + ... + ... 2) AssertionError + ... + ... 3) Variable '\${bad}' not found. + ... + ... 4) AssertionError${ATD ERR} + No Operation + [Teardown] Run keywords + ... ${bad} + ... ${{'Fail'}} + ... Embedded ${bad} + ... Fail + In test teardown with ExecutionPassed exception [Documentation] FAIL Stop here${ATD ERR} No Operation @@ -126,3 +164,6 @@ Non-existing Variable Syntax Error ${invalid} + +Embedded ${arg} + Log ${arg} diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 378675505e7..e1f0222cfb5 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1898,13 +1898,17 @@ def _run_keywords(self, iterable): def _split_run_keywords(self, keywords): if 'AND' not in keywords: - for name in self._variables.replace_list(keywords): + for name in self._split_run_keywords_without_and(keywords): yield name, () else: - for name, args in self._split_run_keywords_from_and(keywords): + for name, args in self._split_run_keywords_with_and(keywords): yield name, args - def _split_run_keywords_from_and(self, keywords): + def _split_run_keywords_without_and(self, keywords): + ignore_errors = self._context.in_teardown + return self._variables.replace_list(keywords, ignore_errors=ignore_errors) + + def _split_run_keywords_with_and(self, keywords): while 'AND' in keywords: index = keywords.index('AND') yield self._resolve_run_keywords_name_and_args(keywords[:index]) @@ -1912,7 +1916,8 @@ def _split_run_keywords_from_and(self, keywords): yield self._resolve_run_keywords_name_and_args(keywords) def _resolve_run_keywords_name_and_args(self, kw_call): - kw_call = self._variables.replace_list(kw_call, replace_until=1) + kw_call = self._variables.replace_list(kw_call, replace_until=1, + ignore_errors=self._context.in_teardown) if not kw_call: raise DataError('Incorrect use of AND') return kw_call[0], kw_call[1:] From f75c5b2276f8f9192746015068a986e71d4efc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 9 Sep 2022 15:39:26 +0300 Subject: [PATCH 0184/1592] Test localized Boolean conversion via Run Keyword. --- .../keywords/type_conversion/translated_boolean_values.robot | 3 +++ .../keywords/type_conversion/translated_boolean_values.robot | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/atest/robot/keywords/type_conversion/translated_boolean_values.robot b/atest/robot/keywords/type_conversion/translated_boolean_values.robot index 355807ae733..746fff37efd 100644 --- a/atest/robot/keywords/type_conversion/translated_boolean_values.robot +++ b/atest/robot/keywords/type_conversion/translated_boolean_values.robot @@ -5,3 +5,6 @@ Resource atest_resource.robot *** Test Cases *** Boolean Check Test Case ${TESTNAME} + +Via Run Keyword + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/translated_boolean_values.robot b/atest/testdata/keywords/type_conversion/translated_boolean_values.robot index 4a5aa42744e..18f58f8e2e3 100644 --- a/atest/testdata/keywords/type_conversion/translated_boolean_values.robot +++ b/atest/testdata/keywords/type_conversion/translated_boolean_values.robot @@ -10,3 +10,7 @@ Boolean Boolean EpäTOSI False Boolean EI False Boolean Pois False + +Via Run Keyword + Run Keyword Boolean Kyllä True + Run Keyword Boolean EI False From 83017739d57a3c9f8d06528f404384bb884384f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 10 Sep 2022 01:05:13 +0300 Subject: [PATCH 0185/1592] Pass embedded args as objects to Run Keyword and its variants. Earlier variables were replaced in keyword names before keywords were called. This meant that keywords using embedded arguments only go string representations of objects, not objects themselves. The biggest change that the `name` argument passed to `BuiltIn.run_keyword` isn't anymore resolved before calling the keyword. Instead the keyword resolves it itself after first checking does it match some keyword accepting embedded arguments. If it does, `name` is not resolved and variables will be handled later by appropriate runners. The above change ought to be backwards compatible when using `Run Keyword` in data, but if `BuiltIn.run_keyword` is used by a library keyword handling of the `name` argument changes. This only affects situations where the name contains variables or escapes so it shouldn't cause problems in normal usage. Not resolving `name` caused various bigger and smaller changes elsewhere. For example, how run keyword variants were handled in dry run was affected and that logic needed to be changed. The bad and deprecated RUN_KW_REGISTER was enhanced to help with that and its documentation was enhanced at the same time. Fixes #1595. --- .../trace_log_keyword_arguments.robot | 2 +- .../builtin/repeat_keyword.robot | 15 +- .../builtin/run_keyword.robot | 40 +++++ ...n_keyword_variants_variable_handling.robot | 17 +- .../builtin/run_keywords.robot | 36 +++- .../builtin/run_keywords_with_arguments.robot | 14 ++ .../builtin/RegisteringLibrary.py | 2 +- .../builtin/embedded_args.py | 13 ++ .../builtin/run_keyword.robot | 32 ++++ ...n_keyword_variants_variable_handling.robot | 13 +- .../builtin/run_keywords.robot | 55 ++++--- .../builtin/run_keywords_with_arguments.robot | 102 ++++++++---- .../standard_libraries/builtin/variable.py | 13 +- src/robot/libraries/BuiltIn.py | 155 +++++++++++------- src/robot/running/handlers.py | 8 +- src/robot/running/librarykeywordrunner.py | 27 +-- src/robot/running/runkwregister.py | 52 ++++-- 17 files changed, 429 insertions(+), 167 deletions(-) create mode 100644 atest/testdata/standard_libraries/builtin/embedded_args.py diff --git a/atest/robot/keywords/trace_log_keyword_arguments.robot b/atest/robot/keywords/trace_log_keyword_arguments.robot index 1aaeb3adb48..be52a1bf928 100644 --- a/atest/robot/keywords/trace_log_keyword_arguments.robot +++ b/atest/robot/keywords/trace_log_keyword_arguments.robot @@ -68,7 +68,7 @@ Object With Unicode Repr as Argument Arguments With Run Keyword ${tc}= Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[1].msgs[0]} Arguments: [ 'Catenate' | '\@{VALUES}' ] TRACE + Check Log Message ${tc.kws[1].msgs[0]} Arguments: [ '\${keyword name}' | '\@{VALUES}' ] TRACE Check Log Message ${tc.kws[1].kws[0].msgs[0]} Arguments: [ 'a' | 'b' | 'c' | 'd' ] TRACE Embedded Arguments diff --git a/atest/robot/standard_libraries/builtin/repeat_keyword.robot b/atest/robot/standard_libraries/builtin/repeat_keyword.robot index c745e9c1b9d..e7ebaacf2fb 100644 --- a/atest/robot/standard_libraries/builtin/repeat_keyword.robot +++ b/atest/robot/standard_libraries/builtin/repeat_keyword.robot @@ -23,9 +23,9 @@ Times With 'x' Postfix Zero And Negative Times ${tc} = Check Test Case ${TEST NAME} - Check Repeated Messages ${tc.kws[0]} 0 - Check Repeated Messages ${tc.kws[2]} 0 - Check Repeated Messages ${tc.kws[3]} 0 + Check Repeated Messages ${tc.kws[0]} 0 name=This is not executed + Check Repeated Messages ${tc.kws[2]} 0 name=\${name} + Check Repeated Messages ${tc.kws[3]} 0 name=This is not executed Invalid Times Check Test Case Invalid Times 1 @@ -72,14 +72,17 @@ Repeat Keyword With Pass Execution After Continuable Failure *** Keywords *** Check Repeated Messages - [Arguments] ${kw} ${count} ${msg}=${None} + [Arguments] ${kw} ${count} ${msg}= ${name}= Should Be Equal As Integers ${kw.kw_count} ${count} FOR ${i} IN RANGE ${count} Check Log Message ${kw.msgs[${i}]} Repeating keyword, round ${i+1}/${count}. Check Log Message ${kw.kws[${i}].msgs[0]} ${msg} END - Run Keyword If ${count} == 0 Check Log Message ${kw.msgs[0]} Keyword 'This is not executed' repeated zero times. - Run Keyword If ${count} != 0 Should Be Equal As Integers ${kw.msg_count} ${count} + IF ${count} != 0 + Should Be Equal As Integers ${kw.msg_count} ${count} + ELSE + Check Log Message ${kw.msgs[0]} Keyword '${name}' repeated zero times. + END Check Repeated Messages With Time [Arguments] ${kw} ${msg}=${None} diff --git a/atest/robot/standard_libraries/builtin/run_keyword.robot b/atest/robot/standard_libraries/builtin/run_keyword.robot index 574fdaa98e6..7116e8d7051 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword.robot @@ -36,6 +36,34 @@ Run Keyword With UK Run Keyword In Multiple Levels And With UK Check test Case ${TEST NAME} +With keyword accepting embedded arguments + ${tc} = Check test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc.kws[0]} Embedded "arg" arg + +With library keyword accepting embedded arguments + ${tc} = Check test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc.kws[0]} Embedded "arg" in library arg + +With keyword accepting embedded arguments as variables + ${tc} = Check test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc.kws[0]} Embedded "\${VARIABLE}" value + Check Run Keyword With Embedded Args ${tc.kws[1]} Embedded "\${1}" 1 + +With library keyword accepting embedded arguments as variables + ${tc} = Check test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc.kws[0]} Embedded "\${VARIABLE}" in library value + Check Run Keyword With Embedded Args ${tc.kws[1]} Embedded "\${1}" in library 1 + +With keyword accepting embedded arguments as variables containing objects + ${tc} = Check test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc.kws[0]} Embedded "\${OBJECT}" Robot + Check Run Keyword With Embedded Args ${tc.kws[1]} Embedded object "\${OBJECT}" Robot + +With library keyword accepting embedded arguments as variables containing objects + ${tc} = Check test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc.kws[0]} Embedded "\${OBJECT}" in library Robot + Check Run Keyword With Embedded Args ${tc.kws[1]} Embedded object "\${OBJECT}" in library Robot + Run Keyword In For Loop ${tc} = Check test Case ${TEST NAME} Check Run Keyword ${tc.kws[0].kws[0].kws[0]} BuiltIn.Log hello from for loop @@ -81,3 +109,15 @@ Check Run Keyword In Uk Should Be Equal ${kw.name} BuiltIn.Run Keyword Should Be Equal ${kw.kws[0].name} My UK Check Run Keyword ${kw.kws[0].kws[0]} ${subkw_name} @{msgs} + +Check Run Keyword With Embedded Args + [Arguments] ${kw} ${subkw_name} ${msg} + Should Be Equal ${kw.name} BuiltIn.Run Keyword + IF ${subkw_name.endswith('library')} + Should Be Equal ${kw.kws[0].name} embedded_args.${subkw_name} + Check Log Message ${kw.kws[0].msgs[0]} ${msg} + ELSE + Should Be Equal ${kw.kws[0].name} ${subkw_name} + Should Be Equal ${kw.kws[0].kws[0].name} BuiltIn.Log + Check Log Message ${kw.kws[0].kws[0].msgs[0]} ${msg} + END diff --git a/atest/robot/standard_libraries/builtin/run_keyword_variants_variable_handling.robot b/atest/robot/standard_libraries/builtin/run_keyword_variants_variable_handling.robot index dd277e90c6b..19b89febcf2 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_variants_variable_handling.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_variants_variable_handling.robot @@ -5,16 +5,25 @@ Resource atest_resource.robot *** Test Case *** Variable Values Should Not Be Visible As Keyword's Arguments ${tc} = Check Test Case ${TEST NAME} - Check Keyword Data ${tc.kws[0]} BuiltIn.Run Keyword args=My UK, Log, \${OBJECT} - Check Keyword Data ${tc.kws[0].kws[0]} My UK args=Log, \${OBJECT} - Check Keyword Data ${tc.kws[0].kws[0].kws[0]} BuiltIn.Run Keyword args=\${name}, \@{args} - Check Keyword Data ${tc.kws[0].kws[0].kws[0].kws[0]} BuiltIn.Log args=\@{args} + Check Keyword Data ${tc.kws[0]} BuiltIn.Run Keyword args=My UK, Log, \${OBJECT} + Check Keyword Data ${tc.kws[0].kws[0]} My UK args=Log, \${OBJECT} + Check Keyword Data ${tc.kws[0].kws[0].kws[0]} BuiltIn.Run Keyword args=\${name}, \@{args} + Check Keyword Data ${tc.kws[0].kws[0].kws[0].kws[0]} BuiltIn.Log args=\@{args} + Check Log Message ${tc.kws[0].kws[0].kws[0].kws[0].msgs[0]} Robot + Check Keyword Data ${tc.kws[0].kws[0].kws[1].kws[0]} BuiltIn.Log args=\${args}[0] + Check Log Message ${tc.kws[0].kws[0].kws[1].kws[0].msgs[0]} Robot Run Keyword When Keyword and Arguments Are in List Variable ${tc} = Check Test Case ${TEST NAME} Check Keyword Data ${tc.kws[0].kws[0]} \\Log Many args=c:\\\\temp\\\\foo, \\\${notvar} Check Keyword Data ${tc.kws[1].kws[0]} \\Log Many args=\\\${notvar} +Run Keyword With Empty List Variable + Check Test Case ${TEST NAME} + +Run Keyword With Multiple Empty List Variables + Check Test Case ${TEST NAME} + Run Keyword If When Arguments are In Multiple List ${tc} = Check Test Case ${TEST NAME} Check Keyword Arguments And Messages ${tc} diff --git a/atest/robot/standard_libraries/builtin/run_keywords.robot b/atest/robot/standard_libraries/builtin/run_keywords.robot index 1cd83a90ab3..307cb414be9 100644 --- a/atest/robot/standard_libraries/builtin/run_keywords.robot +++ b/atest/robot/standard_libraries/builtin/run_keywords.robot @@ -1,4 +1,6 @@ *** Settings *** +Documentation Testing Run Keywords when used without AND. Tests with AND are in +... run_keywords_with_arguments.robot. Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keywords.robot Resource atest_resource.robot @@ -12,6 +14,26 @@ Failing keyword Test Should Have Correct Keywords ... Passing Failing +Embedded arguments + ${tc} = Test Should Have Correct Keywords + ... Embedded "arg" Embedded "\${1}" Embedded object "\${OBJECT}" + Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} arg + Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} 1 + Check Log Message ${tc.kws[0].kws[2].kws[0].msgs[0]} Robot + +Embedded arguments with library keywords + ${tc} = Test Should Have Correct Keywords + ... embedded_args.Embedded "arg" in library + ... embedded_args.Embedded "\${1}" in library + ... embedded_args.Embedded object "\${OBJECT}" in library + Check Log Message ${tc.kws[0].kws[0].msgs[0]} arg + Check Log Message ${tc.kws[0].kws[1].msgs[0]} 1 + Check Log Message ${tc.kws[0].kws[2].msgs[0]} Robot + +Keywords names needing escaping + Test Should Have Correct Keywords + ... Needs \\escaping \\\${notvar} + Continuable failures Test Should Have Correct Keywords ... Continuable failure Multiple continuables Failing @@ -21,9 +43,14 @@ Keywords as variables ... BuiltIn.No Operation Passing BuiltIn.No Operation ... Passing BuiltIn.Log Variables Failing +Keywords names needing escaping as variable + Test Should Have Correct Keywords + ... Needs \\escaping \\\${notvar} Needs \\escaping \\\${notvar} + ... kw_index=1 + Non-existing variable as keyword name - ${tc} = Check Test Case ${TESTNAME} - Should Be Empty ${tc.kws[0].kws} + Test Should Have Correct Keywords + ... Passing Non-existing variable inside executed keyword Test Should Have Correct Keywords @@ -43,10 +70,7 @@ In test setup In test teardown Check Test Case ${TESTNAME} -In test teardown with non-existing variable in keyword name (with AND) - Check Test Case ${TESTNAME} - -In test teardown with non-existing variable in keyword name (without AND) +In test teardown with non-existing variable in keyword name Check Test Case ${TESTNAME} In test teardown with ExecutionPassed exception diff --git a/atest/robot/standard_libraries/builtin/run_keywords_with_arguments.robot b/atest/robot/standard_libraries/builtin/run_keywords_with_arguments.robot index 4a4d98780d7..b155ef8b66d 100644 --- a/atest/robot/standard_libraries/builtin/run_keywords_with_arguments.robot +++ b/atest/robot/standard_libraries/builtin/run_keywords_with_arguments.robot @@ -1,4 +1,6 @@ *** Settings *** +Documentation Testing Run Keywords when used with AND. Tests without AND are in +... run_keywords.robot. Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keywords_with_arguments.robot Resource atest_resource.robot @@ -54,3 +56,15 @@ Consecutive AND's AND as first argument should raise an error Check Test Case ${TESTNAME} + +Keywords names needing escaping + Test Should Have Correct Keywords + ... Needs \\escaping \\\${notvar} Needs \\escaping \\\${notvar} + +Keywords names needing escaping as variable + Test Should Have Correct Keywords + ... Needs \\escaping \\\${notvar} Needs \\escaping \\\${notvar} + ... kw_index=1 + +In test teardown with non-existing variable in keyword name + Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/builtin/RegisteringLibrary.py b/atest/testdata/standard_libraries/builtin/RegisteringLibrary.py index 1622a6a6c5d..e2f49e24817 100644 --- a/atest/testdata/standard_libraries/builtin/RegisteringLibrary.py +++ b/atest/testdata/standard_libraries/builtin/RegisteringLibrary.py @@ -8,6 +8,6 @@ def run_keyword_function(name, *args): register_run_keyword(__name__, 'run_keyword_function', 1) def run_keyword_without_keyword(*args): - return BuiltIn().run_keyword('\Log Many', *args) + return BuiltIn().run_keyword(r'\\Log Many', *args) register_run_keyword(__name__, 'run_keyword_without_keyword', 0) diff --git a/atest/testdata/standard_libraries/builtin/embedded_args.py b/atest/testdata/standard_libraries/builtin/embedded_args.py new file mode 100644 index 00000000000..1cacf6fd422 --- /dev/null +++ b/atest/testdata/standard_libraries/builtin/embedded_args.py @@ -0,0 +1,13 @@ +from robot.api.deco import keyword + + +@keyword('Embedded "${argument}" in library') +def embedded(arg): + print(arg) + + +@keyword('Embedded object "${obj}" in library') +def embedded_object(obj): + print(obj) + if obj.name != 'Robot': + raise AssertionError(f"'{obj.name}' != 'Robot'") diff --git a/atest/testdata/standard_libraries/builtin/run_keyword.robot b/atest/testdata/standard_libraries/builtin/run_keyword.robot index eb64f409063..4b8557fae3c 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword.robot @@ -1,9 +1,12 @@ *** Settings *** Library OperatingSystem +Library embedded_args.py +Variables variable.py *** Variables *** @{NEEDS ESCAPING} c:\\temp\\foo \${notvar} ${42} ${FAIL KW} Fail +${VARIABLE} value *** Test Cases *** Run Keyword @@ -48,6 +51,28 @@ Run Keyword In Multiple Levels And With UK Run Keyword Run Keyword Run Keyword My UK Run Keyword ... My UK My UK My UK Run Keyword Fail Expected Failure +With keyword accepting embedded arguments + Run Keyword Embedded "arg" + +With library keyword accepting embedded arguments + Run Keyword Embedded "arg" in library + +With keyword accepting embedded arguments as variables + Run Keyword Embedded "${VARIABLE}" + Run Keyword Embedded "${1}" + +With library keyword accepting embedded arguments as variables + Run Keyword Embedded "${VARIABLE}" in library + Run Keyword Embedded "${1}" in library + +With keyword accepting embedded arguments as variables containing objects + Run Keyword Embedded "${OBJECT}" + Run Keyword Embedded object "${OBJECT}" + +With library keyword accepting embedded arguments as variables containing objects + Run Keyword Embedded "${OBJECT}" in library + Run Keyword Embedded object "${OBJECT}" in library + Run Keyword In For Loop [Documentation] FAIL Expected failure in For Loop FOR ${kw} ${arg1} ${arg2} IN @@ -99,3 +124,10 @@ Timeoutted UK Passing Timeoutted UK Timeouting [Timeout] 300 milliseconds Sleep 1 second + +Embedded "${arg}" + Log ${arg} + +Embedded object "${obj}" + Log ${obj} + Should Be Equal ${obj.name} Robot diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_variants_variable_handling.robot b/atest/testdata/standard_libraries/builtin/run_keyword_variants_variable_handling.robot index 553336a4aca..c6fa21cecf7 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_variants_variable_handling.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_variants_variable_handling.robot @@ -6,7 +6,6 @@ Variables variable.py @{NEEDS ESCAPING} c:\\temp\\foo \${notvar} @{KEYWORD AND ARG WHICH NEEDS ESCAPING} \\Log Many \${notvar} @{KEYWORD AND ARGS WHICH NEEDS ESCAPING} \\Log Many @{NEEDS ESCAPING} -@{EMPTY} @{EXPRESSION} ${TRUE} @{ARGS} @{NEEDS ESCAPING} ${KEYWORD} \\Log Many @@ -20,6 +19,16 @@ Run Keyword When Keyword and Arguments Are in List Variable Run Keyword @{KEYWORD AND ARGS WHICH NEEDS ESCAPING} Run Keyword @{KEYWORD AND ARG WHICH NEEDS ESCAPING} +Run Keyword With Empty List Variable + [Documentation] FAIL + ... Keyword name missing: Given arguments ['\@{EMPTY}'] resolved to an empty list. + Run Keyword @{EMPTY} + +Run Keyword With Multiple Empty List Variables + [Documentation] FAIL + ... Keyword name missing: Given arguments ['\@{EMPTY}', '\@{{{}}}', '\@{EMPTY}'] resolved to an empty list. + Run Keyword @{EMPTY} @{{{}}} @{EMPTY} + Run Keyword If When Arguments are In Multiple List Run Keyword If @{EXPRESSION} @{KEYWORD LIST} @{ARGS} @@ -50,6 +59,8 @@ Run Keyword If With List And One Argument That needs to Be Processed My UK [Arguments] ${name} @{args} Run Keyword ${name} @{args} + Run Keyword ${name} ${args}[0] + Should Be Equal ${args[0].name} Robot \Log Many [Arguments] @{args} diff --git a/atest/testdata/standard_libraries/builtin/run_keywords.robot b/atest/testdata/standard_libraries/builtin/run_keywords.robot index 4a4deb54fb0..d398ab1ac17 100644 --- a/atest/testdata/standard_libraries/builtin/run_keywords.robot +++ b/atest/testdata/standard_libraries/builtin/run_keywords.robot @@ -1,6 +1,10 @@ *** Settings *** +Documentation Testing Run Keywords when used without AND. Tests with AND are in +... run_keywords_with_arguments.robot. Suite Setup Run keywords Passing ${NOOP} Suite Teardown Run keywords Failing Passing Fail +Library embedded_args.py +Variables variable.py *** Variables *** ${NOOP} No Operation @@ -23,6 +27,18 @@ Failing keyword [Documentation] FAIL Expected error message${ATD ERR} Run keywords Passing Failing Not Executed +Embedded arguments + [Documentation] FAIL ${TD ERR} + Run keywords Embedded "arg" Embedded "${1}" Embedded object "${OBJECT}" + +Embedded arguments with library keywords + [Documentation] FAIL ${TD ERR} + Run keywords Embedded "arg" in library Embedded "${1}" in library Embedded object "${OBJECT}" in library + +Keywords names needing escaping + [Documentation] FAIL ${TD ERR} + Run keywords Needs \\escaping \\\${notvar} + Continuable failures [Documentation] FAIL Several failures occurred: ... @@ -43,9 +59,14 @@ Keywords as variables [Documentation] FAIL Expected error message${ATD ERR} Run keywords ${NOOP} ${PASSING} @{KEYWORDS} ${FAILING} +Keywords names needing escaping as variable + [Documentation] FAIL ${TD ERR} + @{names} = Create List Needs \\escaping \\\${notvar} + Run keywords @{names} ${names}[0] + Non-existing variable as keyword name [Documentation] FAIL Variable '\${NONEXISTING}' not found.${ATD ERR} - Run keywords Not Executed ${NONEXISTING} Not Executed + Run keywords Passing ${NONEXISTING} Not Executed Non-existing variable inside executed keyword [Documentation] FAIL Variable '\${this variable does not exist}' not found.${ATD ERR} @@ -84,26 +105,7 @@ In test teardown ... Non-existing Variable Fail Non-Existing Keyword ... Syntax Error Not Executed After Previous Syntax Error -In test teardown with non-existing variable in keyword name (with AND) - [Documentation] - ... FAIL Teardown failed: - ... Several failures occurred: - ... - ... 1) No keyword with name '\${bad}' found. - ... - ... 2) Executed - ... - ... 3) Variable '\${bad}' not found. - ... - ... 4) Executed${ATD ERR} - No Operation - [Teardown] Run keywords - ... ${bad} AND - ... ${{'Fail'}} Executed AND - ... Embedded ${bad} AND - ... Fail Executed - -In test teardown with non-existing variable in keyword name (without AND) +In test teardown with non-existing variable in keyword name [Documentation] ... FAIL Teardown failed: ... Several failures occurred: @@ -119,7 +121,7 @@ In test teardown with non-existing variable in keyword name (without AND) [Teardown] Run keywords ... ${bad} ... ${{'Fail'}} - ... Embedded ${bad} + ... Embedded "${bad}" ... Fail In test teardown with ExecutionPassed exception @@ -165,5 +167,12 @@ Non-existing Variable Syntax Error ${invalid} -Embedded ${arg} +Embedded "${arg}" Log ${arg} + +Embedded object "${obj}" + Log ${obj} + Should Be Equal ${obj.name} Robot + +Needs \escaping \${notvar} + No operation diff --git a/atest/testdata/standard_libraries/builtin/run_keywords_with_arguments.robot b/atest/testdata/standard_libraries/builtin/run_keywords_with_arguments.robot index 7ce19499989..a8d01276f97 100644 --- a/atest/testdata/standard_libraries/builtin/run_keywords_with_arguments.robot +++ b/atest/testdata/standard_libraries/builtin/run_keywords_with_arguments.robot @@ -1,54 +1,98 @@ -*** Variables *** -${NOOP} No Operation -@{MANY ARGUMENTS} hello 1 2 3 -@{ESCAPED} 1 \AND 2 Log Many x\${escaped} c:\\temp -@{LIST VARIABLE} Log Many this AND that -${AND VARIABLE} AND +*** Settings *** +Documentation Testing Run Keywords when used with AND. Tests without AND are in +... run_keywords.robot. +*** Variables *** +${NOOP} No Operation +@{MANY ARGUMENTS} hello 1 2 3 +@{ESCAPED} 1 \AND 2 Log Many x\${escaped} c:\\temp +@{LIST VARIABLE} Log Many this AND that +${AND VARIABLE} AND *** Test Cases *** -with arguments - Run Keywords Should Be Equal 2 2 AND No Operation AND Log Many hello 1 2 3 AND Should Be Equal 1 1 +With arguments + Run Keywords + ... Should Be Equal 2 2 AND + ... No Operation AND + ... Log Many hello 1 2 3 AND + ... Should Be Equal 1 1 Should fail with failing keyword - [Documentation] FAIL 1 != 2 - Run Keywords No Operation AND Should Be Equal 1 2 AND Not Executed + [Documentation] FAIL 1 != 2 + Run Keywords No Operation AND Should Be Equal 1 2 AND Not Executed Should support keywords and arguments from variables - Run Keywords Should Be Equal 2 2 AND ${NOOP} AND Log Many @{MANY ARGUMENTS} AND Should Be Equal As Integers ${1} 1 + Run Keywords + ... Should Be Equal 2 2 AND + ... ${NOOP} AND + ... Log Many @{MANY ARGUMENTS} AND + ... @{EMPTY} Should Be Equal As Integers ${1} @{EMPTY} 1 AND must be upper case - [Documentation] FAIL No keyword with name 'no kw' found. - Run Keywords Log Many this and that AND no kw + [Documentation] FAIL No keyword with name 'no kw' found. + Run Keywords Log Many this and that AND no kw AND must be whitespace sensitive - [Documentation] FAIL No keyword with name 'no kw' found. - Run Keywords Log Many this A ND that AND no kw + [Documentation] FAIL No keyword with name 'no kw' found. + Run Keywords Log Many this A ND that AND no kw Escaped AND - [Documentation] FAIL No keyword with name 'no kw' found. - Run Keywords Log Many this \AND that AND no kw + [Documentation] FAIL No keyword with name 'no kw' found. + Run Keywords Log Many this \AND that AND no kw AND from Variable - [Documentation] FAIL No keyword with name 'no kw' found. - Run Keywords Log Many this ${AND VARIABLE} that AND no kw + [Documentation] FAIL No keyword with name 'no kw' found. + Run Keywords Log Many this ${AND VARIABLE} that AND no kw AND in List Variable - [Documentation] FAIL No keyword with name 'no kw' found. - Run Keywords @{LIST VARIABLE} AND no kw + [Documentation] FAIL No keyword with name 'no kw' found. + Run Keywords @{LIST VARIABLE} AND no kw Escapes in List Variable should be handled correctly - [Documentation] FAIL No keyword with name 'no kw' found. - Run Keywords Log Many @{ESCAPED} AND no kw + [Documentation] FAIL No keyword with name 'no kw' found. + Run Keywords Log Many @{ESCAPED} AND no kw AND as last argument should raise an error - [Documentation] FAIL Incorrect use of AND - Run Keywords Log Many 1 2 AND No Operation AND + [Documentation] FAIL AND must have keyword before and after. + Run Keywords Log Many 1 2 AND No Operation AND Consecutive AND's - [Documentation] FAIL Incorrect use of AND - Run Keywords Log Many 1 2 AND AND No Operation + [Documentation] FAIL AND must have keyword before and after. + Run Keywords Log Many 1 2 AND AND No Operation AND as first argument should raise an error - [Documentation] FAIL Incorrect use of AND - Run Keywords AND Log Many 1 2 + [Documentation] FAIL AND must have keyword before and after. + Run Keywords AND Log Many 1 2 + +Keywords names needing escaping + Run keywords Needs \\escaping \\\${notvar} AND Needs \\escaping \\\${notvar} + +Keywords names needing escaping as variable + @{names} = Create List Needs \\escaping \\\${notvar} + Run keywords @{names} AND ${names}[0] + +In test teardown with non-existing variable in keyword name + [Documentation] + ... FAIL Teardown failed: + ... Several failures occurred: + ... + ... 1) No keyword with name '\${bad}' found. + ... + ... 2) Executed + ... + ... 3) Variable '\${bad}' not found. + ... + ... 4) Executed + No Operation + [Teardown] Run keywords + ... ${bad} AND + ... ${{'Fail'}} Executed AND + ... Embedded ${bad} AND + ... Fail Executed + +*** Keywords *** +Embedded ${arg} + Log ${arg} + +Needs \escaping \${notvar} + No operation diff --git a/atest/testdata/standard_libraries/builtin/variable.py b/atest/testdata/standard_libraries/builtin/variable.py index 001c602643a..fbd2a37e754 100644 --- a/atest/testdata/standard_libraries/builtin/variable.py +++ b/atest/testdata/standard_libraries/builtin/variable.py @@ -1,9 +1,10 @@ -class O: - def __init__(self, n): - self.n = n - def __str__(self): - return self.n +class Object: + def __init__(self, name): + self.name = name -object = O('Hello! ' * 100) + def __str__(self): + return self.name + +OBJECT = Object('Robot') diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index e1f0222cfb5..c4e393b0329 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -43,10 +43,11 @@ # FIXME: Clean-up registering run keyword variants in RF 5! # https://github.com/robotframework/robotframework/issues/2190 -def run_keyword_variant(resolve): +def run_keyword_variant(resolve, dry_run=False): def decorator(method): RUN_KW_REGISTER.register_run_keyword('BuiltIn', method.__name__, - resolve, deprecation_warning=False) + resolve, deprecation_warning=False, + dry_run=dry_run) return method return decorator @@ -1834,7 +1835,7 @@ class _RunKeyword(_BuiltInBase): # other run keyword variant keywords in BuiltIn which can also be seen # at the end of this file. - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword(self, name, *args): """Executes the given keyword with the given arguments. @@ -1842,12 +1843,31 @@ def run_keyword(self, name, *args): can be a variable and thus set dynamically, e.g. from a return value of another keyword or from the command line. """ + if (is_string(name) + and not self._context.dry_run + and not self._accepts_embedded_arguments(name)): + name, args = self._replace_variables_in_name([name] + list(args)) if not is_string(name): raise RuntimeError('Keyword name must be a string.') kw = Keyword(name, args=args) return kw.run(self._context) - @run_keyword_variant(resolve=0) + def _accepts_embedded_arguments(self, name): + if '{' in name: + runner = self._context.get_runner(name) + if hasattr(runner, 'embedded_args'): + return True + return False + + def _replace_variables_in_name(self, name_and_args): + resolved = self._variables.replace_list(name_and_args, replace_until=1, + ignore_errors=self._context.in_teardown) + if not resolved: + raise DataError(f'Keyword name missing: Given arguments {name_and_args} ' + f'resolved to an empty list.') + return resolved[0], resolved[1:] + + @run_keyword_variant(resolve=0, dry_run=True) def run_keywords(self, *keywords): """Executes all the given keywords in a sequence. @@ -1901,28 +1921,31 @@ def _split_run_keywords(self, keywords): for name in self._split_run_keywords_without_and(keywords): yield name, () else: - for name, args in self._split_run_keywords_with_and(keywords): - yield name, args + for kw_call in self._split_run_keywords_with_and(keywords): + if not kw_call: + raise DataError('AND must have keyword before and after.') + yield kw_call[0], kw_call[1:] def _split_run_keywords_without_and(self, keywords): + replace_list = self._variables.replace_list ignore_errors = self._context.in_teardown - return self._variables.replace_list(keywords, ignore_errors=ignore_errors) + # `run_keyword` resolves variables, but list variables must be expanded + # here to pass it each keyword name separately. + for name in keywords: + if is_list_variable(name): + for n in replace_list([name], ignore_errors=ignore_errors): + yield escape(n) + else: + yield name def _split_run_keywords_with_and(self, keywords): while 'AND' in keywords: index = keywords.index('AND') - yield self._resolve_run_keywords_name_and_args(keywords[:index]) + yield keywords[:index] keywords = keywords[index+1:] - yield self._resolve_run_keywords_name_and_args(keywords) - - def _resolve_run_keywords_name_and_args(self, kw_call): - kw_call = self._variables.replace_list(kw_call, replace_until=1, - ignore_errors=self._context.in_teardown) - if not kw_call: - raise DataError('Incorrect use of AND') - return kw_call[0], kw_call[1:] + yield keywords - @run_keyword_variant(resolve=2) + @run_keyword_variant(resolve=1, dry_run=True) def run_keyword_if(self, condition, name, *args): """Runs the given keyword with the given arguments, if ``condition`` is true. @@ -2004,7 +2027,7 @@ def _split_branch(self, args, control_word, required, required_error): raise DataError('%s requires %s.' % (control_word, required_error)) return args[:index], branch - @run_keyword_variant(resolve=2) + @run_keyword_variant(resolve=1, dry_run=True) def run_keyword_unless(self, condition, name, *args): """*DEPRECATED since RF 5.0. Use Native IF/ELSE or `Run Keyword If` instead.* @@ -2016,7 +2039,7 @@ def run_keyword_unless(self, condition, name, *args): if not self._is_true(condition): return self.run_keyword(name, *args) - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_and_ignore_error(self, name, *args): """Runs the given keyword with the given arguments and ignores possible error. @@ -2042,7 +2065,7 @@ def run_keyword_and_ignore_error(self, name, *args): raise return 'FAIL', str(err) - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_and_warn_on_failure(self, name, *args): """Runs the specified keyword logs a warning if the keyword fails. @@ -2061,7 +2084,7 @@ def run_keyword_and_warn_on_failure(self, name, *args): logger.warn("Executing keyword '%s' failed:\n%s" % (name, message)) return status, message - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_and_return_status(self, name, *args): """Runs the given keyword with given arguments and returns the status as a Boolean value. @@ -2082,7 +2105,7 @@ def run_keyword_and_return_status(self, name, *args): status, _ = self.run_keyword_and_ignore_error(name, *args) return status == 'PASS' - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_and_continue_on_failure(self, name, *args): """Runs the keyword and continues execution even if a failure occurs. @@ -2102,7 +2125,7 @@ def run_keyword_and_continue_on_failure(self, name, *args): err.continue_on_failure = True raise err - @run_keyword_variant(resolve=2) + @run_keyword_variant(resolve=1, dry_run=True) def run_keyword_and_expect_error(self, expected_error, name, *args): """Runs the keyword and checks that the expected error occurred. @@ -2178,7 +2201,7 @@ def _error_is_expected(self, error, expected_error): prefix, expected_error = expected_error.split(':', 1) return matchers[prefix](error, expected_error.lstrip()) - @run_keyword_variant(resolve=2) + @run_keyword_variant(resolve=1, dry_run=True) def repeat_keyword(self, repeat, name, *args): """Executes the specified keyword multiple times. @@ -2257,7 +2280,7 @@ def _keywords_repeated_by_timeout(self, timeout, name, args): secs_to_timestr(maxtime - time.time(), compact=True))) yield name, args - @run_keyword_variant(resolve=3) + @run_keyword_variant(resolve=2, dry_run=True) def wait_until_keyword_succeeds(self, retry, retry_interval, name, *args): """Runs the specified keyword and retries if it fails. @@ -2397,7 +2420,7 @@ def _verify_values_for_set_variable_if(self, values, default=False): return self._verify_values_for_set_variable_if(values) return values - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_if_test_failed(self, name, *args): """Runs the given keyword with the given arguments, if the test failed. @@ -2411,7 +2434,7 @@ def run_keyword_if_test_failed(self, name, *args): if test.failed: return self.run_keyword(name, *args) - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_if_test_passed(self, name, *args): """Runs the given keyword with the given arguments, if the test passed. @@ -2425,7 +2448,7 @@ def run_keyword_if_test_passed(self, name, *args): if test.passed: return self.run_keyword(name, *args) - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_if_timeout_occurred(self, name, *args): """Runs the given keyword if either a test or a keyword timeout has occurred. @@ -2445,7 +2468,7 @@ def _get_test_in_teardown(self, kwname): return ctx.test raise RuntimeError(f"Keyword '{kwname}' can only be used in test teardown.") - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_if_all_tests_passed(self, name, *args): """Runs the given keyword with the given arguments, if all tests passed. @@ -2459,7 +2482,7 @@ def run_keyword_if_all_tests_passed(self, name, *args): if suite.statistics.failed == 0: return self.run_keyword(name, *args) - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_if_any_tests_failed(self, name, *args): """Runs the given keyword with the given arguments, if one or more tests failed. @@ -2740,7 +2763,7 @@ def return_from_keyword_if(self, condition, *return_values): if self._is_true(condition): self._return_from_keyword(return_values) - @run_keyword_variant(resolve=1) + @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_and_return(self, name, *args): """Runs the specified keyword and returns from the enclosing user keyword. @@ -2766,7 +2789,7 @@ def run_keyword_and_return(self, name, *args): else: self._return_from_keyword(return_values=[escape(ret)]) - @run_keyword_variant(resolve=2) + @run_keyword_variant(resolve=1, dry_run=True) def run_keyword_and_return_if(self, condition, name, *args): """Runs the specified keyword and returns from the enclosing user keyword. @@ -3924,51 +3947,63 @@ class RobotNotRunningError(AttributeError): pass -def register_run_keyword(library, keyword, args_to_process=None, - deprecation_warning=True): +def register_run_keyword(library, keyword, args_to_process=0, deprecation_warning=True): """Tell Robot Framework that this keyword runs other keywords internally. *NOTE:* This API will change in the future. For more information see - https://github.com/robotframework/robotframework/issues/2190. Use with - `deprecation_warning=False` to avoid related deprecation warnings. + https://github.com/robotframework/robotframework/issues/2190. - 1) Why is this method needed + :param library: Name of the library the keyword belongs to. + :param keyword: Name of the keyword itself. + :param args_to_process: How many arguments to process normally before + passing them to the keyword. Other arguments are not touched at all. + :param deprecation_warning: Set to ``False```to avoid the warning. - Keywords running other keywords internally using `Run Keyword` or its variants - like `Run Keyword If` need some special handling by the framework. This includes - not processing arguments (e.g. variables in them) twice, special handling of - timeouts, and so on. + Registered keywords are handled specially by Robot so that: - 2) How to use this method + - Their arguments are not resolved normally (use ``args_to_process`` + to control that). This basically means not replacing variables or + handling escapes. + - They are not stopped by timeouts. + - If there are conflicts with keyword names, these keywords have + *lower* precedence than other keywords. - `library` is the name of the library where the registered keyword is implemented. + Main use cases are: - `keyword` is the name of the keyword. With Python 2 it is possible to pass also - the function or method implementing the keyword. + - Library keyword is using `BuiltIn.run_keyword` internally to execute other + keywords. Registering the caller as a "run keyword variant" avoids variables + and escapes in arguments being resolved multiple times. All arguments passed + to `run_keyword` can and should be left unresolved. + - Keyword has some need to not resolve variables in arguments. This way + variable values are not logged anywhere by Robot automatically. - `args_to_process`` defines how many of the arguments to the registered keyword must - be processed normally. + As mentioned above, this API will likely be reimplemented in the future + or there could be new API for library keywords to execute other keywords. + External libraries can nevertheless use this API if they really need it and + are aware of the possible breaking changes in the future. - 3) Examples + Examples:: - from robot.libraries.BuiltIn import BuiltIn, register_run_keyword + from robot.libraries.BuiltIn import BuiltIn, register_run_keyword - def my_run_keyword(name, *args): - # do something - return BuiltIn().run_keyword(name, *args) + def my_run_keyword(name, *args): + # do something + return BuiltIn().run_keyword(name, *args) - register_run_keyword(__name__, 'My Run Keyword', 1) + register_run_keyword(__name__, 'My Run Keyword') - ------------- + ------------- - from robot.libraries.BuiltIn import BuiltIn, register_run_keyword + from robot.libraries.BuiltIn import BuiltIn, register_run_keyword - class MyLibrary: - def my_run_keyword_if(self, expression, name, *args): - # do something - return BuiltIn().run_keyword_if(expression, name, *args) + class MyLibrary: + def my_run_keyword_if(self, expression, name, *args): + # Do something + if self._is_true(expression): + return BuiltIn().run_keyword(name, *args) - register_run_keyword('MyLibrary', 'my_run_keyword_if', 2) + # Process one argument normally to get `expression` resolved. + register_run_keyword('MyLibrary', 'my_run_keyword_if', args_to_process=1) """ RUN_KW_REGISTER.register_run_keyword(library, keyword, args_to_process, deprecation_warning) diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py index 0dce2ba9050..8c9ea577b49 100644 --- a/src/robot/running/handlers.py +++ b/src/robot/running/handlers.py @@ -242,14 +242,12 @@ def handler(*positional, **kwargs): class _RunKeywordHandler(_PythonHandler): def create_runner(self, name, languages=None): - default_dry_run_keywords = ('name' in self.arguments.positional and - self._args_to_process) - return RunKeywordRunner(self, default_dry_run_keywords) + dry_run = RUN_KW_REGISTER.get_dry_run(self.library.orig_name, self.name) + return RunKeywordRunner(self, execute_in_dry_run=dry_run) @property def _args_to_process(self): - return RUN_KW_REGISTER.get_args_to_process(self.library.orig_name, - self.name) + return RUN_KW_REGISTER.get_args_to_process(self.library.orig_name, self.name) def resolve_arguments(self, args, variables=None, languages=None): return self.arguments.resolve(args, variables, self.library.converters, diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index a271d906cd7..2ef881b89ad 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -133,16 +133,16 @@ class EmbeddedArgumentsRunner(LibraryKeywordRunner): def __init__(self, handler, name): super().__init__(handler, name) - self._embedded_args = handler.embedded.match(name).groups() + self.embedded_args = handler.embedded.match(name).groups() def _run(self, context, args): if args: raise DataError("Positional arguments are not allowed when using " "embedded arguments.") - return super()._run(context, self._embedded_args) + return super()._run(context, self.embedded_args) def _dry_run(self, context, args): - return super()._dry_run(context, self._embedded_args) + return super()._dry_run(context, self.embedded_args) def _get_result(self, kw, assignment): result = super()._get_result(kw, assignment) @@ -152,11 +152,12 @@ def _get_result(self, kw, assignment): class RunKeywordRunner(LibraryKeywordRunner): - def __init__(self, handler, default_dry_run_keywords=False): + def __init__(self, handler, execute_in_dry_run=False): super().__init__(handler) - self._default_dry_run_keywords = default_dry_run_keywords + self.execute_in_dry_run = execute_in_dry_run def _get_timeout(self, context): + # These keywords are not affected by timeouts. Keywords they execute are. return None def _run_with_output_captured_and_signal_monitor(self, runner, context): @@ -169,16 +170,16 @@ def _dry_run(self, context, args): BodyRunner(context).run(keywords) def _get_dry_run_keywords(self, args): + if not self.execute_in_dry_run: + return [] name = self._handler.name if name == 'Run Keyword If': - return self._get_run_kw_if_keywords(args) + return self._get_dry_run_keywords_for_run_keyword_if(args) if name == 'Run Keywords': - return self._get_run_kws_keywords(args) - if self._default_dry_run_keywords: - return self._get_default_run_kw_keywords(args) - return [] + return self._get_dry_run_keywords_for_run_keyword(args) + return self._get_dry_run_keywords_based_on_name_argument(args) - def _get_run_kw_if_keywords(self, given_args): + def _get_dry_run_keywords_for_run_keyword_if(self, given_args): for kw_call in self._get_run_kw_if_calls(given_args): if kw_call: yield Keyword(name=kw_call[0], args=kw_call[1:]) @@ -212,7 +213,7 @@ def _validate_kw_call(self, kw_call, min_length=2): return True return any(is_list_variable(item) for item in kw_call) - def _get_run_kws_keywords(self, given_args): + def _get_dry_run_keywords_for_run_keyword(self, given_args): for kw_call in self._get_run_kws_calls(given_args): yield Keyword(name=kw_call[0], args=kw_call[1:]) @@ -228,6 +229,6 @@ def _get_run_kws_calls(self, given_args): if given_args: yield given_args - def _get_default_run_kw_keywords(self, given_args): + def _get_dry_run_keywords_based_on_name_argument(self, given_args): index = list(self._handler.arguments.positional).index('name') return [Keyword(name=given_args[index], args=given_args[index+1:])] diff --git a/src/robot/running/runkwregister.py b/src/robot/running/runkwregister.py index 2fcad6f158a..1d699f03b6a 100644 --- a/src/robot/running/runkwregister.py +++ b/src/robot/running/runkwregister.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import inspect import warnings from robot.utils import NormalizedDict @@ -24,8 +23,39 @@ class _RunKeywordRegister: def __init__(self): self._libs = {} - def register_run_keyword(self, libname, keyword, args_to_process=None, - deprecation_warning=True): + def register_run_keyword(self, libname, keyword, args_to_process, + deprecation_warning=True, dry_run=False): + """Deprecated API for registering "run keyword variants". + + Registered keywords are handled specially by Robot so that: + + - Their arguments are not resolved normally (use ``args_to_process`` + to control that). This mainly means replacing variables and handling + escapes. + - They are not stopped by timeouts. + - If there are conflicts with keyword names, these keywords have + *lower* precedence than other keywords. + + This API is pretty bad and will be reimplemented in the future. + It is thus not considered stable, but external libraries can use it + if they really need it and are aware of forthcoming breaking changes. + + Something like this is needed at least internally also in the future. + For external libraries we hopefully could provide a better API for + running keywords so that they would not need this in the first place. + + For more details see the following issues and issues linked from it: + https://github.com/robotframework/robotframework/issues/2190 + + :param libname: Name of the library the keyword belongs to. + :param keyword: Name of the keyword itself. + :param args_to_process: How many arguments to process normally before + passing them to the keyword. Other arguments are not touched at all. + :param dry_run: When true, this keyword is executed in dry run. Keywords + to actually run are got based on the ``name`` argument these + keywords must have. + :param deprecation_warning: Set to ``False```to avoid the warning. + """ if deprecation_warning: warnings.warn( "The API to register run keyword variants and to disable variable " @@ -35,24 +65,22 @@ def register_run_keyword(self, libname, keyword, args_to_process=None, "Use with `deprecation_warning=False` to avoid this warning.", UserWarning ) - if args_to_process is None: - args_to_process = self._get_args_from_method(keyword) - keyword = keyword.__name__ if libname not in self._libs: self._libs[libname] = NormalizedDict(ignore=['_']) - self._libs[libname][keyword] = int(args_to_process) + self._libs[libname][keyword] = (int(args_to_process), dry_run) def get_args_to_process(self, libname, kwname): if libname in self._libs and kwname in self._libs[libname]: - return self._libs[libname][kwname] + return self._libs[libname][kwname][0] return -1 + def get_dry_run(self, libname, kwname): + if libname in self._libs and kwname in self._libs[libname]: + return self._libs[libname][kwname][1] + return False + def is_run_keyword(self, libname, kwname): return self.get_args_to_process(libname, kwname) >= 0 - def _get_args_from_method(self, method): - raise RuntimeError('Cannot determine arguments to process ' - 'automatically in Python 3.') - RUN_KW_REGISTER = _RunKeywordRegister() From e6131773fdfe5538cf7409009c2b92390611aee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 14 Sep 2022 01:11:53 +0300 Subject: [PATCH 0186/1592] Fix example --- doc/userguide/src/CreatingTestData/ControlStructures.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index 1ea95950906..b78cc6a1738 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -1031,8 +1031,8 @@ can be defined with a variable as well. .. sourcecode:: robotframework - *** Settings *** - ${regexp} regexp + *** Variables *** + ${MATCH TYPE} regexp *** Test Cases *** Glob pattern @@ -1047,7 +1047,7 @@ can be defined with a variable as well. Regular expression TRY Some Keyword - EXCEPT ValueError: .* type=${regexp} + EXCEPT ValueError: .* type=${MATCH TYPE} Error Handler 1 EXCEPT [Ee]rror \\d+ occurred type=Regexp # Backslash needs to be escaped. Error Handler 2 From 28a50a13ff8693112937af3e0bf4c19a9a352348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 14 Sep 2022 01:20:20 +0300 Subject: [PATCH 0187/1592] Disable validating embedded arg given as variable against custom regexp. This was originally implemented to fix #4069, but needed to be reverted due to backwards incompatibility reasons. We hopefully can add strict validation back later, and thus the functionatliy was only disabled, not fully reverted. --- atest/robot/keywords/embedded_arguments.robot | 4 ++-- .../embedded_arguments_library_keywords.robot | 4 ++-- .../keywords/embedded_arguments.robot | 8 +++---- .../embedded_arguments_library_keywords.robot | 8 +++---- .../CreatingTestData/CreatingUserKeywords.rst | 10 ++++----- src/robot/running/arguments/embedded.py | 21 +++++++++++++++++++ 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/atest/robot/keywords/embedded_arguments.robot b/atest/robot/keywords/embedded_arguments.robot index 0d46577b525..801e5abebf9 100644 --- a/atest/robot/keywords/embedded_arguments.robot +++ b/atest/robot/keywords/embedded_arguments.robot @@ -89,10 +89,10 @@ Grouping Custom Regexp Custom Regexp Matching Variables Check Test Case ${TEST NAME} -Non Matching Variable Is Not Accepted With Custom Regexp +Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) Check Test Case ${TEST NAME} -Partially Matching Variable Is Not Accepted With Custom Regexp +Partially Matching Variable Is Accepted With Custom Regexp (But Not For Long) Check Test Case ${TEST NAME} Non String Variable Is Accepted With Custom Regexp diff --git a/atest/robot/keywords/embedded_arguments_library_keywords.robot b/atest/robot/keywords/embedded_arguments_library_keywords.robot index 8eedaa43d81..c705193c792 100755 --- a/atest/robot/keywords/embedded_arguments_library_keywords.robot +++ b/atest/robot/keywords/embedded_arguments_library_keywords.robot @@ -77,10 +77,10 @@ Grouping Custom Regexp Custom Regexp Matching Variables Check Test Case ${TEST NAME} -Non Matching Variable Is Not Accepted With Custom Regexp +Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) Check Test Case ${TEST NAME} -Partially Matching Variable Is Not Accepted With Custom Regexp +Partially Matching Variable Is Accepted With Custom Regexp (But Not For Long) Check Test Case ${TEST NAME} Non String Variable Is Accepted With Custom Regexp diff --git a/atest/testdata/keywords/embedded_arguments.robot b/atest/testdata/keywords/embedded_arguments.robot index 03f0d5ef375..4bbd30758ea 100644 --- a/atest/testdata/keywords/embedded_arguments.robot +++ b/atest/testdata/keywords/embedded_arguments.robot @@ -100,12 +100,12 @@ Custom Regexp Matching Variables I execute "${bar}" with "${zap}" I execute "${bar}" -Non Matching Variable Is Not Accepted With Custom Regexp - [Documentation] FAIL ValueError: Embedded argument 'x' got value 'foo' that does not match custom pattern 'bar'. +Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) + [Documentation] FAIL foo != bar # ValueError: Embedded argument 'x' got value 'foo' that does not match custom pattern 'bar'. I execute "${foo}" with "${bar}" -Partially Matching Variable Is Not Accepted With Custom Regexp - [Documentation] FAIL ValueError: Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. +Partially Matching Variable Is Accepted With Custom Regexp (But Not For Long) + [Documentation] FAIL ba != bar # ValueError: Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. I execute "${bar[:2]}" with "${zap}" Non String Variable Is Accepted With Custom Regexp diff --git a/atest/testdata/keywords/embedded_arguments_library_keywords.robot b/atest/testdata/keywords/embedded_arguments_library_keywords.robot index 4b62c1297e4..9d1446d6508 100755 --- a/atest/testdata/keywords/embedded_arguments_library_keywords.robot +++ b/atest/testdata/keywords/embedded_arguments_library_keywords.robot @@ -85,12 +85,12 @@ Custom Regexp Matching Variables I execute "${bar}" with "${zap}" I execute "${bar}" -Non Matching Variable Is Not Accepted With Custom Regexp - [Documentation] FAIL ValueError: Embedded argument 'x' got value 'foo' that does not match custom pattern 'bar'. +Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) + [Documentation] FAIL foo != bar # ValueError: Embedded argument 'x' got value 'foo' that does not match custom pattern 'bar'. I execute "${foo}" with "${bar}" -Partially Matching Variable Is Not Accepted With Custom Regexp - [Documentation] FAIL ValueError: Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. +Partially Matching Variable Is Accepted With Custom Regexp (But Not For Long) + [Documentation] FAIL ba != bar # ValueError: Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. I execute "${bar[:2]}" with "${zap}" Non String Variable Is Accepted With Custom Regexp diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 465cc96f0d6..f377686adb7 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -689,18 +689,16 @@ using the keywords from the earlier example. Today is ${DATE} I type ${1} + ${2} -If the value of the variable is a string, it must match the custom regular -expression. Non-string values are accepted without validation. - -.. note:: Validating string values against custom regular expressions is new - in Robot Framework 5.1. Earlier all variable values were accepted - without validation. +Notice that the actual value of the variable does not need to match the custom +regular expression. This is likely to change in the future, though, +as discussed in `issue #4069`__. __ http://en.wikipedia.org/wiki/Regular_expression __ `Embedded arguments matching too much`_ __ http://docs.python.org/library/re.html __ `Errors and warnings during execution`_ __ Escaping_ +__ https://github.com/robotframework/robotframework/issues/4069 Behavior-driven development example ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/running/arguments/embedded.py b/src/robot/running/arguments/embedded.py index 9c7b2f6cf78..d873dc63763 100644 --- a/src/robot/running/arguments/embedded.py +++ b/src/robot/running/arguments/embedded.py @@ -20,6 +20,9 @@ from robot.variables import VariableIterator +ENABLE_STRICT_ARGUMENT_VALIDATION = False + + class EmbeddedArguments: def __init__(self, name=None, args=(), custom_patterns=None): @@ -39,6 +42,24 @@ def map(self, values): return list(zip(self.args, values)) def validate(self, values): + # Validating that embedded args match custom regexps also if args are + # given as variables was initially implemented in RF 5.1. It needed + # to be reverted due to backwards incompatibility reasons: + # https://github.com/robotframework/robotframework/issues/4069 + # + # We hopefully can add validation back in RF 5.2 or 6.0. A precondition + # is implementing better approach to handle conflicts with keywords + # using embedded arguments: + # https://github.com/robotframework/robotframework/issues/4454 + # + # Because the plan is to add validation back, the code was not removed + # but the `ENABLE_STRICT_ARGUMENT_VALIDATION` guard was added instead. + # Enabling validation requires only removing the following two lines + # (along with this comment). If someone wants to enable strict validation + # already now, they set `ENABLE_STRICT_ARGUMENT_VALIDATION` to True + # before running tests. + if not ENABLE_STRICT_ARGUMENT_VALIDATION: + return if not self.custom_patterns: return for arg, value in zip(self.args, values): From 9419f614dfcbfa37ae4397816d01ff8652e1488a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 15 Sep 2022 03:00:46 +0300 Subject: [PATCH 0188/1592] Enhance reporting errors if multiple keywords match. Earlier conflicts with keywords having embedded args were handled when getting a handler from a particular file. Now all matching handlers are returned and all conflicts are handled in the same place. The main motivation for this is making it possible to select the best match if there are conflicts with embedded args as proposed in #4454. Earlier implementation allowed handling conflicts only within a single file, but now we can handle them across all files. --- atest/robot/keywords/embedded_arguments.robot | 6 +- .../keywords/duplicate_dynamic_keywords.robot | 6 +- .../keywords/duplicate_hybrid_keywords.robot | 6 +- .../keywords/duplicate_static_keywords.robot | 12 +- .../keywords/duplicate_user_keywords.robot | 10 +- .../keywords/embedded_arguments.robot | 30 ++-- .../embedded_arguments_library_keywords.robot | 27 +-- src/robot/running/handlers.py | 2 + src/robot/running/handlerstore.py | 52 ++---- src/robot/running/importer.py | 2 +- src/robot/running/namespace.py | 163 ++++++++++-------- src/robot/running/testlibraries.py | 7 +- src/robot/running/usererrorhandler.py | 1 + src/robot/running/userkeyword.py | 16 +- src/robot/running/userkeywordrunner.py | 8 +- utest/running/test_testlibrary.py | 2 +- utest/running/test_userlibrary.py | 28 +-- 17 files changed, 192 insertions(+), 186 deletions(-) diff --git a/atest/robot/keywords/embedded_arguments.robot b/atest/robot/keywords/embedded_arguments.robot index 801e5abebf9..cefbaf65628 100644 --- a/atest/robot/keywords/embedded_arguments.robot +++ b/atest/robot/keywords/embedded_arguments.robot @@ -100,13 +100,13 @@ Non String Variable Is Accepted With Custom Regexp Regexp Extensions Are Not Supported Check Test Case ${TEST NAME} - Creating Keyword Failed 1 287 + Creating Keyword Failed 1 291 ... Regexp extensions like \${x:(?x)re} are not supported ... Regexp extensions are not allowed in embedded arguments. Invalid Custom Regexp Check Test Case ${TEST NAME} - Creating Keyword Failed 2 290 + Creating Keyword Failed 2 294 ... Invalid \${x:(} Regexp ... Compiling embedded arguments regexp failed: * @@ -143,7 +143,7 @@ Keyword with embedded args cannot be used as "normal" keyword Check Test Case ${TEST NAME} Creating keyword with both normal and embedded arguments fails - Creating Keyword Failed 0 234 + Creating Keyword Failed 0 238 ... Keyword with \${embedded} and normal args is invalid ... Keyword cannot have both normal and embedded arguments. Check Test Case ${TEST NAME} diff --git a/atest/testdata/keywords/duplicate_dynamic_keywords.robot b/atest/testdata/keywords/duplicate_dynamic_keywords.robot index 51ca670dcaa..a795429b2cc 100644 --- a/atest/testdata/keywords/duplicate_dynamic_keywords.robot +++ b/atest/testdata/keywords/duplicate_dynamic_keywords.robot @@ -11,9 +11,9 @@ Using keyword defined multiple times fails Keyword with embedded arguments defined multiple times fails at run-time [Documentation] FAIL - ... Library 'DupeDynamicKeywords' contains multiple keywords matching name 'Embedded twice': - ... ${INDENT}EMBEDDED \${ARG} - ... ${INDENT}Embedded \${twice} + ... Multiple keywords matching name 'Embedded twice' found: + ... ${INDENT}DupeDynamicKeywords.EMBEDDED \${ARG} + ... ${INDENT}DupeDynamicKeywords.Embedded \${twice} Embedded twice Exact duplicate is accepted diff --git a/atest/testdata/keywords/duplicate_hybrid_keywords.robot b/atest/testdata/keywords/duplicate_hybrid_keywords.robot index 9977344b86f..8e3db8fa6fd 100644 --- a/atest/testdata/keywords/duplicate_hybrid_keywords.robot +++ b/atest/testdata/keywords/duplicate_hybrid_keywords.robot @@ -11,9 +11,9 @@ Using keyword defined multiple times fails Keyword with embedded arguments defined multiple times fails at run-time [Documentation] FAIL - ... Library 'DupeHybridKeywords' contains multiple keywords matching name 'Embedded twice': - ... ${INDENT}EMBEDDED \${ARG} - ... ${INDENT}Embedded \${twice} + ... Multiple keywords matching name 'Embedded twice' found: + ... ${INDENT}DupeHybridKeywords.EMBEDDED \${ARG} + ... ${INDENT}DupeHybridKeywords.Embedded \${twice} Embedded twice Exact duplicate is accepted diff --git a/atest/testdata/keywords/duplicate_static_keywords.robot b/atest/testdata/keywords/duplicate_static_keywords.robot index 7680a67e4b1..5b090b699ea 100644 --- a/atest/testdata/keywords/duplicate_static_keywords.robot +++ b/atest/testdata/keywords/duplicate_static_keywords.robot @@ -15,14 +15,14 @@ Using keyword defined thrice fails as well Keyword with embedded arguments defined twice fails at run-time: Called with embedded args [Documentation] FAIL - ... Library 'DupeKeywords' contains multiple keywords matching name 'Embedded arguments twice': - ... ${INDENT}Embedded \${arguments match} TWICE - ... ${INDENT}Embedded \${arguments} twice + ... Multiple keywords matching name 'Embedded arguments twice' found: + ... ${INDENT}DupeKeywords.Embedded \${arguments match} TWICE + ... ${INDENT}DupeKeywords.Embedded \${arguments} twice Embedded arguments twice Keyword with embedded arguments defined twice fails at run-time: Called with exact name [Documentation] FAIL - ... Library 'DupeKeywords' contains multiple keywords matching name 'Embedded ${arguments match} twice': - ... ${INDENT}Embedded \${arguments match} TWICE - ... ${INDENT}Embedded \${arguments} twice + ... Multiple keywords matching name 'Embedded \${arguments match} twice' found: + ... ${INDENT}DupeKeywords.Embedded \${arguments match} TWICE + ... ${INDENT}DupeKeywords.Embedded \${arguments} twice Embedded ${arguments match} twice diff --git a/atest/testdata/keywords/duplicate_user_keywords.robot b/atest/testdata/keywords/duplicate_user_keywords.robot index 2ac19800c98..6a424b4a732 100644 --- a/atest/testdata/keywords/duplicate_user_keywords.robot +++ b/atest/testdata/keywords/duplicate_user_keywords.robot @@ -15,14 +15,14 @@ Using keyword defined thrice fails as well Keyword with embedded arguments defined twice fails at run-time: Called with embedded args [Documentation] FAIL - ... Test case file contains multiple keywords matching name 'Embedded arguments twice': + ... Multiple keywords matching name 'Embedded arguments twice' found: ... ${INDENT}Embedded \${arguments match} TWICE ... ${INDENT}Embedded \${arguments} twice Embedded arguments twice Keyword with embedded arguments defined twice fails at run-time: Called with exact name [Documentation] FAIL - ... Test case file contains multiple keywords matching name 'Embedded ${arguments match} twice': + ... Multiple keywords matching name 'Embedded \${arguments match} twice' found: ... ${INDENT}Embedded \${arguments match} TWICE ... ${INDENT}Embedded \${arguments} twice Embedded ${arguments match} twice @@ -33,9 +33,9 @@ Using keyword defined multiple times in resource fails Keyword with embedded arguments defined multiple times in resource fails at run-time [Documentation] FAIL - ... Resource file 'dupe_keywords.resource' contains multiple keywords matching name 'Embedded arguments twice in resource': - ... ${INDENT}Embedded \${arguments match} TWICE IN RESOURCE - ... ${INDENT}Embedded \${arguments} twice in resource + ... Multiple keywords matching name 'Embedded arguments twice in resource' found: + ... ${INDENT}dupe_keywords.Embedded \${arguments match} TWICE IN RESOURCE + ... ${INDENT}dupe_keywords.Embedded \${arguments} twice in resource Embedded arguments twice in resource *** Keywords *** diff --git a/atest/testdata/keywords/embedded_arguments.robot b/atest/testdata/keywords/embedded_arguments.robot index 4bbd30758ea..524274cc4bc 100644 --- a/atest/testdata/keywords/embedded_arguments.robot +++ b/atest/testdata/keywords/embedded_arguments.robot @@ -167,7 +167,8 @@ Creating keyword with both normal and embedded arguments fails Keyword with ${embedded} and normal args is invalid arg1 arg2 Keyword Matching Multiple Keywords In Test Case File - [Documentation] FAIL Test case file contains multiple keywords matching name 'foo+tc+bar-tc-zap': + [Documentation] FAIL + ... Multiple keywords matching name 'foo+tc+bar-tc-zap' found: ... ${INDENT}\${a}+tc+\${b} ... ${INDENT}\${a}-tc-\${b} foo+tc+bar @@ -176,26 +177,29 @@ Keyword Matching Multiple Keywords In Test Case File foo+tc+bar-tc-zap Keyword Matching Multiple Keywords In One Resource File - [Documentation] FAIL Resource file 'embedded_args_in_uk_1.robot' contains multiple keywords matching name 'foo+r1+bar-r1-zap': - ... ${INDENT}\${a}+r1+\${b} - ... ${INDENT}\${a}-r1-\${b} + [Documentation] FAIL + ... Multiple keywords matching name 'foo+r1+bar-r1-zap' found: + ... ${INDENT}embedded_args_in_uk_1.\${a}+r1+\${b} + ... ${INDENT}embedded_args_in_uk_1.\${a}-r1-\${b} foo+r1+bar foo-r1-bar foo+r1+bar-r1-zap Keyword Matching Multiple Keywords In Different Resource Files - [Documentation] FAIL Multiple keywords with name 'foo-r1-bar-r2-zap' found. \ - ... Give the full name of the keyword you want to use: - ... ${INDENT}embedded_args_in_uk_1.foo-r1-bar-r2-zap - ... ${INDENT}embedded_args_in_uk_2.foo-r1-bar-r2-zap + [Documentation] FAIL + ... Multiple keywords matching name 'foo-r1-bar-r2-zap' found: + ... ${INDENT}embedded_args_in_uk_1.\${a}-r1-\${b} + ... ${INDENT}embedded_args_in_uk_2.\${arg1}-r2-\${arg2} foo-r1-bar foo-r2-bar foo-r1-bar-r2-zap Keyword Matching Multiple Keywords In One And Different Resource Files - [Documentation] FAIL Resource file 'embedded_args_in_uk_1.robot' contains multiple keywords matching name '-r1-r2-+r1+': - ... ${INDENT}\${a}+r1+\${b} - ... ${INDENT}\${a}-r1-\${b} + [Documentation] FAIL + ... Multiple keywords matching name '-r1-r2-+r1+' found: + ... ${INDENT}embedded_args_in_uk_1.\${a}+r1+\${b} + ... ${INDENT}embedded_args_in_uk_1.\${a}-r1-\${b} + ... ${INDENT}embedded_args_in_uk_2.\${arg1}-r2-\${arg2} -r1-r2-+r1+ Same name with different regexp works @@ -205,14 +209,14 @@ Same name with different regexp works Same name with different regexp matching multiple fails [Documentation] FAIL - ... Test case file contains multiple keywords matching name 'It is a cat': + ... Multiple keywords matching name 'It is a cat' found: ... ${INDENT}It is \${animal:a (cat|cow)} ... ${INDENT}It is \${animal:a (dog|cat)} It is a cat Same name with same regexp fails [Documentation] FAIL - ... Test case file contains multiple keywords matching name 'It is totally same': + ... Multiple keywords matching name 'It is totally same' found: ... ${INDENT}It is totally \${same} ... ${INDENT}It is totally \${same} It is totally same diff --git a/atest/testdata/keywords/embedded_arguments_library_keywords.robot b/atest/testdata/keywords/embedded_arguments_library_keywords.robot index 9d1446d6508..af6531064cf 100755 --- a/atest/testdata/keywords/embedded_arguments_library_keywords.robot +++ b/atest/testdata/keywords/embedded_arguments_library_keywords.robot @@ -116,19 +116,20 @@ Embedded Arguments Syntax is Underscore Sensitive User Janne Selects x from_webshop Keyword Matching Multiple Keywords In Library File - [Documentation] FAIL Library 'embedded_args_in_lk_1' contains multiple keywords matching name 'foo+lib+bar-lib-zap': - ... ${INDENT}\${a}+lib+\${b} - ... ${INDENT}\${a}-lib-\${b} + [Documentation] FAIL + ... Multiple keywords matching name 'foo+lib+bar-lib-zap' found: + ... ${INDENT}embedded_args_in_lk_1.\${a}+lib+\${b} + ... ${INDENT}embedded_args_in_lk_1.\${a}-lib-\${b} foo+lib+bar foo-lib-bar foo+lib+bar+lib+zap foo+lib+bar-lib-zap Keyword Matching Multiple Keywords In Different Library Files - [Documentation] FAIL Multiple keywords with name 'foo*lib*bar' found. \ - ... Give the full name of the keyword you want to use: - ... ${INDENT}embedded_args_in_lk_1.foo*lib*bar - ... ${INDENT}embedded_args_in_lk_2.foo*lib*bar + [Documentation] FAIL + ... Multiple keywords matching name 'foo*lib*bar' found: + ... ${INDENT}embedded_args_in_lk_1.\${a}*lib*\${b} + ... ${INDENT}embedded_args_in_lk_2.\${a}*lib*\${b} foo*lib*bar Embedded And Positional Arguments Do Not Work Together @@ -166,14 +167,14 @@ Same name with different regexp works Same name with different regexp matching multiple fails [Documentation] FAIL - ... Library 'embedded_args_in_lk_1' contains multiple keywords matching name 'It is a cat': - ... ${INDENT}It is ${animal:a (cat|cow)} - ... ${INDENT}It is ${animal:a (dog|cat)} + ... Multiple keywords matching name 'It is a cat' found: + ... ${INDENT}embedded_args_in_lk_1.It is \${animal:a (cat|cow)} + ... ${INDENT}embedded_args_in_lk_1.It is \${animal:a (dog|cat)} It is a cat Same name with same regexp fails [Documentation] FAIL - ... Library 'embedded_args_in_lk_1' contains multiple keywords matching name 'It is totally same': - ... ${INDENT}It is totally ${same} - ... ${INDENT}It is totally ${same} + ... Multiple keywords matching name 'It is totally same' found: + ... ${INDENT}embedded_args_in_lk_1.It is totally ${same} + ... ${INDENT}embedded_args_in_lk_1.It is totally ${same} It is totally same diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py index 8c9ea577b49..a62a01d36ee 100644 --- a/src/robot/running/handlers.py +++ b/src/robot/running/handlers.py @@ -45,6 +45,7 @@ def InitHandler(library, method=None, docgetter=None): class _RunnableHandler: + supports_embedded_args = False def __init__(self, library, handler_name, handler_method, doc='', tags=None): self.library = library @@ -282,6 +283,7 @@ def _parse_arguments(self, init_method): class EmbeddedArgumentsHandler: + supports_embedded_args = True def __init__(self, embedded, orig_handler): self.arguments = ArgumentSpec() # Show empty argument spec for Libdoc diff --git a/src/robot/running/handlerstore.py b/src/robot/running/handlerstore.py index 8a4e7e35bcf..11f65e5dd55 100644 --- a/src/robot/running/handlerstore.py +++ b/src/robot/running/handlerstore.py @@ -13,22 +13,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -from operator import attrgetter +from itertools import chain -from robot.errors import DataError, KeywordError -from robot.utils import NormalizedDict +from robot.errors import DataError +from robot.utils import NormalizedDict, seq2str from .usererrorhandler import UserErrorHandler class HandlerStore: - LIBRARY_TYPE = 'Library' - TEST_CASE_FILE_TYPE = 'Test case file' - RESOURCE_FILE_TYPE = 'Resource file' - def __init__(self, source, source_type): - self.source = source - self.source_type = source_type + def __init__(self): self._normal = NormalizedDict(ignore='_') self._embedded = [] @@ -44,8 +39,7 @@ def add(self, handler, embedded=False): raise error def __iter__(self): - handlers = list(self._normal.values()) + self._embedded - return iter(sorted(handlers, key=attrgetter('name'))) + return chain(self._normal.values(), self._embedded) def __len__(self): return len(self._normal) + len(self._embedded) @@ -55,30 +49,16 @@ def __contains__(self, name): return True return any(template.matches(name) for template in self._embedded) - def create_runner(self, name, languages=None): - return self[name].create_runner(name, languages) - def __getitem__(self, name): - try: - return self._normal[name] - except KeyError: - return self._find_embedded(name) - - def _find_embedded(self, name): - embedded = [template for template in self._embedded if template.matches(name)] - if len(embedded) == 1: - return embedded[0] - self._raise_no_single_match(name, embedded) + handlers = self.get_handlers(name) + if len(handlers) == 1: + return handlers[0] + if not handlers: + raise ValueError(f"No handler with name '{name}' found.") + names = seq2str([handler.name for handler in handlers]) + raise ValueError(f"Multiple handlers matching name '{name}' found: {names}") - def _raise_no_single_match(self, name, found): - if self.source_type == self.TEST_CASE_FILE_TYPE: - source = self.source_type - else: - source = "%s '%s'" % (self.source_type, self.source) - if not found: - raise KeywordError("%s contains no keywords matching name '%s'." - % (source, name)) - error = ["%s contains multiple keywords matching name '%s':" - % (source, name)] - names = sorted(handler.name for handler in found) - raise KeywordError('\n '.join(error + names)) + def get_handlers(self, name): + if name in self._normal: + return [self._normal[name]] + return [template for template in self._embedded if template.matches(name)] diff --git a/src/robot/running/importer.py b/src/robot/running/importer.py index dc614dc4e72..261df740f4a 100644 --- a/src/robot/running/importer.py +++ b/src/robot/running/importer.py @@ -98,7 +98,7 @@ def _copy_library(self, orig, name): lib.name = name lib.scope = type(lib.scope)(lib) lib.reset_instance() - lib.handlers = HandlerStore(orig.handlers.source, orig.handlers.source_type) + lib.handlers = HandlerStore() for handler in orig.handlers._normal.values(): handler = copy.copy(handler) handler.library = lib diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 96f19b0bbd4..b2e8421c1b8 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -225,7 +225,7 @@ def get_runner(self, name): class KeywordStore: def __init__(self, resource, languages): - self.user_keywords = UserLibrary(resource, UserLibrary.TEST_CASE_FILE_TYPE) + self.user_keywords = UserLibrary(resource, resource_file=False) self.libraries = OrderedDict() self.resources = ImportCache() self.search_order = () @@ -286,7 +286,7 @@ def _get_runner(self, name): raise DataError('Keyword name cannot be empty.') if not is_string(name): raise DataError('Keyword name must be a string.') - runner = self._get_runner_from_test_case_file(name) + runner = self._get_runner_from_suite_file(name) if not runner and '.' in name: runner = self._get_explicit_runner(name) if not runner: @@ -314,15 +314,17 @@ def _get_implicit_runner(self, name): runner = self._get_runner_from_libraries(name) return runner - def _get_runner_from_test_case_file(self, name): + def _get_runner_from_suite_file(self, name): if name not in self.user_keywords.handlers: return None - runner = self.user_keywords.handlers.create_runner(name) + handlers = self.user_keywords.handlers.get_handlers(name) + if len(handlers) > 1: + self._raise_multiple_keywords_found(handlers, name) + runner = handlers[0].create_runner(name, self.languages) ctx = EXECUTION_CONTEXTS.current caller = ctx.user_keywords[-1] if ctx.user_keywords else ctx.test if caller and runner.source != caller.source: - local_runner = self._get_runner_from_resource_file(name, caller.source) - if local_runner: + if self._exists_in_resource_file(name, caller.source): message = ( f"Keyword '{caller.longname}' called keyword '{name}' that " f"exist both in the same resource file and in the test case " @@ -332,74 +334,77 @@ def _get_runner_from_test_case_file(self, name): runner.pre_run_messages += Message(message, level='WARN'), return runner - def _get_runner_from_resource_file(self, name, source): + def _exists_in_resource_file(self, name, source): for resource in self.resources.values(): if resource.source == source and name in resource.handlers: - return resource.handlers.create_runner(name) - return None + return True + return False def _get_runner_from_resource_files(self, name): - found = [lib.handlers.create_runner(name) - for lib in self.resources.values() - if name in lib.handlers] - if not found: + handlers = [handler for res in self.resources.values() + for handler in res.handlers_for(name)] + if not handlers: return None - if len(found) > 1: - found = self._get_runner_based_on_search_order(found) - if len(found) > 1: - found = self._get_runner_from_same_resource_file(found) - if len(found) > 1: - found = self._handle_private_user_keywords(found) - if len(found) == 1: - return found[0] - self._raise_multiple_keywords_found(found, name) + if len(handlers) > 1: + handlers = self._filter_based_on_search_order(handlers) + if len(handlers) > 1: + handlers = self._prioritize_handlers_from_same_file(handlers) + if len(handlers) > 1: + handlers = self._filter_private_user_keywords(handlers) + if len(handlers) != 1: + self._raise_multiple_keywords_found(handlers, name) + return handlers[0].create_runner(name, self.languages) def _get_runner_from_libraries(self, name): - found = [lib.handlers.create_runner(name, self.languages) - for lib in self.libraries.values() - if name in lib.handlers] - if not found: + handlers = [handler for lib in self.libraries.values() + for handler in lib.handlers_for(name)] + if not handlers: return None - if len(found) > 1: - found = self._get_runner_based_on_search_order(found) - if len(found) == 2: - found = self._filter_stdlib_runner(*found) - if len(found) == 1: - return found[0] - self._raise_multiple_keywords_found(found, name) - - def _get_runner_from_same_resource_file(self, found): + pre_run_message = None + if len(handlers) > 1: + handlers = self._filter_based_on_search_order(handlers) + if len(handlers) > 1: + handlers, pre_run_message = self._filter_stdlib_handler(handlers) + if len(handlers) != 1: + self._raise_multiple_keywords_found(handlers, name) + runner = handlers[0].create_runner(name, self.languages) + if pre_run_message: + runner.pre_run_messages += (pre_run_message,) + return runner + + def _prioritize_handlers_from_same_file(self, handlers): user_keywords = EXECUTION_CONTEXTS.current.user_keywords if not user_keywords: - return found + return handlers parent_source = user_keywords[-1].source - for runner in found: - if runner.source == parent_source: - return [runner] - return found + matches = [h for h in handlers if h.source == parent_source] + return matches or handlers - def _handle_private_user_keywords(self, runners): - public = [r for r in runners if not r.private] - return public if len(public) == 1 else runners + def _filter_private_user_keywords(self, handlers): + matches = [handler for handler in handlers if not handler.private] + return matches if len(matches) == 1 else handlers - def _get_runner_based_on_search_order(self, runners): + def _filter_based_on_search_order(self, handlers): for libname in self.search_order: - for runner in runners: - if eq(libname, runner.libname): - return [runner] - return runners - - def _filter_stdlib_runner(self, runner1, runner2): + matches = [hand for hand in handlers if eq(libname, hand.libname)] + if matches: + return matches + return handlers + + def _filter_stdlib_handler(self, handlers): + warning = None + if len(handlers) != 2: + return handlers, warning stdlibs_without_remote = STDLIBS - {'Remote'} - if runner1.library.orig_name in stdlibs_without_remote: - standard, custom = runner1, runner2 - elif runner2.library.orig_name in stdlibs_without_remote: - standard, custom = runner2, runner1 + if handlers[0].library.orig_name in stdlibs_without_remote: + standard, custom = handlers + elif handlers[1].library.orig_name in stdlibs_without_remote: + custom, standard = handlers else: - return [runner1, runner2] + return handlers, warning if not RUN_KW_REGISTER.is_run_keyword(custom.library.orig_name, custom.name): - self._custom_and_standard_keyword_conflict_warning(custom, standard) - return [custom] + warning = self._custom_and_standard_keyword_conflict_warning(custom, standard) + return [custom], warning def _custom_and_standard_keyword_conflict_warning(self, custom, standard): custom_with_name = standard_with_name = '' @@ -407,38 +412,46 @@ def _custom_and_standard_keyword_conflict_warning(self, custom, standard): custom_with_name = f" imported as '{custom.library.name}'" if standard.library.name != standard.library.orig_name: standard_with_name = f" imported as '{standard.library.name}'" - message = ( + return Message( f"Keyword '{standard.name}' found both from a custom library " f"'{custom.library.orig_name}'{custom_with_name} and a standard library " f"'{standard.library.orig_name}'{standard_with_name}. The custom keyword " f"is used. To select explicitly, and to get rid of this warning, use " - f"either '{custom.longname}' or '{standard.longname}'." + f"either '{custom.longname}' or '{standard.longname}'.", level='WARN' ) - custom.pre_run_messages += Message(message, level='WARN'), def _get_explicit_runner(self, name): - found = [] - for owner_name, kw_name in self._yield_owner_and_kw_names(name): - found.extend(self._find_keywords(owner_name, kw_name)) - if len(found) > 1: - self._raise_multiple_keywords_found(found, name, implicit=False) - return found[0] if found else None + handlers_and_names = [ + (handler, kw_name) + for owner_name, kw_name in self._yield_owner_and_kw_names(name) + for handler in self._yield_handlers(owner_name, kw_name) + ] + if not handlers_and_names: + return None + if len(handlers_and_names) > 1: + handlers = [h for h, n in handlers_and_names] + self._raise_multiple_keywords_found(handlers, name, implicit=False) + handler, kw_name = handlers_and_names[0] + return handler.create_runner(kw_name, self.languages) def _yield_owner_and_kw_names(self, full_name): tokens = full_name.split('.') for i in range(1, len(tokens)): yield '.'.join(tokens[:i]), '.'.join(tokens[i:]) - def _find_keywords(self, owner_name, name): - return [owner.handlers.create_runner(name) - for owner in chain(self.libraries.values(), self.resources.values()) - if eq(owner.name, owner_name) and name in owner.handlers] + def _yield_handlers(self, owner_name, name): + for owner in chain(self.libraries.values(), self.resources.values()): + if eq(owner.name, owner_name) and name in owner.handlers: + yield from owner.handlers.get_handlers(name) - def _raise_multiple_keywords_found(self, runners, used_as, implicit=True): - error = f"Multiple keywords with name '{used_as}' found" - if implicit: - error += ". Give the full name of the keyword you want to use" - names = sorted(r.longname for r in runners) + def _raise_multiple_keywords_found(self, handlers, name, implicit=True): + if any(hand.supports_embedded_args for hand in handlers): + error = f"Multiple keywords matching name '{name}' found" + else: + error = f"Multiple keywords with name '{name}' found" + if implicit: + error += ". Give the full name of the keyword you want to use" + names = sorted(hand.longname for hand in handlers) raise KeywordError('\n '.join([error+':'] + names)) diff --git a/src/robot/running/testlibraries.py b/src/robot/running/testlibraries.py index fadc280fe6e..746800fb46d 100644 --- a/src/robot/running/testlibraries.py +++ b/src/robot/running/testlibraries.py @@ -74,7 +74,7 @@ def __init__(self, libcode, name, args, source, logger, variables): self.source = source self.logger = logger self.converters = self._get_converters(libcode) - self.handlers = HandlerStore(self.name, HandlerStore.LIBRARY_TYPE) + self.handlers = HandlerStore() self.has_listener = None # Set when first instance is created self._doc = None self.doc_format = self._get_doc_format(libcode) @@ -112,8 +112,11 @@ def create_handlers(self): self._create_handlers(self.get_instance()) self.reset_instance() + def handlers_for(self, name): + return self.handlers.get_handlers(name) + def reload(self): - self.handlers = HandlerStore(self.name, HandlerStore.LIBRARY_TYPE) + self.handlers = HandlerStore() self._create_handlers(self.get_instance()) def start_suite(self): diff --git a/src/robot/running/usererrorhandler.py b/src/robot/running/usererrorhandler.py index 4f0c49380fb..e8a80f8c552 100644 --- a/src/robot/running/usererrorhandler.py +++ b/src/robot/running/usererrorhandler.py @@ -28,6 +28,7 @@ class UserErrorHandler: tests in affected test case file from executing. Instead UserErrorHandler is created and if it is ever run DataError is raised then. """ + supports_embedded_arguments = False def __init__(self, error, name, libname=None, source=None, lineno=None): """ diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index 1c3913dc847..c53fab79d06 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -26,18 +26,14 @@ class UserLibrary: - TEST_CASE_FILE_TYPE = HandlerStore.TEST_CASE_FILE_TYPE - RESOURCE_FILE_TYPE = HandlerStore.RESOURCE_FILE_TYPE - def __init__(self, resource, source_type=RESOURCE_FILE_TYPE): + def __init__(self, resource, resource_file=True): source = resource.source basename = os.path.basename(source) if source else None - self.name = os.path.splitext(basename)[0] \ - if source_type == self.RESOURCE_FILE_TYPE else None + self.name = os.path.splitext(basename)[0] if resource_file else None self.doc = resource.doc - self.handlers = HandlerStore(basename, source_type) + self.handlers = HandlerStore() self.source = source - self.source_type = source_type for kw in resource.keywords: try: handler = self._create_handler(kw) @@ -64,10 +60,14 @@ def _log_creating_failed(self, handler, error): LOGGER.error(f"Error in file '{self.source}' on line {handler.lineno}: " f"Creating keyword '{handler.name}' failed: {error.message}") + def handlers_for(self, name): + return self.handlers.get_handlers(name) + # TODO: Should be merged with running.model.UserKeyword class UserKeywordHandler: + supports_embedded_args = False def __init__(self, keyword, libname): self.name = keyword.name @@ -100,10 +100,10 @@ def create_runner(self, name, languages=None): class EmbeddedArgumentsHandler(UserKeywordHandler): + supports_embedded_args = True def __init__(self, keyword, libname, embedded): super().__init__(keyword, libname) - self.keyword = keyword self.embedded = embedded def matches(self, name): diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 0898c0c475a..3dacfbed7b9 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -38,7 +38,7 @@ def __init__(self, handler, name=None): @property def longname(self): libname = self._handler.libname - return '%s.%s' % (libname, self.name) if libname else self.name + return f'{libname}.{self.name}' if libname else self.name @property def libname(self): @@ -52,10 +52,6 @@ def tags(self): def source(self): return self._handler.source - @property - def private(self): - return self._handler.private - @property def arguments(self): """:rtype: :py:class:`robot.running.arguments.ArgumentSpec`""" @@ -65,7 +61,7 @@ def run(self, kw, context, run=True): assignment = VariableAssignment(kw.assign) result = self._get_result(kw, assignment, context.variables) with StatusReporter(kw, result, context, run): - if self.private: + if self._handler.private: context.warn_on_invalid_private_call(self._handler) with assignment.assigner(context) as assigner: if run: diff --git a/utest/running/test_testlibrary.py b/utest/running/test_testlibrary.py index 66e80231ba0..2640f19a800 100644 --- a/utest/running/test_testlibrary.py +++ b/utest/running/test_testlibrary.py @@ -355,7 +355,7 @@ def test_global_handlers_are_created_only_once(self): assert_equal(instance.kw_accessed, 1) assert_equal(instance.kw_called, 0) for _ in range(5): - lib.handlers.create_runner('kw')._run(_FakeContext(), []) + lib.handlers['kw'].create_runner('kw')._run(_FakeContext(), []) assert_true(lib._libinst is instance) assert_equal(instance.kw_accessed, 1) assert_equal(instance.kw_called, 5) diff --git a/utest/running/test_userlibrary.py b/utest/running/test_userlibrary.py index fb4463fd46e..17c6a2a8bed 100644 --- a/utest/running/test_userlibrary.py +++ b/utest/running/test_userlibrary.py @@ -3,8 +3,7 @@ from robot.running import userkeyword from robot.running.model import ResourceFile, UserKeyword -from robot.running.userkeyword import UserLibrary -from robot.errors import DataError +from robot.running.userkeyword import EmbeddedArguments, UserLibrary from robot.utils.asserts import (assert_equal, assert_none, assert_raises_with_msg, assert_true) @@ -25,12 +24,13 @@ def create(self, name): class EmbeddedArgsHandlerStub: def __init__(self, kwdata, library, embedded): - self.name = kwdata.name - if kwdata.name != 'Embedded ${arg}': + if '${' not in kwdata.name: raise TypeError + self.name = kwdata.name + self.embedded = embedded def matches(self, name): - return name == self.name + return self.embedded.match(name) class TestUserLibrary(unittest.TestCase): @@ -101,9 +101,17 @@ def test_handlers_contains(self): def test_handlers_getitem_with_non_existing_keyword(self): lib = self._get_userlibrary('kw') assert_raises_with_msg( - DataError, - "Test case file contains no keywords matching name 'non existing'.", - lib.handlers.__getitem__, 'non existing') + ValueError, "No handler with name 'non existing' found.", + lib.handlers.__getitem__, 'non existing' + ) + + def test_handlers_getitem_with_multiple_matching_keywords(self): + lib = self._get_userlibrary('Embedded ${a}', 'Embedded ${b}') + assert_raises_with_msg( + ValueError, + "Multiple handlers matching name 'Embedded x' found: 'Embedded ${a}' and 'Embedded ${b}'", + lib.handlers.__getitem__, 'Embedded x' + ) def test_handlers_getitem_with_existing_keyword(self): lib = self._get_userlibrary('kw') @@ -113,9 +121,7 @@ def test_handlers_getitem_with_existing_keyword(self): def _get_userlibrary(self, *keywords, **conf): resource = ResourceFile(**conf) resource.keywords = [UserKeyword(name) for name in keywords] - resource_type = UserLibrary.TEST_CASE_FILE_TYPE \ - if 'source' not in conf else UserLibrary.RESOURCE_FILE_TYPE - return UserLibrary(resource, resource_type) + return UserLibrary(resource, resource_file='source' in conf) def _lib_has_embedded_arg_keyword(self, lib, count=1): assert_true('Embedded ${arg}' in lib.handlers) From 850297fc8fd401b91576a4ac82d31af514f2f6c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 15 Sep 2022 16:28:26 +0300 Subject: [PATCH 0189/1592] Give precedence to local and public kws over search order. Prioritizing local keywords in a resource file (#4366) and prioritizing public user keywords over private ones (#430) are new features in RF 5.1. Earlier possible library search order was looked at first, but this commit changes logic so that it is consulted only as the last resort. Motivation for the change has been discussed in the comments of #4454: https://github.com/robotframework/robotframework/issues/4454#issuecomment-1248018031 --- atest/robot/keywords/keyword_namespaces.robot | 5 +++++ atest/robot/keywords/private.robot | 5 +++++ atest/testdata/keywords/keyword_namespaces.robot | 6 ++++++ atest/testdata/keywords/private.robot | 5 +++++ src/robot/libraries/BuiltIn.py | 13 +++++++------ src/robot/running/namespace.py | 6 +++--- 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/atest/robot/keywords/keyword_namespaces.robot b/atest/robot/keywords/keyword_namespaces.robot index fbe4058866b..ae014941ccd 100644 --- a/atest/robot/keywords/keyword_namespaces.robot +++ b/atest/robot/keywords/keyword_namespaces.robot @@ -39,6 +39,11 @@ Local keyword in resource file has precedence over keywords in other resource fi Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} Keyword in resource 1 Check Log Message ${tc.body[1].body[0].body[0].msgs[0]} Keyword in resource 2 +Local keyword in resource file has precedence even if search order is set + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} Keyword in resource 1 + Check Log Message ${tc.body[1].body[0].body[0].msgs[0]} Keyword in resource 2 + Keyword From Custom Library Overrides Keywords From Standard Library ${tc} = Check Test Case ${TEST NAME} Verify Override Message ${ERRORS}[2] ${tc.kws[0].msgs[0]} Comment BuiltIn diff --git a/atest/robot/keywords/private.robot b/atest/robot/keywords/private.robot index 91d2b46e45a..bdb73611e92 100644 --- a/atest/robot/keywords/private.robot +++ b/atest/robot/keywords/private.robot @@ -31,6 +31,11 @@ Local Private Keyword In Resource File Has Precedence Over Keywords In Another R Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} private.resource Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} private.resource +Local Private Keyword In Resource File Has Precedence Even If Search Order Is Set + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} private.resource + Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} private.resource + Imported Public Keyword Has Precedence Over Imported Private Keywords ${tc}= Check Test Case ${TESTNAME} Check Log Message ${tc.body[0].body[0].msgs[0]} private2.resource diff --git a/atest/testdata/keywords/keyword_namespaces.robot b/atest/testdata/keywords/keyword_namespaces.robot index 4473d735fcf..2753540cf87 100644 --- a/atest/testdata/keywords/keyword_namespaces.robot +++ b/atest/testdata/keywords/keyword_namespaces.robot @@ -63,6 +63,12 @@ Local keyword in resource file has precedence over keywords in other resource fi Use local keyword that exists also in another resource 1 Use local keyword that exists also in another resource 2 +Local keyword in resource file has precedence even if search order is set + [Setup] Set library search order my_resource_1 + Use local keyword that exists also in another resource 1 + Use local keyword that exists also in another resource 2 + [Teardown] Set library search order + Keyword From Custom Library Overrides Keywords From Standard Library Comment Copy Directory diff --git a/atest/testdata/keywords/private.robot b/atest/testdata/keywords/private.robot index 22ebc79d36b..abd21847068 100644 --- a/atest/testdata/keywords/private.robot +++ b/atest/testdata/keywords/private.robot @@ -22,6 +22,11 @@ Invalid Usage In Resource file Local Private Keyword In Resource File Has Precedence Over Keywords In Another Resource Use Local Private Keyword Instead Of Keywords From Other Resources +Local Private Keyword In Resource File Has Precedence Even If Search Order Is Set + [Setup] Set Library Search Order private2 private2 + Use Local Private Keyword Instead Of Keywords From Other Resources + [Teardown] Set Library Search Order + Imported Public Keyword Has Precedence Over Imported Private Keywords Private In One Resource And Public In Another Use Imported Public Keyword Instead Instead Of Imported Private Keyword diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index c4e393b0329..e9e325946f5 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -3215,12 +3215,12 @@ def import_resource(self, path): def set_library_search_order(self, *search_order): """Sets the resolution order to use when a name matches multiple keywords. - The library search order is used to resolve conflicts when a keyword - name in the test data matches multiple keywords. The first library + The library search order is used to resolve conflicts when a keyword name + that is used matches multiple keyword implementations. The first library (or resource, see below) containing the keyword is selected and that keyword implementation used. If the keyword is not found from any library - (or resource), test executing fails the same way as when the search - order is not set. + (or resource), execution fails the same way as when the search order is + not set. When this keyword is used, there is no need to use the long ``LibraryName.Keyword Name`` notation. For example, instead of @@ -3244,10 +3244,11 @@ def set_library_search_order(self, *search_order): | Set Library Search Order | resource | another_resource | *NOTE:* - - The search order is valid only in the suite where this keywords is used. + - The search order is valid only in the suite where this keyword is used. - Keywords in resources always have higher priority than keywords in libraries regardless the search order. - The old order is returned and can be used to reset the search order later. + - Calling this keyword without arguments removes possible search order. - Library and resource names in the search order are both case and space insensitive. """ @@ -3256,7 +3257,7 @@ def set_library_search_order(self, *search_order): def keyword_should_exist(self, name, msg=None): """Fails unless the given keyword exists in the current scope. - Fails also if there are more than one keywords with the same name. + Fails also if there is more than one keyword with the same name. Works both with the short name (e.g. ``Log``) and the full name (e.g. ``BuiltIn.Log``). diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index b2e8421c1b8..c953ecffe07 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -346,11 +346,11 @@ def _get_runner_from_resource_files(self, name): if not handlers: return None if len(handlers) > 1: - handlers = self._filter_based_on_search_order(handlers) + handlers = self._prioritize_handlers_from_same_file(handlers) if len(handlers) > 1: - handlers = self._prioritize_handlers_from_same_file(handlers) + handlers = self._filter_private_user_keywords(handlers) if len(handlers) > 1: - handlers = self._filter_private_user_keywords(handlers) + handlers = self._filter_based_on_search_order(handlers) if len(handlers) != 1: self._raise_multiple_keywords_found(handlers, name) return handlers[0].create_runner(name, self.languages) From 95881a2ae0341b846e8df9be1b7887a4d78408be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 15 Sep 2022 23:59:58 +0300 Subject: [PATCH 0190/1592] Select best match if kws with embedded args have conflict. See #4454. --- .../embedded_arguments_conflicts.robot | 77 +++++++++ .../embedded_arguments_conflicts.robot | 149 ++++++++++++++++++ .../embedded_arguments_conflicts/library.py | 28 ++++ .../embedded_arguments_conflicts/library2.py | 17 ++ .../resource.resource | 32 ++++ .../resource2.resource | 17 ++ atest/testdata/keywords/private.robot | 9 +- src/robot/running/namespace.py | 55 +++++-- 8 files changed, 364 insertions(+), 20 deletions(-) create mode 100644 atest/robot/keywords/embedded_arguments_conflicts.robot create mode 100644 atest/testdata/keywords/embedded_arguments_conflicts.robot create mode 100644 atest/testdata/keywords/embedded_arguments_conflicts/library.py create mode 100644 atest/testdata/keywords/embedded_arguments_conflicts/library2.py create mode 100644 atest/testdata/keywords/embedded_arguments_conflicts/resource.resource create mode 100644 atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource diff --git a/atest/robot/keywords/embedded_arguments_conflicts.robot b/atest/robot/keywords/embedded_arguments_conflicts.robot new file mode 100644 index 00000000000..2bd0abf7359 --- /dev/null +++ b/atest/robot/keywords/embedded_arguments_conflicts.robot @@ -0,0 +1,77 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/embedded_arguments_conflicts.robot +Resource atest_resource.robot + +*** Test Cases *** +Unique match in suite file + Check Test Case ${TESTNAME} + +Best match wins in suite file + Check Test Case ${TESTNAME} + +Conflict in suite file + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Unique match in resource + Check Test Case ${TESTNAME} + +Best match wins in resource + Check Test Case ${TESTNAME} + +Conflict in resource + Check Test Case ${TESTNAME} + +Unique match in resource with explicit usage + Check Test Case ${TESTNAME} + +Best match wins in resource with explicit usage + Check Test Case ${TESTNAME} + +Conflict in resource with explicit usage + Check Test Case ${TESTNAME} + +Unique match in library + Check Test Case ${TESTNAME} + +Best match wins in library + Check Test Case ${TESTNAME} + +Conflict in library + Check Test Case ${TESTNAME} + +Unique match in library with explicit usage + Check Test Case ${TESTNAME} + +Best match wins in library with explicit usage + Check Test Case ${TESTNAME} + +Conflict in library with explicit usage + Check Test Case ${TESTNAME} + +Search order resolves conflict with resources + Check Test Case ${TESTNAME} + +Best match in resource wins over search order + Check Test Case ${TESTNAME} + +Search order resolves conflict with libraries + Check Test Case ${TESTNAME} + +Best match in library wins over search order + Check Test Case ${TESTNAME} + +Search order cannot resolve conflict within resource + Check Test Case ${TESTNAME} + +Search order cannot resolve conflict within library + Check Test Case ${TESTNAME} + +Public match wins over better private match in different resource + Check Test Case ${TESTNAME} + +Match in same resource wins over better match elsewhere + Check Test Case ${TESTNAME} + +Keyword without embedded arguments wins over keyword with them + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/embedded_arguments_conflicts.robot b/atest/testdata/keywords/embedded_arguments_conflicts.robot new file mode 100644 index 00000000000..d95f22b5a1d --- /dev/null +++ b/atest/testdata/keywords/embedded_arguments_conflicts.robot @@ -0,0 +1,149 @@ +*** Settings *** +Resource embedded_arguments_conflicts/resource.resource +Resource embedded_arguments_conflicts/resource2.resource +Library embedded_arguments_conflicts/library.py +Library embedded_arguments_conflicts/library2.py +Suite Setup Set library search order resource2 library2 + +*** Variables *** +${INDENT} ${SPACE * 4} + +*** Test Cases *** +Unique match in suite file + Execute "robot" + Automation framework + Robot uprising + +Best match wins in suite file + Execute "x" on device "y" + +Conflict in suite file 1 + [Documentation] FAIL + ... Multiple keywords matching name 'Execute "ls"' found: + ... ${INDENT}Execute "\${command:(ls|grep)}" + ... ${INDENT}Execute "\${command}" + Execute "ls" + +Conflict in suite file 2 + [Documentation] FAIL + ... Multiple keywords matching name 'Robot Framework' found: + ... ${INDENT}\${x} Framework + ... ${INDENT}Robot \${x} + Robot Framework + +Unique match in resource + x in resource + No conflict in resource + +Best match wins in resource + x and y in resource + +Conflict in resource + [Documentation] FAIL + ... Multiple keywords matching name 'y in resource' found: + ... ${INDENT}resource.\${x} in resource + ... ${INDENT}resource.\${y:y} in resource + y in resource + +Unique match in resource with explicit usage + resource.x in resource + resource2.No conflict in resource + +Best match wins in resource with explicit usage + resource.x and y in resource + +Conflict in resource with explicit usage + [Documentation] FAIL + ... Multiple keywords matching name 'resource.y in resource' found: + ... ${INDENT}resource.\${x} in resource + ... ${INDENT}resource.\${y:y} in resource + resource.y in resource + +Unique match in library + x in library + No conflict in library + +Best match wins in library + x and y in library + +Conflict in library + [Documentation] FAIL + ... Multiple keywords matching name 'y in library' found: + ... ${INDENT}library.\${x} in library + ... ${INDENT}library.\${y:y} in library + y in library + +Unique match in library with explicit usage + library.x in library + library2.No conflict in library + +Best match wins in library with explicit usage + library.x and y in library + +Conflict in library with explicit usage + [Documentation] FAIL + ... Multiple keywords matching name 'library.y in library' found: + ... ${INDENT}library.\${x} in library + ... ${INDENT}library.\${y:y} in library + library.y in library + +Search order resolves conflict with resources + Match in both resources + +Best match in resource wins over search order + Best match in one of resources + +Search order resolves conflict with libraries + Match in both libraries + +Best match in library wins over search order + Best match in one of libraries + +Search order cannot resolve conflict within resource + [Documentation] FAIL + ... Multiple keywords matching name 'Unresolvable conflict in resource' found: + ... ${INDENT}resource2.\${possible} conflict in resource + ... ${INDENT}resource2.Unresolvable \${conflict} in resource + Unresolvable conflict in resource + +Search order cannot resolve conflict within library + [Documentation] FAIL + ... Multiple keywords matching name 'Unresolvable conflict in library' found: + ... ${INDENT}library2.\${possible} conflict in library + ... ${INDENT}library2.Unresolvable \${conflict} in library + Unresolvable conflict in library + +Public match wins over better private match in different resource + [Documentation] and better match wins when both are in same file + Better public match + +Match in same resource wins over better match elsewhere + [Documentation] even if match in same file would be private + Match in same resource wins over better match elsewhere + +Keyword without embedded arguments wins over keyword with them + Match with and without embedded arguments + Match with embedded arguments + +*** Keywords *** +Execute "${command}" + Should be equal ${command} robot + +Execute "${command}" on device "${device}" + Should be equal ${command} x + Should be equal ${device} y + +Execute "${command:(ls|grep)}" + Fail Should not be run due to conflict + +${x} Framework + Should be equal ${x} Automation + +Robot ${x} + Should be equal ${x} uprising + +Match with and without embedded arguments + No Operation + +Match ${with} embedded arguments + Should be equal ${with} with diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/library.py b/atest/testdata/keywords/embedded_arguments_conflicts/library.py new file mode 100644 index 00000000000..49e0e925d11 --- /dev/null +++ b/atest/testdata/keywords/embedded_arguments_conflicts/library.py @@ -0,0 +1,28 @@ +from robot.api.deco import keyword + + +@keyword('${x} in library') +def x_in_library(x): + assert x == 'x' + + +@keyword('${x} and ${y} in library') +def x_and_y_in_library(x, y): + assert x == 'x' + assert y == 'y' + + +@keyword('${y:y} in library') +def y_in_library(y): + assert False + + +@keyword('${match} in ${both} libraries') +def match_in_both_libraries(match, both): + assert False + + +@keyword('Best ${match} in ${one of} libraries') +def best_match_in_one_of_libraries(match, one_of): + assert match == 'match' + assert one_of == 'one of' diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/library2.py b/atest/testdata/keywords/embedded_arguments_conflicts/library2.py new file mode 100644 index 00000000000..34fc74d3b01 --- /dev/null +++ b/atest/testdata/keywords/embedded_arguments_conflicts/library2.py @@ -0,0 +1,17 @@ +from robot.api.deco import keyword + + +@keyword('${match} in ${both} libraries') +def match_in_both_libraries(match, both): + assert match == 'Match' + assert both == 'both' + + +@keyword('Unresolvable ${conflict} in library') +def unresolvable_conflict_in_library(conflict): + assert False + + +@keyword('${possible} conflict in library') +def possible_conflict_in_library(possible): + assert possible == 'No' diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource new file mode 100644 index 00000000000..1bbf96247fd --- /dev/null +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource @@ -0,0 +1,32 @@ +*** Keywords *** +${x} in resource + Should be equal ${x} x + +${x} and ${y} in resource + Should be equal ${x} x + Should be equal ${y} y + +${y:y} in resource + Fail Should not be run due to conflict + +${match} in ${both} resources + Fail Should not be run due to search order + +Best ${match} in ${one of} resources + Should be equal ${match} match + Should be equal ${one of} one of + +${public} match + Should be equal ${public} Better public + Better private match + +Better ${private} match + [Tags] robot:private + Should be equal ${private} private + +Match in same resource wins over better match elsewhere + Another match in both resource files + +Another match ${in both resource files} + [Tags] robot:private + Should be equal ${in both resource files} in both resource files diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource new file mode 100644 index 00000000000..a0b1bddc636 --- /dev/null +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource @@ -0,0 +1,17 @@ +*** Keywords *** +${match} in ${both} resources + Should be equal ${match} Match + Should be equal ${both} both + +Unresolvable ${conflict} in resource + Fail Should not be run due to conflict + +${possible} conflict in resource + Should be equal ${possible} No + +Better ${private} match + [Tags] robot:private + Fail Should not be run due to being private in different file + +Another ${match} in both resource files + Fail Better match but should not be run due to being in different file diff --git a/atest/testdata/keywords/private.robot b/atest/testdata/keywords/private.robot index abd21847068..38a32404f17 100644 --- a/atest/testdata/keywords/private.robot +++ b/atest/testdata/keywords/private.robot @@ -23,7 +23,7 @@ Local Private Keyword In Resource File Has Precedence Over Keywords In Another R Use Local Private Keyword Instead Of Keywords From Other Resources Local Private Keyword In Resource File Has Precedence Even If Search Order Is Set - [Setup] Set Library Search Order private2 private2 + [Setup] Set Library Search Order private2 private3 Use Local Private Keyword Instead Of Keywords From Other Resources [Teardown] Set Library Search Order @@ -32,7 +32,8 @@ Imported Public Keyword Has Precedence Over Imported Private Keywords Use Imported Public Keyword Instead Instead Of Imported Private Keyword If All Keywords Are Private Raise Multiple Keywords Found - [Documentation] FAIL Multiple keywords with name 'Private Keyword In All Resources' found. \ + [Documentation] FAIL + ... Multiple keywords with name 'Private Keyword In All Resources' found. \ ... Give the full name of the keyword you want to use: ... ${SPACE*4}private.Private Keyword In All Resources ... ${SPACE*4}private2.Private Keyword In All Resources @@ -40,9 +41,9 @@ If All Keywords Are Private Raise Multiple Keywords Found Private Keyword In All Resources If More Than Two Keywords Are Public Raise Multiple Keywords Found - [Documentation] FAIL Multiple keywords with name 'Private In One Resource And Public In Two' found. \ + [Documentation] FAIL + ... Multiple keywords with name 'Private In One Resource And Public In Two' found. \ ... Give the full name of the keyword you want to use: - ... ${SPACE*4}private.Private In One Resource And Public In Two ... ${SPACE*4}private2.Private In One Resource And Public In Two ... ${SPACE*4}private3.Private In One Resource And Public In Two Private In One Resource And Public In Two diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index c953ecffe07..2d8f16fec23 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -319,7 +319,9 @@ def _get_runner_from_suite_file(self, name): return None handlers = self.user_keywords.handlers.get_handlers(name) if len(handlers) > 1: - self._raise_multiple_keywords_found(handlers, name) + handlers = self._select_best_embedded_match(handlers) + if len(handlers) > 1: + self._raise_multiple_keywords_found(handlers, name) runner = handlers[0].create_runner(name, self.languages) ctx = EXECUTION_CONTEXTS.current caller = ctx.user_keywords[-1] if ctx.user_keywords else ctx.test @@ -334,6 +336,23 @@ def _get_runner_from_suite_file(self, name): runner.pre_run_messages += Message(message, level='WARN'), return runner + def _select_best_embedded_match(self, handlers): + if all(hand.supports_embedded_args for hand in handlers): + for hand in handlers: + if self._is_best_embedded_match(hand, handlers): + return [hand] + return handlers + + def _is_best_embedded_match(self, candidate, alternatives): + # Match is considered better than another match if it doesn't match + # the other but the other matches it. + for other in alternatives: + if candidate is other: + continue + if candidate.matches(other.name) or not other.matches(candidate.name): + return False + return True + def _exists_in_resource_file(self, name, source): for resource in self.resources.values(): if resource.source == source and name in resource.handlers: @@ -346,9 +365,9 @@ def _get_runner_from_resource_files(self, name): if not handlers: return None if len(handlers) > 1: - handlers = self._prioritize_handlers_from_same_file(handlers) + handlers = self._prioritize_same_file_or_public(handlers) if len(handlers) > 1: - handlers = self._filter_private_user_keywords(handlers) + handlers = self._select_best_embedded_match(handlers) if len(handlers) > 1: handlers = self._filter_based_on_search_order(handlers) if len(handlers) != 1: @@ -362,9 +381,11 @@ def _get_runner_from_libraries(self, name): return None pre_run_message = None if len(handlers) > 1: - handlers = self._filter_based_on_search_order(handlers) + handlers = self._select_best_embedded_match(handlers) if len(handlers) > 1: handlers, pre_run_message = self._filter_stdlib_handler(handlers) + if len(handlers) > 1: + handlers = self._filter_based_on_search_order(handlers) if len(handlers) != 1: self._raise_multiple_keywords_found(handlers, name) runner = handlers[0].create_runner(name, self.languages) @@ -372,17 +393,15 @@ def _get_runner_from_libraries(self, name): runner.pre_run_messages += (pre_run_message,) return runner - def _prioritize_handlers_from_same_file(self, handlers): + def _prioritize_same_file_or_public(self, handlers): user_keywords = EXECUTION_CONTEXTS.current.user_keywords - if not user_keywords: - return handlers - parent_source = user_keywords[-1].source - matches = [h for h in handlers if h.source == parent_source] - return matches or handlers - - def _filter_private_user_keywords(self, handlers): + if user_keywords: + parent_source = user_keywords[-1].source + matches = [h for h in handlers if h.source == parent_source] + if matches: + return matches matches = [handler for handler in handlers if not handler.private] - return matches if len(matches) == 1 else handlers + return matches or handlers def _filter_based_on_search_order(self, handlers): for libname in self.search_order: @@ -428,10 +447,14 @@ def _get_explicit_runner(self, name): ] if not handlers_and_names: return None - if len(handlers_and_names) > 1: + if len(handlers_and_names) == 1: + handler, kw_name = handlers_and_names[0] + else: handlers = [h for h, n in handlers_and_names] - self._raise_multiple_keywords_found(handlers, name, implicit=False) - handler, kw_name = handlers_and_names[0] + matches = self._select_best_embedded_match(handlers) + if len(matches) > 1: + self._raise_multiple_keywords_found(handlers, name, implicit=False) + handler, kw_name = handlers_and_names[handlers.index(matches[0])] return handler.create_runner(kw_name, self.languages) def _yield_owner_and_kw_names(self, full_name): From 7288cdd6725e00aaf1038fbd18e66d5b8b494870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 16 Sep 2022 01:03:56 +0300 Subject: [PATCH 0191/1592] Fine-tune warning message --- atest/robot/keywords/keyword_namespaces.robot | 6 +++--- src/robot/running/namespace.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/atest/robot/keywords/keyword_namespaces.robot b/atest/robot/keywords/keyword_namespaces.robot index ae014941ccd..a19702c3601 100644 --- a/atest/robot/keywords/keyword_namespaces.robot +++ b/atest/robot/keywords/keyword_namespaces.robot @@ -28,9 +28,9 @@ Keyword From Resource Overrides Keywords From Libraries Keyword From Test Case File Overriding Local Keyword In Resource File Is Deprecated ${tc} = Check Test Case ${TEST NAME} ${message} = Catenate - ... Keyword 'my_resource_1.Use test case file keyword even when local keyword with same name exists' called - ... keyword 'Keyword Everywhere' that exist both in the same resource file and in the test case file using - ... that resource. The keyword in the test case file is used now, but this will change in Robot Framework 6.0. + ... Keyword 'my_resource_1.Use test case file keyword even when local keyword with same name exists' called keyword + ... 'Keyword Everywhere' that exists both in the same resource file as the caller and in the suite file using that + ... resource. The keyword in the suite file is used now, but this will change in Robot Framework 6.0. Check Log Message ${tc.body[0].body[0].msgs[0]} ${message} WARN Check Log Message ${ERRORS}[1] ${message} WARN diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 2d8f16fec23..7c92fcfbd96 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -328,10 +328,10 @@ def _get_runner_from_suite_file(self, name): if caller and runner.source != caller.source: if self._exists_in_resource_file(name, caller.source): message = ( - f"Keyword '{caller.longname}' called keyword '{name}' that " - f"exist both in the same resource file and in the test case " - f"file using that resource. The keyword in the test case file " - f"is used now, but this will change in Robot Framework 6.0." + f"Keyword '{caller.longname}' called keyword '{name}' that exists " + f"both in the same resource file as the caller and in the suite " + f"file using that resource. The keyword in the suite file is used " + f"now, but this will change in Robot Framework 6.0." ) runner.pre_run_messages += Message(message, level='WARN'), return runner From 48aea619e2186c9c9ec8c4c2ac16e50a619172d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 16 Sep 2022 01:04:31 +0300 Subject: [PATCH 0192/1592] Use "normal" match over embedded matches in case of conflicts. Matches not containing embedded args are considered exact and thus they win over matches with embedded args. Searching for keywords from a single file has always worked like that. This change makes the behavior consistent if matches are from different files. This is part of #4454. --- .../embedded_arguments_conflicts.robot | 5 +++- .../embedded_arguments_conflicts.robot | 29 ++++++++++++++++--- .../resource.resource | 3 ++ .../resource2.resource | 3 ++ src/robot/running/namespace.py | 23 ++++++++------- 5 files changed, 48 insertions(+), 15 deletions(-) diff --git a/atest/robot/keywords/embedded_arguments_conflicts.robot b/atest/robot/keywords/embedded_arguments_conflicts.robot index 2bd0abf7359..b93ab55491f 100644 --- a/atest/robot/keywords/embedded_arguments_conflicts.robot +++ b/atest/robot/keywords/embedded_arguments_conflicts.robot @@ -73,5 +73,8 @@ Public match wins over better private match in different resource Match in same resource wins over better match elsewhere Check Test Case ${TESTNAME} -Keyword without embedded arguments wins over keyword with them +Keyword without embedded arguments wins over keyword with them in same file + Check Test Case ${TESTNAME} + +Keyword without embedded arguments wins over keyword with them in different file Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/embedded_arguments_conflicts.robot b/atest/testdata/keywords/embedded_arguments_conflicts.robot index d95f22b5a1d..f2924a5fc2a 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts.robot +++ b/atest/testdata/keywords/embedded_arguments_conflicts.robot @@ -3,7 +3,6 @@ Resource embedded_arguments_conflicts/resource.resource Resource embedded_arguments_conflicts/resource2.resource Library embedded_arguments_conflicts/library.py Library embedded_arguments_conflicts/library2.py -Suite Setup Set library search order resource2 library2 *** Variables *** ${INDENT} ${SPACE * 4} @@ -88,30 +87,42 @@ Conflict in library with explicit usage library.y in library Search order resolves conflict with resources + [Setup] Enable search order Match in both resources + [Teardown] Disable search order Best match in resource wins over search order + [Setup] Enable search order Best match in one of resources + [Teardown] Disable search order Search order resolves conflict with libraries + [Setup] Enable search order Match in both libraries + [Teardown] Disable search order Best match in library wins over search order + [Setup] Enable search order Best match in one of libraries + [Teardown] Disable search order Search order cannot resolve conflict within resource [Documentation] FAIL ... Multiple keywords matching name 'Unresolvable conflict in resource' found: ... ${INDENT}resource2.\${possible} conflict in resource ... ${INDENT}resource2.Unresolvable \${conflict} in resource + [Setup] Enable search order Unresolvable conflict in resource + [Teardown] Disable search order Search order cannot resolve conflict within library [Documentation] FAIL ... Multiple keywords matching name 'Unresolvable conflict in library' found: ... ${INDENT}library2.\${possible} conflict in library ... ${INDENT}library2.Unresolvable \${conflict} in library + [Setup] Enable search order Unresolvable conflict in library + [Teardown] Disable search order Public match wins over better private match in different resource [Documentation] and better match wins when both are in same file @@ -121,10 +132,14 @@ Match in same resource wins over better match elsewhere [Documentation] even if match in same file would be private Match in same resource wins over better match elsewhere -Keyword without embedded arguments wins over keyword with them +Keyword without embedded arguments wins over keyword with them in same file Match with and without embedded arguments Match with embedded arguments +Keyword without embedded arguments wins over keyword with them in different file + Match with and without embedded arguments in different files + Match with embedded arguments in different files + *** Keywords *** Execute "${command}" Should be equal ${command} robot @@ -145,5 +160,11 @@ Robot ${x} Match with and without embedded arguments No Operation -Match ${with} embedded arguments - Should be equal ${with} with +Match ${with and without} embedded arguments + Should be equal ${with and without} with + +Enable search order + Set library search order resource2 library2 + +Disable search order + Set library search order diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource index 1bbf96247fd..e810f8ea168 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource @@ -30,3 +30,6 @@ Match in same resource wins over better match elsewhere Another match ${in both resource files} [Tags] robot:private Should be equal ${in both resource files} in both resource files + +Match with and without embedded arguments in different files + No operation diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource index a0b1bddc636..b07e4e977de 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource @@ -15,3 +15,6 @@ Better ${private} match Another ${match} in both resource files Fail Better match but should not be run due to being in different file + +Match ${with and without} embedded arguments in different files + Should be equal ${with and without} with diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 7c92fcfbd96..f1a56477ae3 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -319,7 +319,7 @@ def _get_runner_from_suite_file(self, name): return None handlers = self.user_keywords.handlers.get_handlers(name) if len(handlers) > 1: - handlers = self._select_best_embedded_match(handlers) + handlers = self._select_best_match(handlers) if len(handlers) > 1: self._raise_multiple_keywords_found(handlers, name) runner = handlers[0].create_runner(name, self.languages) @@ -336,15 +336,18 @@ def _get_runner_from_suite_file(self, name): runner.pre_run_messages += Message(message, level='WARN'), return runner - def _select_best_embedded_match(self, handlers): - if all(hand.supports_embedded_args for hand in handlers): - for hand in handlers: - if self._is_best_embedded_match(hand, handlers): - return [hand] + def _select_best_match(self, handlers): + # "Normal" match is considered exact and wins over embedded matches. + normal = [hand for hand in handlers if not hand.supports_embedded_args] + if normal: + return normal if len(normal) == 1 else handlers + for hand in handlers: + if self._is_best_embedded_match(hand, handlers): + return [hand] return handlers def _is_best_embedded_match(self, candidate, alternatives): - # Match is considered better than another match if it doesn't match + # Embedded match is considered better than another if it doesn't match # the other but the other matches it. for other in alternatives: if candidate is other: @@ -367,7 +370,7 @@ def _get_runner_from_resource_files(self, name): if len(handlers) > 1: handlers = self._prioritize_same_file_or_public(handlers) if len(handlers) > 1: - handlers = self._select_best_embedded_match(handlers) + handlers = self._select_best_match(handlers) if len(handlers) > 1: handlers = self._filter_based_on_search_order(handlers) if len(handlers) != 1: @@ -381,7 +384,7 @@ def _get_runner_from_libraries(self, name): return None pre_run_message = None if len(handlers) > 1: - handlers = self._select_best_embedded_match(handlers) + handlers = self._select_best_match(handlers) if len(handlers) > 1: handlers, pre_run_message = self._filter_stdlib_handler(handlers) if len(handlers) > 1: @@ -451,7 +454,7 @@ def _get_explicit_runner(self, name): handler, kw_name = handlers_and_names[0] else: handlers = [h for h, n in handlers_and_names] - matches = self._select_best_embedded_match(handlers) + matches = self._select_best_match(handlers) if len(matches) > 1: self._raise_multiple_keywords_found(handlers, name, implicit=False) handler, kw_name = handlers_and_names[handlers.index(matches[0])] From b1fbfa21287a6b5870e9600044a97644722e17c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 16 Sep 2022 14:28:02 +0300 Subject: [PATCH 0193/1592] Resolve another conflict with embedded args (#4454). Find "best matches" instead of a single "best match". This allows search order to resolve the remaining conflict. --- .../embedded_arguments_conflicts/library2.py | 5 +++ .../resource2.resource | 3 ++ src/robot/running/namespace.py | 40 ++++++++++--------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/library2.py b/atest/testdata/keywords/embedded_arguments_conflicts/library2.py index 34fc74d3b01..9a7186f3345 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/library2.py +++ b/atest/testdata/keywords/embedded_arguments_conflicts/library2.py @@ -7,6 +7,11 @@ def match_in_both_libraries(match, both): assert both == 'both' +@keyword('${match} libraries') +def match_libraries(match): + assert False + + @keyword('Unresolvable ${conflict} in library') def unresolvable_conflict_in_library(conflict): assert False diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource index b07e4e977de..2f6cdbd79a0 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource @@ -3,6 +3,9 @@ ${match} in ${both} resources Should be equal ${match} Match Should be equal ${both} both +${match} resources + Fail Should not be run due to being worse match than above + Unresolvable ${conflict} in resource Fail Should not be run due to conflict diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index f1a56477ae3..fac969ee1c9 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -319,7 +319,7 @@ def _get_runner_from_suite_file(self, name): return None handlers = self.user_keywords.handlers.get_handlers(name) if len(handlers) > 1: - handlers = self._select_best_match(handlers) + handlers = self._select_best_matches(handlers) if len(handlers) > 1: self._raise_multiple_keywords_found(handlers, name) runner = handlers[0].create_runner(name, self.languages) @@ -336,25 +336,27 @@ def _get_runner_from_suite_file(self, name): runner.pre_run_messages += Message(message, level='WARN'), return runner - def _select_best_match(self, handlers): - # "Normal" match is considered exact and wins over embedded matches. + def _select_best_matches(self, handlers): + # "Normal" matches are considered exact and win over embedded matches. normal = [hand for hand in handlers if not hand.supports_embedded_args] if normal: - return normal if len(normal) == 1 else handlers - for hand in handlers: - if self._is_best_embedded_match(hand, handlers): - return [hand] - return handlers + return normal + matches = [hand for hand in handlers + if not self._is_worse_match_than_others(hand, handlers)] + return matches or handlers - def _is_best_embedded_match(self, candidate, alternatives): - # Embedded match is considered better than another if it doesn't match - # the other but the other matches it. + def _is_worse_match_than_others(self, candidate, alternatives): for other in alternatives: - if candidate is other: - continue - if candidate.matches(other.name) or not other.matches(candidate.name): - return False - return True + if (candidate is not other + and self._is_better_match(other, candidate) + and not self._is_better_match(candidate, other)): + return True + return False + + def _is_better_match(self, candidate, other): + # Embedded match is considered better than another if the other matches + # it, but it doesn't match the other. + return other.matches(candidate.name) and not candidate.matches(other.name) def _exists_in_resource_file(self, name, source): for resource in self.resources.values(): @@ -370,7 +372,7 @@ def _get_runner_from_resource_files(self, name): if len(handlers) > 1: handlers = self._prioritize_same_file_or_public(handlers) if len(handlers) > 1: - handlers = self._select_best_match(handlers) + handlers = self._select_best_matches(handlers) if len(handlers) > 1: handlers = self._filter_based_on_search_order(handlers) if len(handlers) != 1: @@ -384,7 +386,7 @@ def _get_runner_from_libraries(self, name): return None pre_run_message = None if len(handlers) > 1: - handlers = self._select_best_match(handlers) + handlers = self._select_best_matches(handlers) if len(handlers) > 1: handlers, pre_run_message = self._filter_stdlib_handler(handlers) if len(handlers) > 1: @@ -454,7 +456,7 @@ def _get_explicit_runner(self, name): handler, kw_name = handlers_and_names[0] else: handlers = [h for h, n in handlers_and_names] - matches = self._select_best_match(handlers) + matches = self._select_best_matches(handlers) if len(matches) > 1: self._raise_multiple_keywords_found(handlers, name, implicit=False) handler, kw_name = handlers_and_names[handlers.index(matches[0])] From 63f06ea805c1d182b992f3bb81ba7ff56029ea82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 16 Sep 2022 18:26:21 +0300 Subject: [PATCH 0194/1592] Test that exact match wins over embedded matches regardless search order. To some extend related to #4454. --- .../builtin/set_library_search_order.robot | 2 ++ .../builtin/set_resource_search_order.robot | 3 +++ .../builtin/set_library_search_order/TestLibrary.py | 6 +++--- .../builtin/set_library_search_order/embedded.py | 13 +++++++++++++ .../setting_library_order.robot | 13 ++++++++++--- .../set_resource_search_order/embedded.resource | 3 +++ .../set_resource_search_order/resource1.robot | 2 +- .../setting_resource_order.robot | 5 +++++ 8 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 atest/testdata/standard_libraries/builtin/set_library_search_order/embedded.py create mode 100644 atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource diff --git a/atest/robot/standard_libraries/builtin/set_library_search_order.robot b/atest/robot/standard_libraries/builtin/set_library_search_order.robot index 6bc2e12bf8e..2b2d940909f 100644 --- a/atest/robot/standard_libraries/builtin/set_library_search_order.robot +++ b/atest/robot/standard_libraries/builtin/set_library_search_order.robot @@ -39,3 +39,5 @@ Library Search Order Is Space Insensitive Library Search Order Is Case Insensitive Check Test Case ${TEST NAME} +Exact match wins over match containing embedded arguments regardless search order + Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/set_resource_search_order.robot b/atest/robot/standard_libraries/builtin/set_resource_search_order.robot index d162ecd82b5..af80b4adcac 100644 --- a/atest/robot/standard_libraries/builtin/set_resource_search_order.robot +++ b/atest/robot/standard_libraries/builtin/set_resource_search_order.robot @@ -38,3 +38,6 @@ Resource Search Order Is Case Insensitive Default Resource Order Should Be Suite Specific Check Test Case ${TEST NAME} + +Exact match wins over match containing embedded arguments regardless search order + Check Test Case ${TEST NAME} diff --git a/atest/testdata/standard_libraries/builtin/set_library_search_order/TestLibrary.py b/atest/testdata/standard_libraries/builtin/set_library_search_order/TestLibrary.py index f0280e3b042..2976efc4391 100644 --- a/atest/testdata/standard_libraries/builtin/set_library_search_order/TestLibrary.py +++ b/atest/testdata/standard_libraries/builtin/set_library_search_order/TestLibrary.py @@ -1,13 +1,13 @@ class TestLibrary: - + def __init__(self, name='TestLibrary'): self.name = name - + def get_name(self): return self.name get_library_name = get_name def no_operation(self): - raise AssertionError("No operation used in %s!" % self.name) + return self.name diff --git a/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded.py b/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded.py new file mode 100644 index 00000000000..6dd0927d1c8 --- /dev/null +++ b/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded.py @@ -0,0 +1,13 @@ +from robot.api.deco import keyword + + +@keyword('No ${Ope}ration') +def no_operation(ope): + raise AssertionError('Should not be run due to keywords with normal ' + 'arguments having higher precedence.') + + +@keyword('Get ${Name}') +def get_name(name): + raise AssertionError('Should not be run due to keywords with normal ' + 'arguments having higher precedence.') diff --git a/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot b/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot index 7832a7fbf95..bfe42e937ba 100644 --- a/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot +++ b/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot @@ -4,6 +4,7 @@ Library TestLibrary.py Library1 WITH NAME Library1 Library TestLibrary.py Library2 WITH NAME Library2 Library TestLibrary.py Library3 WITH NAME Library3 Library TestLibrary.py Library With Space WITH NAME Library With Space +Library embedded.py *** Test Cases *** Library Order Set In Suite Setup Should Be Available In Test Cases @@ -43,7 +44,8 @@ Setting Library Order Returns Previous Library Order Setting Library Order Allows Setting BuiltIn Library As Default Library Set Library Search Order BuiltIn - No Operation + ${result} = No Operation + Should Be Equal ${result} ${NONE} Setting Library Order Allows Setting Own Library Before BuiltIn Library Set Library Search Order Library1 @@ -61,6 +63,10 @@ Library Search Order Is Case Insensitive Set Library Search Order library3 Library1 Active Library Should Be Library3 +Exact match wins over match containing embedded arguments regardless search order + Set Library Search Order embedded Library1 + Active Library Should Be Library1 + *** Keywords *** Active Library Should Be [Arguments] ${expected} @@ -68,5 +74,6 @@ Active Library Should Be Should Be Equal ${name} ${expected} Own Library Should Be Used - [Arguments] ${name} - Run Keyword And Expect Error No operation used in ${name}! No operation + [Arguments] ${expected} + ${name} = No Operation + Should Be Equal ${name} ${expected} diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource new file mode 100644 index 00000000000..0a5dbd8028a --- /dev/null +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource @@ -0,0 +1,3 @@ +*** Keywords *** +Get ${Name:\w+} + Fail Should not be run due to keywords with normal arguments having higher precedence diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot b/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot index eaecdb34bd7..7cb1194dc78 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot @@ -1,3 +1,3 @@ *** Keywords *** Get Name - [Return] resource1 + RETURN resource1 diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot b/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot index 95c7ae1e767..e66bea35cb5 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot @@ -2,6 +2,7 @@ Suite Setup Set Library Search Order resource1 resource2 Resource resource1.robot Resource resource2.robot +Resource embedded.resource Library ../set_library_search_order/TestLibrary.py Library ../set_library_search_order/TestLibrary.py AnotherLibrary WITH NAME AnotherLibrary @@ -57,6 +58,10 @@ Resource Search Order Is Case Insensitive Set Library Search Order Resource1 resource2 Active Resource Should Be resource1 +Exact match wins over match containing embedded arguments regardless search order + Set Library Search Order embedded resource1 + Active Resource Should Be resource1 + *** Keywords *** Active Resource Should Be [Arguments] ${expected} From ef86dd220d1cfbe9f1314e76aec7e04df139429c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 16 Sep 2022 20:33:14 +0300 Subject: [PATCH 0195/1592] Add pathlib.Path support to OperatingSystem #4455 --- .../create_and_remove_dir.robot | 3 ++ .../operating_system/create_file.robot | 3 ++ .../file_and_dir_empty_or_not.robot | 3 ++ .../file_and_dir_existence.robot | 3 ++ .../operating_system/get_file.robot | 3 ++ .../operating_system/get_file_size.robot | 3 ++ .../operating_system/modified_time.robot | 3 ++ .../move_copy_directory.robot | 3 ++ .../operating_system/move_copy_file.robot | 3 ++ .../operating_system/move_copy_files.robot | 3 ++ .../operating_system/path.robot | 3 ++ .../operating_system/path_expansion.robot | 3 ++ .../operating_system/remove_file.robot | 3 ++ .../operating_system/touch.robot | 3 ++ .../wait_until_removed_created.robot | 3 ++ .../create_and_remove_dir.robot | 20 +++++++--- .../operating_system/create_file.robot | 35 +++++++++------- .../file_and_dir_empty_or_not.robot | 9 +++++ .../file_and_dir_existence.robot | 12 ++++++ .../operating_system/get_file.robot | 7 ++++ .../operating_system/get_file_size.robot | 7 +++- .../operating_system/list_dir.robot | 10 +++-- .../operating_system/modified_time.robot | 10 ++++- .../move_copy_directory.robot | 8 ++++ .../operating_system/move_copy_file.robot | 8 ++++ .../operating_system/move_copy_files.robot | 9 +++++ .../operating_system/os_resource.robot | 1 + .../operating_system/path.robot | 6 +++ .../operating_system/path_expansion.robot | 40 ++++++++----------- .../operating_system/remove_file.robot | 9 +++++ .../operating_system/touch.robot | 4 ++ .../wait_until_removed_created.robot | 7 ++++ src/robot/libraries/OperatingSystem.py | 29 +++++++++++--- 33 files changed, 220 insertions(+), 56 deletions(-) diff --git a/atest/robot/standard_libraries/operating_system/create_and_remove_dir.robot b/atest/robot/standard_libraries/operating_system/create_and_remove_dir.robot index 0f3bd03efc5..61631cbb8c0 100644 --- a/atest/robot/standard_libraries/operating_system/create_and_remove_dir.robot +++ b/atest/robot/standard_libraries/operating_system/create_and_remove_dir.robot @@ -35,3 +35,6 @@ Create And Remove Non-ASCII Directory Create And Remove Directory With Space Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/create_file.robot b/atest/robot/standard_libraries/operating_system/create_file.robot index b285e8e4d85..c8ae72205b9 100644 --- a/atest/robot/standard_libraries/operating_system/create_file.robot +++ b/atest/robot/standard_libraries/operating_system/create_file.robot @@ -49,3 +49,6 @@ Creating Binary File Using Unicode With Ordinal > 255 Fails Append To File Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/file_and_dir_empty_or_not.robot b/atest/robot/standard_libraries/operating_system/file_and_dir_empty_or_not.robot index f23f96fc8b3..50b7465af70 100644 --- a/atest/robot/standard_libraries/operating_system/file_and_dir_empty_or_not.robot +++ b/atest/robot/standard_libraries/operating_system/file_and_dir_empty_or_not.robot @@ -50,3 +50,6 @@ File With Space Should Not Be Empty File Should Not Be Empty When File Does Not Exist Check testcase ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/file_and_dir_existence.robot b/atest/robot/standard_libraries/operating_system/file_and_dir_existence.robot index f28d9c5a5c0..566627a37d6 100644 --- a/atest/robot/standard_libraries/operating_system/file_and_dir_existence.robot +++ b/atest/robot/standard_libraries/operating_system/file_and_dir_existence.robot @@ -80,3 +80,6 @@ Directory Should Not Exist With Pattern Matching One Dir Directory Should Not Exist With Pattern Matching Multiple Dirs Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/get_file.robot b/atest/robot/standard_libraries/operating_system/get_file.robot index 9fe431e0c61..6652e40d2a2 100644 --- a/atest/robot/standard_libraries/operating_system/get_file.robot +++ b/atest/robot/standard_libraries/operating_system/get_file.robot @@ -133,3 +133,6 @@ Grep File with 'replace' Error Handler Grep File With Windows line endings ${tc}= Check testcase ${TESTNAME} Check Log Message ${tc.kws[0].kws[0].msgs[1]} 1 out of 5 lines matched + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/get_file_size.robot b/atest/robot/standard_libraries/operating_system/get_file_size.robot index f46fbf746ec..6430e61f3e0 100644 --- a/atest/robot/standard_libraries/operating_system/get_file_size.robot +++ b/atest/robot/standard_libraries/operating_system/get_file_size.robot @@ -19,3 +19,6 @@ Get size of non-existing file Get size of directory Check testcase ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/modified_time.robot b/atest/robot/standard_libraries/operating_system/modified_time.robot index c7681e61db0..713829ffc76 100644 --- a/atest/robot/standard_libraries/operating_system/modified_time.robot +++ b/atest/robot/standard_libraries/operating_system/modified_time.robot @@ -54,3 +54,6 @@ Set And Get Modified Time Of Non-ASCII File Set And Get Modified Time Of File With Spaces In Name Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/move_copy_directory.robot b/atest/robot/standard_libraries/operating_system/move_copy_directory.robot index d1305a74cb0..41a226194cb 100644 --- a/atest/robot/standard_libraries/operating_system/move_copy_directory.robot +++ b/atest/robot/standard_libraries/operating_system/move_copy_directory.robot @@ -32,3 +32,6 @@ Moving Non-Existing Directory Fails Copying Non-Existing Directory Fails Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/move_copy_file.robot b/atest/robot/standard_libraries/operating_system/move_copy_file.robot index c0e3ccec4ce..53090b73880 100644 --- a/atest/robot/standard_libraries/operating_system/move_copy_file.robot +++ b/atest/robot/standard_libraries/operating_system/move_copy_file.robot @@ -91,3 +91,6 @@ Move File returns destination path Copy File returns destination path Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/move_copy_files.robot b/atest/robot/standard_libraries/operating_system/move_copy_files.robot index 839c0a0da23..e81ec7729fc 100644 --- a/atest/robot/standard_libraries/operating_system/move_copy_files.robot +++ b/atest/robot/standard_libraries/operating_system/move_copy_files.robot @@ -74,3 +74,6 @@ Copying From Name With Glob Moving From Name With Glob Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/path.robot b/atest/robot/standard_libraries/operating_system/path.robot index 0c841b3a4c5..b9030c1fcd9 100644 --- a/atest/robot/standard_libraries/operating_system/path.robot +++ b/atest/robot/standard_libraries/operating_system/path.robot @@ -34,3 +34,6 @@ Non-ASCII With Space Check testcase ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/path_expansion.robot b/atest/robot/standard_libraries/operating_system/path_expansion.robot index 4c1fe51f220..c135b890e1e 100644 --- a/atest/robot/standard_libraries/operating_system/path_expansion.robot +++ b/atest/robot/standard_libraries/operating_system/path_expansion.robot @@ -8,3 +8,6 @@ Tilde in path Tilde and username in path Check testcase ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/remove_file.robot b/atest/robot/standard_libraries/operating_system/remove_file.robot index 7ba1ea58bb6..0bba0cee3dc 100644 --- a/atest/robot/standard_libraries/operating_system/remove_file.robot +++ b/atest/robot/standard_libraries/operating_system/remove_file.robot @@ -31,3 +31,6 @@ Removing Directory As A File Fails Remove file containing glob pattern Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/touch.robot b/atest/robot/standard_libraries/operating_system/touch.robot index 6054e56bb34..6b80b30c39d 100644 --- a/atest/robot/standard_libraries/operating_system/touch.robot +++ b/atest/robot/standard_libraries/operating_system/touch.robot @@ -26,3 +26,6 @@ Touching Directory Fails Touch When Parent Does Not Exist Fails Check testcase ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/operating_system/wait_until_removed_created.robot b/atest/robot/standard_libraries/operating_system/wait_until_removed_created.robot index 585af1e4815..b85175ba1b5 100644 --- a/atest/robot/standard_libraries/operating_system/wait_until_removed_created.robot +++ b/atest/robot/standard_libraries/operating_system/wait_until_removed_created.robot @@ -50,3 +50,6 @@ Wait Until File With Glob Like Name Wait Until Removed File With Glob Like Name Check Test Case ${TESTNAME} + +Path as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/operating_system/create_and_remove_dir.robot b/atest/testdata/standard_libraries/operating_system/create_and_remove_dir.robot index 39f36c28cf3..49106a56963 100644 --- a/atest/testdata/standard_libraries/operating_system/create_and_remove_dir.robot +++ b/atest/testdata/standard_libraries/operating_system/create_and_remove_dir.robot @@ -1,6 +1,6 @@ *** Settings *** -Suite Teardown Remove Base Test Directory Test Setup Create Base Test Directory +Suite Teardown Remove Base Test Directory Resource os_resource.robot *** Test Cases *** @@ -8,8 +8,8 @@ Create Directory Create Directory ${TESTDIR} Directory Should Exist ${TESTDIR} Create Directory ${TESTDIR} - Create Directory ${TESTDIR}${/}sub${/}dirs${/}here - Directory Should Exist ${TESTDIR}${/}sub${/}dirs${/}here + Create Directory ${TESTDIR}/sub/dirs/here + Directory Should Exist ${TESTDIR}/sub/dirs/here Creating Directory Over Existing File Fails [Documentation] FAIL Path '${TESTFILE}' is not a directory. @@ -23,8 +23,8 @@ Remove Directory Should Not Exist ${TESTDIR} Remove Directory Recursively - Create File ${TESTDIR}${/}file.txt - Create File ${TESTDIR}${/}sub${/}file2.txt + Create File ${TESTDIR}/file.txt + Create File ${TESTDIR}/sub/file2.txt Remove Directory ${TESTDIR} Recursive Should Not Exist ${TESTDIR} @@ -34,7 +34,7 @@ Removing Non-Existing Directory Is Ok Removing Non-Empty Directory When Not Recursive Fails [Documentation] FAIL Directory '${TESTDIR}' is not empty. Create Directory ${TESTDIR} - Create File ${TESTDIR}${/}file.txt + Create File ${TESTDIR}/file.txt Remove Directory ${TESTDIR} recursive=no Empty Directory @@ -64,3 +64,11 @@ Create And Remove Directory With Space Directory Should Exist ${WITH SPACE} Remove Directory ${WITH SPACE} Should Not Exist ${WITH SPACE} + +Path as `pathlib.Path` + ${path} = Set Variable ${{pathlib.Path($TESTDIR)}} + Create Directory ${path} + Create File ${path/'file.txt'} + File Should Exist ${TESTDIR}/file.txt + Empty Directory ${path} + Remove Directory ${path} diff --git a/atest/testdata/standard_libraries/operating_system/create_file.robot b/atest/testdata/standard_libraries/operating_system/create_file.robot index c4ef4a7a1c5..ba8ad768548 100644 --- a/atest/testdata/standard_libraries/operating_system/create_file.robot +++ b/atest/testdata/standard_libraries/operating_system/create_file.robot @@ -50,14 +50,14 @@ Create File With Console Encoding Create File With Non-ASCII Name [Template] Create and Verify File - ASCII content file=${NON ASCII} - Спасибо file=${NON ASCII} + ASCII content path=${NON ASCII} + Спасибо path=${NON ASCII} Create File With Space In Name - Create And Verify File file=${WITH SPACE} + Create And Verify File path=${WITH SPACE} Create File To Non-Existing Directory - Create And Verify File file=${TESTDIR}${/}file.txt + Create And Verify File path=${TESTDIR}${/}file.txt Creating File Fails If Encoding Is Incorrect [Documentation] FAIL REGEXP: Unicode(Encode|)Error: .* @@ -95,25 +95,30 @@ Append To File Append To File ${TESTFILE} Lääst läin\n\n UTF-8 Verify File ${TESTFILE} First line${\n}Second line${\n}3${\n}${\n}${\n}Lääst läin${\n}${\n} +Path as `pathlib.Path` + Create And Verify File path=${PATH/'file.txt'} + Append To File ${PATH/'file.txt'} xxx + Create And Verify Binary File Using Bytes path=${PATH/'file.txt'} + *** Keywords *** Create And Verify File - [Arguments] ${content}=content ${encoding}=UTF-8 ${file}=${TESTFILE} ${expected}=${content} - Create File ${file} ${content} ${encoding} - Verify File ${file} ${expected} ${encoding} + [Arguments] ${content}=content ${encoding}=UTF-8 ${path}=${TESTFILE} ${expected}=${content} + Create File ${path} ${content} ${encoding} + Verify File ${path} ${expected} ${encoding} Create And Verify Binary File Using Bytes - [Arguments] ${content} ${file}=${TESTFILE} + [Arguments] ${content}=content ${path}=${TESTFILE} ${content} = Convert To Bytes ${content} - Create Binary File ${file} ${content} - Verify Binary File ${file} ${content} + Create Binary File ${path} ${content} + Verify Binary File ${path} ${content} Create And Verify Binary File Using Unicode - [Arguments] ${content} ${file}=${TESTFILE} - Create Binary File ${file} ${content} + [Arguments] ${content} ${path}=${TESTFILE} + Create Binary File ${path} ${content} ${expected} = Convert To Bytes ${content} - Verify Binary File ${file} ${expected} + Verify Binary File ${path} ${expected} Verify Binary File - [Arguments] ${file} ${expected} - ${content} = Get Binary File ${file} + [Arguments] ${path} ${expected} + ${content} = Get Binary File ${path} Should Be Equal ${content} ${expected} diff --git a/atest/testdata/standard_libraries/operating_system/file_and_dir_empty_or_not.robot b/atest/testdata/standard_libraries/operating_system/file_and_dir_empty_or_not.robot index 9a89facdd76..a0b1a2f4ca6 100644 --- a/atest/testdata/standard_libraries/operating_system/file_and_dir_empty_or_not.robot +++ b/atest/testdata/standard_libraries/operating_system/file_and_dir_empty_or_not.robot @@ -68,6 +68,15 @@ File Should Not Be Empty When File Does Not Exist [Documentation] FAIL File '${NON ASCII}' does not exist. File Should Not Be Empty ${NON ASCII} +Path as `pathlib.Path` + Create Directory ${BASE}/dir + Directory Should Be Empty ${PATH/'dir'} + Create File ${BASE}/dir/file.txt + File Should Be Empty ${PATH/'dir/file.txt'} + Create File ${BASE}/dir/file.txt content + File Should Not Be Empty ${PATH/'dir/file.txt'} + Directory Should Not Be Empty ${PATH/'dir'} + *** Keywords *** Test Directory Should Be Empty [Arguments] ${dir} diff --git a/atest/testdata/standard_libraries/operating_system/file_and_dir_existence.robot b/atest/testdata/standard_libraries/operating_system/file_and_dir_existence.robot index 1b984bb239c..c7537d07487 100644 --- a/atest/testdata/standard_libraries/operating_system/file_and_dir_existence.robot +++ b/atest/testdata/standard_libraries/operating_system/file_and_dir_existence.robot @@ -176,3 +176,15 @@ Directory Should Not Exist With Pattern Matching Multiple Dirs Create Directory ${BASE}/dir Create Directory ${BASE}/another Directory Should Not Exist ${BASE}${/}*r + +Path as `pathlib.Path` + Create File ${BASE}/file.txt + File Should Exist ${PATH/'file.txt'} + Directory Should Not Exist ${PATH/'file.txt'} + Should Exist ${PATH/'file.txt'} + File Should Not Exist ${PATH} + Directory Should Exist ${PATH} + Should Exist ${PATH} + File Should Not Exist ${PATH/'nonex'} + Directory Should Not Exist ${PATH/'nonex'} + Should Not Exist ${PATH/'nonex'} diff --git a/atest/testdata/standard_libraries/operating_system/get_file.robot b/atest/testdata/standard_libraries/operating_system/get_file.robot index 964055a67eb..58ad3ad3511 100644 --- a/atest/testdata/standard_libraries/operating_system/get_file.robot +++ b/atest/testdata/standard_libraries/operating_system/get_file.robot @@ -183,6 +183,13 @@ Grep File With Windows line endings Grep And Check File f*a foo bar ${UTF-8 WINDOWS FILE} Grep And Check File f.*a foo bar ${UTF-8 WINDOWS FILE} regexp=${True} +Path as `pathlib.Path` + Create File ${BASE}/file.txt content\nthree\nlines + ${content} = Get File ${PATH/'file.txt'} + Should Be Equal ${content} content\nthree\nlines + ${content} = Grep File ${PATH/'file.txt'} t + Should Be Equal ${content} content\nthree + *** Keywords *** Get And Check File [Arguments] ${path} ${expected} diff --git a/atest/testdata/standard_libraries/operating_system/get_file_size.robot b/atest/testdata/standard_libraries/operating_system/get_file_size.robot index dd1c90d7ca7..ff2d58b71bb 100644 --- a/atest/testdata/standard_libraries/operating_system/get_file_size.robot +++ b/atest/testdata/standard_libraries/operating_system/get_file_size.robot @@ -15,7 +15,7 @@ Get File Size ${size} = Get File Size ${WITH SPACE} Should Be Equal ${size} ${12} ${size} = Get File Size ${CURDIR}/get_file_size.robot - Should Be True 0 < ${size} < 1000 + Should Be True 0 < ${size} < 1111 Get size of non-existing file [Documentation] FAIL File '${EXECDIR}${/}non.ex' does not exist. @@ -24,3 +24,8 @@ Get size of non-existing file Get size of directory [Documentation] FAIL File '${CURDIR}' does not exist. Get File Size ${CURDIR} + +Path as `pathlib.Path` + Create File ${BASE}/file.txt content + ${size} = Get File Size ${PATH/'file.txt'} + Should Be Equal ${size} ${7} diff --git a/atest/testdata/standard_libraries/operating_system/list_dir.robot b/atest/testdata/standard_libraries/operating_system/list_dir.robot index da5e0d10346..c0b39b33ac2 100644 --- a/atest/testdata/standard_libraries/operating_system/list_dir.robot +++ b/atest/testdata/standard_libraries/operating_system/list_dir.robot @@ -59,11 +59,15 @@ Create Test Directories Create File ${BASE}/${F2} List And Count Directory - [Arguments] @{expected} - @{items} = List Directory ${BASE} - ${count} = Count Items In Directory ${BASE} + [Arguments] @{expected} ${directory}=${BASE} + @{items} = List Directory ${directory} + ${count} = Count Items In Directory ${directory} Lists Should Be Equal ${items} ${expected} Length Should Be ${items} ${count} + # Test also with pathlib.Path instance. + IF $directory == $BASE + List And Count Directory @{expected} directory=${PATH} + END List And Count Directory With Pattern [Arguments] ${pattern} @{expected} diff --git a/atest/testdata/standard_libraries/operating_system/modified_time.robot b/atest/testdata/standard_libraries/operating_system/modified_time.robot index c712ebaedc7..f5ddaa557bf 100644 --- a/atest/testdata/standard_libraries/operating_system/modified_time.robot +++ b/atest/testdata/standard_libraries/operating_system/modified_time.robot @@ -39,7 +39,7 @@ Get Modified Time Fails When Path Does Not Exist Set Modified Time Using Epoch [Documentation] FAIL ValueError: Epoch time must be positive (got -1). Create File ${TESTFILE} - ${epoch} = Evaluate 1542892422.0 + time.timezone modules=time + ${epoch} = Evaluate 1542892422.0 + time.timezone Set Modified Time ${TESTFILE} ${epoch} ${mtime} = Get Modified Time ${TESTFILE} Should Be Equal ${mtime} 2018-11-22 13:13:42 @@ -47,7 +47,7 @@ Set Modified Time Using Epoch Set Modified Time Using Timestamp Create File ${TESTFILE} - ${expected} = Evaluate 1542892422.0 + time.timezone modules=time + ${expected} = Evaluate 1542892422.0 + time.timezone FOR ${timestamp} IN 2018-11-22 13:13:42 20181122 13:13:42 ... 20181122 131342 20181122-131342 2018-11-22 13:13:42.456 Set Modified Time ${TESTFILE} ${timestamp} @@ -108,3 +108,9 @@ Set And Get Modified Time Of File With Spaces In Name Set Modified Time ${WITH SPACE} 2010-09-26 21:24 ${time} = Get Modified Time ${WITH SPACE} Should Be Equal ${time} 2010-09-26 21:24:00 + +Path as `pathlib.Path` + Create File ${BASE}/file.txt + Set Modified Time ${PATH/'file.txt'} 2022-09-16 19:41:12 + ${time} = Get Modified Time ${PATH/'file.txt'} + Should Be Equal ${time} 2022-09-16 19:41:12 diff --git a/atest/testdata/standard_libraries/operating_system/move_copy_directory.robot b/atest/testdata/standard_libraries/operating_system/move_copy_directory.robot index 8bf88fab849..03d7b313e62 100644 --- a/atest/testdata/standard_libraries/operating_system/move_copy_directory.robot +++ b/atest/testdata/standard_libraries/operating_system/move_copy_directory.robot @@ -82,6 +82,14 @@ Copying Non-Existing Directory Fails [Documentation] FAIL Source '${EXECDIR}${/}non-existing-dir' does not exist. Copy Directory non-existing-dir whatever +Path as `pathlib.Path` + Create Directory ${BASE}/dir + Move Directory ${PATH/'dir'} ${PATH/'new'} + Copy Directory ${PATH/'new'} ${PATH/'copy'} + Directory Should Not Exist ${BASE}/dir + Directory Should Exist ${BASE}/new + Directory Should Exist ${BASE}/copy + *** Keywords *** Remove Just Name Dirs Remove Directory rf_test_1 recursive diff --git a/atest/testdata/standard_libraries/operating_system/move_copy_file.robot b/atest/testdata/standard_libraries/operating_system/move_copy_file.robot index f995d880874..36209b7301b 100644 --- a/atest/testdata/standard_libraries/operating_system/move_copy_file.robot +++ b/atest/testdata/standard_libraries/operating_system/move_copy_file.robot @@ -167,3 +167,11 @@ Copy File returns destination path Should Be Equal ${path} ${BASE}${/}new.txt ${path} = Copy File ${BASE}/f*.txt ${BASE}/dir Should Be Equal ${path} ${BASE}${/}dir${/}file.txt + +Path as `pathlib.Path` + Create File ${BASE}/file + Move File ${PATH/'file'} ${PATH/'new'} + Copy File ${PATH/'new'} ${PATH/'copy'} + File Should Not Exist ${BASE}/file + File Should Exist ${BASE}/new + File Should Exist ${BASE}/copy diff --git a/atest/testdata/standard_libraries/operating_system/move_copy_files.robot b/atest/testdata/standard_libraries/operating_system/move_copy_files.robot index 04b0799a8cf..a7e4cdb3de1 100644 --- a/atest/testdata/standard_libraries/operating_system/move_copy_files.robot +++ b/atest/testdata/standard_libraries/operating_system/move_copy_files.robot @@ -178,6 +178,15 @@ Moving From Name With Glob Move Files ${SOURCE GLOB}/${GLOB FILE} ${DEST} Directory Should Have Items ${DEST} ${GLOB FILE} +Path as `pathlib.Path` + Move Files ${{pathlib.Path($SOURCE)/'movecopy-*.txt'}} ${{pathlib.Path($DEST)}} + Directory Should Have Items ${DEST} movecopy-one.txt + Remove Values From List ${SOURCE FILES} movecopy-one.txt + Directory Should Have Items ${SOURCE} @{SOURCE FILES} + Copy Files ${{pathlib.Path($DEST)/'*.txt'}} ${{pathlib.Path($DEST)/'new'}} + Directory Should Have Items ${DEST}/new movecopy-one.txt + Directory Should Have Items ${DEST} movecopy-one.txt new + *** Keywords *** Create Test Files For Multi-file Operations Create Directory ${SOURCE} diff --git a/atest/testdata/standard_libraries/operating_system/os_resource.robot b/atest/testdata/standard_libraries/operating_system/os_resource.robot index 27c8ea6c78a..e9dbf70d47b 100644 --- a/atest/testdata/standard_libraries/operating_system/os_resource.robot +++ b/atest/testdata/standard_libraries/operating_system/os_resource.robot @@ -5,6 +5,7 @@ Library String *** Variables *** ${BASE} %{TEMPDIR}${/}robot-os-tests +${PATH} ${{pathlib.Path($BASE)}} ${TESTFILE SHORT NAME} f1.txt ${TESTFILE} ${BASE}${/}${TESTFILE SHORT NAME} ${TESTFILE 2 SHORT NAME} f2.txt diff --git a/atest/testdata/standard_libraries/operating_system/path.robot b/atest/testdata/standard_libraries/operating_system/path.robot index 97bcefc16c9..9c7369eb683 100644 --- a/atest/testdata/standard_libraries/operating_system/path.robot +++ b/atest/testdata/standard_libraries/operating_system/path.robot @@ -109,6 +109,12 @@ With Space Split Path And Check with space/and another with space and another Split Extension And Check with space.and another with space and another +Path as `pathlib.Path` + Join Path And Check foo/bar ${{pathlib.Path('foo')}} ${{pathlib.Path('bar')}} + Normalize Path And Check ${{pathlib.Path('foo/../bar')}} bar + Split Path And Check ${{pathlib.Path('foo/bar')}} foo bar + Split Extension And Check ${{pathlib.Path('foo.bar')}} foo bar + *** Keywords *** Join Path And Check [Arguments] ${expected} @{inputs} diff --git a/atest/testdata/standard_libraries/operating_system/path_expansion.robot b/atest/testdata/standard_libraries/operating_system/path_expansion.robot index d82676f7120..aad2fb3162b 100644 --- a/atest/testdata/standard_libraries/operating_system/path_expansion.robot +++ b/atest/testdata/standard_libraries/operating_system/path_expansion.robot @@ -18,32 +18,26 @@ Tilde and username in path Should Be Equal ${path} ${home}${/}foo Directory Should Exist ~${user} +Path as `pathlib.Path` + ${path} = Normalize Path ${{pathlib.Path('~/foo')}} + ${home} = Get Home + Should Be Equal ${path} ${home}${/}foo + Directory Should Exist ${{pathlib.Path('~')}} + *** Keywords *** Get Home [Arguments] ${case_normalize}=False - ${home} = Run Keyword If ${WINDOWS} - ... Get Windows Home - ... ELSE - ... Get Posix Home + IF ${WINDOWS} + ${home} = Get Environment Variable USERPROFILE %{HOMEDRIVE}%{HOMEPATH} + ELSE + ${home} = Get Environment Variable HOME + END ${home} = Normalize Path ${home} ${case_normalize} - [Return] ${home} - -Get Windows Home - ${home} = Get Environment Variable USERPROFILE %{HOMEDRIVE}%{HOMEPATH} - [Return] ${home} - -Get Posix Home - [Return] %{HOME} + RETURN ${home} Get User - ${user} = Run Keyword If ${WINDOWS} - ... Get Windows User - ... ELSE - ... Get Posix User - [Return] ${user} - -Get Windows User - [Return] %{USERNAME} - -Get Posix User - [Return] %{USER} + IF ${WINDOWS} + RETURN %{USERNAME} + ELSE + RETURN %{USER} + END diff --git a/atest/testdata/standard_libraries/operating_system/remove_file.robot b/atest/testdata/standard_libraries/operating_system/remove_file.robot index e84065c64a5..a0afbab568e 100644 --- a/atest/testdata/standard_libraries/operating_system/remove_file.robot +++ b/atest/testdata/standard_libraries/operating_system/remove_file.robot @@ -61,3 +61,12 @@ Remove file containing glob pattern File Should Exist ${BASE}/[foo]bar.txt Remove File ${BASE}/[foo]bar.txt Should Not Exist ${BASE}/[foo]bar.txt + +Path as `pathlib.Path` + Create File ${BASE}/file1.txt + Create File ${BASE}/file2.txt + Create File ${BASE}/file3.txt + Create File ${BASE}/file4.txt + Remove File ${PATH/'file1.txt'} + Remove Files ${PATH/'file2.txt'} ${PATH/'file[34].txt'} + Should Not Exist ${BASE}/file*.txt diff --git a/atest/testdata/standard_libraries/operating_system/touch.robot b/atest/testdata/standard_libraries/operating_system/touch.robot index 8a420f43f69..81f19cc761a 100644 --- a/atest/testdata/standard_libraries/operating_system/touch.robot +++ b/atest/testdata/standard_libraries/operating_system/touch.robot @@ -36,6 +36,10 @@ Touch When Parent Does Not Exist Fails Directory Should Not Exist ${TESTDIR} Touch ${TESTDIR}/file.txt +Path as `pathlib.Path` + Touch ${PATH/'file.txt'} + File Should Be Empty ${BASE}/file.txt + *** Keywords *** Remove Temps Remove File ${TESTFILE} diff --git a/atest/testdata/standard_libraries/operating_system/wait_until_removed_created.robot b/atest/testdata/standard_libraries/operating_system/wait_until_removed_created.robot index 98b7811b4f5..0d134214dff 100644 --- a/atest/testdata/standard_libraries/operating_system/wait_until_removed_created.robot +++ b/atest/testdata/standard_libraries/operating_system/wait_until_removed_created.robot @@ -98,6 +98,13 @@ Wait Until Removed File With Glob Like Name Create Items Wait Until Removed ${FILE WITH GLOB} 0.042 +Path as `pathlib.Path` + Create Items + Remove After Sleeping ${FILE} + Wait Until Removed ${{pathlib.Path($FILE)}} 5 second + Remove After Sleeping ${DIR} + Wait Until Removed ${{pathlib.Path($DIR)}} 32 seconds 44 millis + *** Keywords *** Remove Items Remove File ${FILE WITH GLOB} diff --git a/src/robot/libraries/OperatingSystem.py b/src/robot/libraries/OperatingSystem.py index e2d0b70862f..943dc930daf 100644 --- a/src/robot/libraries/OperatingSystem.py +++ b/src/robot/libraries/OperatingSystem.py @@ -14,9 +14,10 @@ # limitations under the License. import fnmatch -import re import glob import os +import pathlib +import re import shutil import tempfile import time @@ -67,7 +68,7 @@ class OperatingSystem: = Pattern matching = - Many keywords accepts arguments as either _glob_ or _regular expression_ patterns. + Many keywords accept arguments as either _glob_ or _regular expression_ patterns. == Glob patterns == @@ -107,6 +108,15 @@ class OperatingSystem: operating system dependent, but typically e.g. ``~/robot`` is expanded to ``C:\Users\<user>\robot`` on Windows and ``/home/<user>/robot`` on Unixes. + = ``pathlib.Path`` support = + + Starting from Robot Framework 5.1, arguments representing paths can be given + as [https://docs.python.org/3/library/pathlib.html pathlib.Path] instances + in addition to strings. + + All keywords returning paths return them as strings. This may change in + the future so that the return value type matches the argument type. + = Boolean arguments = Some keywords accept arguments that are handled as Boolean values true or @@ -780,6 +790,8 @@ def _normalize_copy_and_move_source(self, source): return source def _normalize_copy_and_move_destination(self, destination): + if isinstance(destination, pathlib.Path): + destination = str(destination) is_dir = os.path.isdir(destination) or destination.endswith(('/', '\\')) destination = self._absnorm(destination) directory = destination if is_dir else os.path.dirname(destination) @@ -1077,9 +1089,9 @@ def join_path(self, base, *parts): - ${p4} = '/path' - ${p5} = '/my/path2' """ - base = base.replace('/', os.sep) - parts = [p.replace('/', os.sep) for p in parts] - return self.normalize_path(os.path.join(base, *parts)) + parts = [str(p) if isinstance(p, pathlib.Path) else p.replace('/', os.sep) + for p in (base,) + parts] + return self.normalize_path(os.path.join(*parts)) def join_paths(self, base, *paths): """Joins given paths with base and returns resulted paths. @@ -1105,6 +1117,7 @@ def normalize_path(self, path, case_normalize=False): - Replaces initial ``~`` or ``~user`` by that user's home directory. - If ``case_normalize`` is given a true value (see `Boolean arguments`) on Windows, converts the path to all lowercase. + - Converts ``pathlib.Path`` instances to ``str``. Examples: | ${path1} = | Normalize Path | abc/ | @@ -1120,7 +1133,11 @@ def normalize_path(self, path, case_normalize=False): On Windows result would use ``\\`` instead of ``/`` and home directory would be different. """ - path = os.path.normpath(os.path.expanduser(path.replace('/', os.sep))) + if isinstance(path, pathlib.Path): + path = str(path) + else: + path = path.replace('/', os.sep) + path = os.path.normpath(os.path.expanduser(path)) # os.path.normcase doesn't normalize on OSX which also, by default, # has case-insensitive file system. Our robot.utils.normpath would # do that, but it's not certain would that, or other things that the From 26e267c5e4b44feb8567fba3b788e382154d6678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 16 Sep 2022 21:07:15 +0300 Subject: [PATCH 0196/1592] Add pathlib.Path support to Process #4455 --- .../standard_libraries/process/stdin.robot | 5 +++- .../process/stdout_and_stderr.robot | 6 +++++ .../process/process_library.robot | 2 +- .../process/process_resource.robot | 1 + .../standard_libraries/process/stdin.robot | 17 ++++++++----- .../process/stdout_and_stderr.robot | 8 ++++++ src/robot/libraries/Process.py | 25 ++++++++----------- 7 files changed, 41 insertions(+), 23 deletions(-) diff --git a/atest/robot/standard_libraries/process/stdin.robot b/atest/robot/standard_libraries/process/stdin.robot index 4b9b65fcf63..a89ccf75b6e 100644 --- a/atest/robot/standard_libraries/process/stdin.robot +++ b/atest/robot/standard_libraries/process/stdin.robot @@ -16,7 +16,10 @@ Stdin can be disabled Stdin can be disabled with None object Check Test Case ${TESTNAME} -Stdin as file +Stdin as path + Check Test Case ${TESTNAME} + +Stdin as `pathlib.Path` Check Test Case ${TESTNAME} Stdin as text diff --git a/atest/robot/standard_libraries/process/stdout_and_stderr.robot b/atest/robot/standard_libraries/process/stdout_and_stderr.robot index 606d115bc58..213337a3265 100644 --- a/atest/robot/standard_libraries/process/stdout_and_stderr.robot +++ b/atest/robot/standard_libraries/process/stdout_and_stderr.robot @@ -9,12 +9,18 @@ Default stdout and stderr Custom stdout Check Test Case ${TESTNAME} +Custom stdout as `pathlib.Path` + Check Test Case ${TESTNAME} + Redirecting stdout to DEVNULL Check Test Case ${TESTNAME} Custom stderr Check Test Case ${TESTNAME} +Custom stderr as `pathlib.Path` + Check Test Case ${TESTNAME} + Custom stdout and stderr Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/process/process_library.robot b/atest/testdata/standard_libraries/process/process_library.robot index da0b5ec52f0..a7fe9108728 100644 --- a/atest/testdata/standard_libraries/process/process_library.robot +++ b/atest/testdata/standard_libraries/process/process_library.robot @@ -20,7 +20,7 @@ Start And Wait Process Change Current Working Directory ${result}= Run Process python -c import os; print(os.path.abspath(os.curdir)) cwd=. - ${result2}= Run Process python -c import os; print(os.path.abspath(os.curdir)) cwd=.. + ${result2}= Run Process python -c import os; print(os.path.abspath(os.curdir)) cwd=${{pathlib.Path('..')}} Should Not Be Equal ${result.stdout} ${result2.stdout} Running a process in a shell diff --git a/atest/testdata/standard_libraries/process/process_resource.robot b/atest/testdata/standard_libraries/process/process_resource.robot index b76819b624c..bdaea17db53 100644 --- a/atest/testdata/standard_libraries/process/process_resource.robot +++ b/atest/testdata/standard_libraries/process/process_resource.robot @@ -11,6 +11,7 @@ ${TEMPFILE} %{TEMPDIR}${/}terminate-process-temp.txt ${STARTED} %{TEMPDIR}${/}some-process-started.txt ${STDOUT} %{TEMPDIR}/process-stdout-file.txt ${STDERR} %{TEMPDIR}/process-stderr-file.txt +${STDIN} %{TEMPDIR}/process-stdin-file.txt ${CWD} %{TEMPDIR}/process-cwd *** Keywords *** diff --git a/atest/testdata/standard_libraries/process/stdin.robot b/atest/testdata/standard_libraries/process/stdin.robot index 04781777e55..bea3a048ac1 100644 --- a/atest/testdata/standard_libraries/process/stdin.robot +++ b/atest/testdata/standard_libraries/process/stdin.robot @@ -1,6 +1,5 @@ *** Settings *** -Library OperatingSystem -Library Process +Resource process_resource.robot *** Test Cases *** Stdin is PIPE by defauls @@ -38,11 +37,17 @@ Stdin can be disabled with None object Should Be Equal ${process.stdin} ${None} Should Be Equal ${result.stdout} Hello, world! -Stdin as file - Create File %{TEMPDIR}/stdin.txt Hyvää päivää maailma! encoding=CONSOLE - ${result} = Run Process python -c import sys; print(sys.stdin.read()) stdin=%{TEMPDIR}/stdin.txt +Stdin as path + Create File ${STDIN} Hyvää päivää maailma! encoding=CONSOLE + ${result} = Run Process python -c import sys; print(sys.stdin.read()) stdin=${STDIN} Should Be Equal ${result.stdout} Hyvää päivää maailma! - [Teardown] Remove File %{TEMPDIR}/stdin.txt + [Teardown] Remove File ${STDIN} + +Stdin as `pathlib.Path` + Create File ${STDIN} Hyvää päivää maailma! encoding=CONSOLE + ${result} = Run Process python -c import sys; print(sys.stdin.read()) stdin=${{pathlib.Path($STDIN)}} + Should Be Equal ${result.stdout} Hyvää päivää maailma! + [Teardown] Remove File ${STDIN} Stdin as text ${result} = Run Process python -c import sys; print(sys.stdin.read()) stdin=Hyvää päivää maailma! diff --git a/atest/testdata/standard_libraries/process/stdout_and_stderr.robot b/atest/testdata/standard_libraries/process/stdout_and_stderr.robot index c08278755f8..baf5ccff6cf 100644 --- a/atest/testdata/standard_libraries/process/stdout_and_stderr.robot +++ b/atest/testdata/standard_libraries/process/stdout_and_stderr.robot @@ -11,6 +11,10 @@ Custom stdout ${result} = Run Stdout Stderr Process stdout=${STDOUT} Result Should Equal ${result} stdout stderr stdout_path=${STDOUT} +Custom stdout as `pathlib.Path` + ${result} = Run Stdout Stderr Process stdout=${{pathlib.Path($STDOUT)}} + Result Should Equal ${result} stdout stderr stdout_path=${STDOUT} + Redirecting stdout to DEVNULL ${result} = Run Stdout Stderr Process stdout=DEVNULL Should Not Exist ${EXECDIR}/DEVNULL @@ -22,6 +26,10 @@ Custom stderr ${result} = Run Stdout Stderr Process stderr=${STDERR} Result Should Equal ${result} stdout stderr stderr_path=${STDERR} +Custom stderr as `pathlib.Path` + ${result} = Run Stdout Stderr Process stderr=${{pathlib.Path($STDERR)}} + Result Should Equal ${result} stdout stderr stderr_path=${STDERR} + Custom stdout and stderr ${result} = Run Stdout Stderr Process stdout=${STDOUT} stderr=${STDERR} Result Should Equal ${result} stdout stderr stdout_path=${STDOUT} stderr_path=${STDERR} diff --git a/src/robot/libraries/Process.py b/src/robot/libraries/Process.py index 07c74b221d0..a9961a78607 100644 --- a/src/robot/libraries/Process.py +++ b/src/robot/libraries/Process.py @@ -14,14 +14,14 @@ # limitations under the License. import os +import signal as signal_module import subprocess import time from tempfile import TemporaryFile -import signal as signal_module from robot.utils import (abspath, cmdline2list, ConnectionCache, console_decode, - console_encode, is_list_like, is_string, is_truthy, - NormalizedDict, secs_to_timestr, system_decode, + console_encode, is_list_like, is_pathlike, is_string, + is_truthy, NormalizedDict, secs_to_timestr, system_decode, system_encode, timestr_to_secs, WINDOWS) from robot.version import get_version from robot.api import logger @@ -883,7 +883,7 @@ class ProcessConfiguration: def __init__(self, cwd=None, shell=False, stdout=None, stderr=None, stdin='PIPE', output_encoding='CONSOLE', alias=None, env=None, **rest): - self.cwd = self._get_cwd(cwd) + self.cwd = os.path.normpath(cwd) if cwd else abspath('.') self.shell = is_truthy(shell) self.alias = alias self.output_encoding = output_encoding @@ -892,11 +892,6 @@ def __init__(self, cwd=None, shell=False, stdout=None, stderr=None, stdin='PIPE' self.stdin_stream = self._get_stdin(stdin) self.env = self._construct_env(env, rest) - def _get_cwd(self, cwd): - if cwd: - return cwd.replace('/', os.sep) - return abspath('.') - def _new_stream(self, name): if name == 'DEVNULL': return open(os.devnull, 'w') @@ -913,19 +908,19 @@ def _get_stderr(self, stderr, stdout, stdout_stream): return self._new_stream(stderr) def _get_stdin(self, stdin): - if not is_string(stdin): + if is_pathlike(stdin): + stdin = str(stdin) + elif not is_string(stdin): return stdin - if stdin.upper() == 'NONE': + elif stdin.upper() == 'NONE': return None - if stdin == 'PIPE': + elif stdin == 'PIPE': return subprocess.PIPE path = os.path.normpath(os.path.join(self.cwd, stdin)) if os.path.isfile(path): return open(path) stdin_file = TemporaryFile() - if is_string(stdin): - stdin = console_encode(stdin, self.output_encoding, force=True) - stdin_file.write(stdin) + stdin_file.write(console_encode(stdin, self.output_encoding, force=True)) stdin_file.seek(0) return stdin_file From f43b23f1091c7b53f187c17b65e56d25966b80fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 16 Sep 2022 21:26:23 +0300 Subject: [PATCH 0197/1592] Add pathlib.Path support to XML #4455. Parsing alraedy worked with pathlib.Path but saving didn't. --- atest/robot/standard_libraries/xml/save_xml.robot | 3 +++ .../robot/standard_libraries/xml/save_xml_with_lxml.robot | 3 +++ atest/testdata/standard_libraries/xml/save_xml.robot | 4 ++++ .../standard_libraries/xml/save_xml_with_lxml.robot | 4 ++++ src/robot/libraries/XML.py | 8 ++++---- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/atest/robot/standard_libraries/xml/save_xml.robot b/atest/robot/standard_libraries/xml/save_xml.robot index 0619adf3858..90057fba72f 100644 --- a/atest/robot/standard_libraries/xml/save_xml.robot +++ b/atest/robot/standard_libraries/xml/save_xml.robot @@ -24,6 +24,9 @@ Save Non-ASCII XML Save Non-ASCII XML Using Custom Encoding Check Test Case ${TESTNAME} +Save to `pathlib.Path` + Check Test Case ${TESTNAME} + Save to Invalid File Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/xml/save_xml_with_lxml.robot b/atest/robot/standard_libraries/xml/save_xml_with_lxml.robot index cb16dfdba7a..00878bc2046 100644 --- a/atest/robot/standard_libraries/xml/save_xml_with_lxml.robot +++ b/atest/robot/standard_libraries/xml/save_xml_with_lxml.robot @@ -25,6 +25,9 @@ Save Non-ASCII XML Save Non-ASCII XML Using Custom Encoding Check Test Case ${TESTNAME} +Save to `pathlib.Path` + Check Test Case ${TESTNAME} + Save to Invalid File Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/xml/save_xml.robot b/atest/testdata/standard_libraries/xml/save_xml.robot index 134742b6ed2..7d7f12bfb00 100644 --- a/atest/testdata/standard_libraries/xml/save_xml.robot +++ b/atest/testdata/standard_libraries/xml/save_xml.robot @@ -34,6 +34,10 @@ Save Non-ASCII XML Using Custom Encoding Save XML ${NON-ASCII} ${OUTPUT} ISO-8859-1 XML Content Should Be ${NON-ASCII} ISO-8859-1 +Save to `pathlib.Path` + Save XML ${SIMPLE} ${{pathlib.Path($OUTPUT)}} + XML Content Should Be ${SIMPLE} + Save to Invalid File [Documentation] FAIL REGEXP: (IOError|IsADirectoryError|PermissionError): .* Save XML ${SIMPLE} %{TEMPDIR} diff --git a/atest/testdata/standard_libraries/xml/save_xml_with_lxml.robot b/atest/testdata/standard_libraries/xml/save_xml_with_lxml.robot index b9614b69a28..e7328fa6599 100644 --- a/atest/testdata/standard_libraries/xml/save_xml_with_lxml.robot +++ b/atest/testdata/standard_libraries/xml/save_xml_with_lxml.robot @@ -36,6 +36,10 @@ Save Non-ASCII XML Using Custom Encoding Save XML ${NON-ASCII} ${OUTPUT} ISO-8859-1 XML Content Should Be ${NON-ASCII} ISO-8859-1 +Save to `pathlib.Path` + Save XML ${SIMPLE} ${{pathlib.Path($OUTPUT)}} + XML Content Should Be ${SIMPLE SAVED} + Save to Invalid File [Documentation] FAIL REGEXP: (IOError|IsADirectoryError|PermissionError): .* Save XML ${SIMPLE} %{TEMPDIR} diff --git a/src/robot/libraries/XML.py b/src/robot/libraries/XML.py index 64b890f251d..8d3205f8990 100644 --- a/src/robot/libraries/XML.py +++ b/src/robot/libraries/XML.py @@ -15,7 +15,6 @@ import copy import os -import pathlib import re try: @@ -517,7 +516,7 @@ def parse_xml(self, source, keep_clark_notation=False, strip_namespaces=False): the whole structure. See `Parsing XML` section for more details and examples. """ - if isinstance(source, pathlib.Path): + if isinstance(source, os.PathLike): source = str(source) with ETSource(source) as source: tree = self.etree.parse(source) @@ -589,7 +588,7 @@ def get_elements(self, source, xpath): | ${children} = | Get Elements | ${XML} | first/child | | Should Be Empty | ${children} | | | """ - if is_string(source) or is_bytes(source) or isinstance(source, pathlib.Path): + if isinstance(source, (str, bytes, os.PathLike)): source = self.parse_xml(source) finder = ElementFinder(self.etree, self.modern_etree, self.lxml_etree) return finder.find_all(source, xpath) @@ -1349,7 +1348,8 @@ def save_xml(self, source, path, encoding='UTF-8'): Use `Element To String` if you just need a string representation of the element. """ - path = os.path.abspath(path.replace('/', os.sep)) + path = os.path.abspath(str(path) if isinstance(path, os.PathLike) + else path.replace('/', os.sep)) elem = self.get_element(source) tree = self.etree.ElementTree(elem) config = {'encoding': encoding} From 744a8da053a31f6b291917a7d9673e3ce25fe433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 16 Sep 2022 22:14:07 +0300 Subject: [PATCH 0198/1592] Add `pathlib.Path` support to Screenstot #4455 Also some test data cleanup. --- .../screenshot/set_screenshot_directory.robot | 9 ++-- .../screenshot/take_screenshot.robot | 4 ++ .../screenshot/screenshot_resource.robot | 2 +- .../screenshot/set_screenshot_directory.robot | 20 +++++--- .../screenshot/take_screenshot.robot | 48 +++++++++---------- src/robot/libraries/Screenshot.py | 18 ++++--- 6 files changed, 59 insertions(+), 42 deletions(-) diff --git a/atest/robot/standard_libraries/screenshot/set_screenshot_directory.robot b/atest/robot/standard_libraries/screenshot/set_screenshot_directory.robot index 82c96f17469..9d2c050dc0d 100644 --- a/atest/robot/standard_libraries/screenshot/set_screenshot_directory.robot +++ b/atest/robot/standard_libraries/screenshot/set_screenshot_directory.robot @@ -1,8 +1,11 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} standard_libraries/screenshot/set_screenshot_directory.robot -Force Tags require-screenshot +Suite Setup Run Tests ${EMPTY} standard_libraries/screenshot/set_screenshot_directory.robot +Test Tags require-screenshot Resource atest_resource.robot *** Test Cases *** Set Screenshot Directory - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} + +Set Screenshot Directory as `pathlib.Path` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/screenshot/take_screenshot.robot b/atest/robot/standard_libraries/screenshot/take_screenshot.robot index b626b1fba18..d1a97b97649 100644 --- a/atest/robot/standard_libraries/screenshot/take_screenshot.robot +++ b/atest/robot/standard_libraries/screenshot/take_screenshot.robot @@ -20,6 +20,10 @@ Basename With Extension Turns Off Index Generation Check Embedding In Log ${tc.kws[0].kws[0].msgs[1]} xxx.jpg Check Embedding In Log ${tc.kws[1].kws[0].msgs[1]} yyy.jpeg +Name as `pathlib.Path` + ${tc}= Check Test Case ${TESTNAME} + Check Embedding In Log ${tc.kws[0].msgs[1]} name.jpg + Screenshot Width Can Be Given ${tc}= Check Test Case ${TESTNAME} Check Embedding In Log ${tc.kws[0].msgs[1]} screenshot_1.jpg 300px diff --git a/atest/testdata/standard_libraries/screenshot/screenshot_resource.robot b/atest/testdata/standard_libraries/screenshot/screenshot_resource.robot index 4fb8e720706..e1f3f1761e9 100644 --- a/atest/testdata/standard_libraries/screenshot/screenshot_resource.robot +++ b/atest/testdata/standard_libraries/screenshot/screenshot_resource.robot @@ -18,5 +18,5 @@ Save Start Time Screenshots Should Exist [Arguments] ${directory} @{files} - @{actual files}= List Directory ${directory} *.jp*g absolute + @{actual files}= List Directory ${directory} *.jp*g Lists Should Be Equal ${actual files} ${files} diff --git a/atest/testdata/standard_libraries/screenshot/set_screenshot_directory.robot b/atest/testdata/standard_libraries/screenshot/set_screenshot_directory.robot index 35d7eadacb4..c9e5c1cff58 100644 --- a/atest/testdata/standard_libraries/screenshot/set_screenshot_directory.robot +++ b/atest/testdata/standard_libraries/screenshot/set_screenshot_directory.robot @@ -1,8 +1,8 @@ *** Settings *** -Suite Setup Clean Temp Files And Create Directory -Test Setup Save Start Time -Test Teardown Clean Temp Files -Resource screenshot_resource.robot +Suite Setup Clean Temp Files And Create Directory +Test Setup Save Start Time +Suite Teardown Clean Temp Files +Resource screenshot_resource.robot *** Variables *** ${SCREENSHOT DIR} = %{TEMPDIR}${/}robot_atest_screenshots @@ -11,11 +11,17 @@ ${FIRST_SCREENSHOT} = ${BASENAME}_1.jpg *** Test Cases *** Set Screenshot Directory - ${old} = Set Screenshot Directory ${SCREENSHOT DIR} - Paths Should Be Equal ${OUTPUT DIR} ${old} + ${old} = Set Screenshot Directory ${SCREENSHOT DIR} + Paths Should Be Equal ${OUTPUT DIR} ${old} + Set Suite Variable ${OUTPUT DIR} ${SCREENSHOT DIR} Take Screenshot - Screenshot Should Exist ${FIRST SCREENSHOT} + Screenshot Should Exist ${FIRST SCREENSHOT} +Set Screenshot Directory as `pathlib.Path` + ${old} = Set Screenshot Directory ${{pathlib.Path($SCREENSHOT_DIR)}} + Paths Should Be Equal ${OUTPUT DIR} ${old} + Take Screenshot + Screenshot Should Exist ${FIRST SCREENSHOT} *** Keywords *** Clean Temp Files And Create Directory diff --git a/atest/testdata/standard_libraries/screenshot/take_screenshot.robot b/atest/testdata/standard_libraries/screenshot/take_screenshot.robot index 03d3d47faf8..470acbf5ff2 100644 --- a/atest/testdata/standard_libraries/screenshot/take_screenshot.robot +++ b/atest/testdata/standard_libraries/screenshot/take_screenshot.robot @@ -5,45 +5,45 @@ Test Teardown Remove Files ${OUTPUTDIR}/*.jp*g Resource screenshot_resource.robot *** Variables *** -${BASENAME} ${OUTPUTDIR}${/}screenshot -${FIRST_SCREENSHOT} ${BASENAME}_1.jpg -${SECOND_SCREENSHOT} ${BASENAME}_2.jpg -${FIRST_CUSTOM_SCREENSHOT} ${OUTPUTDIR}${/}foo_1.jpg -${SECOND_CUSTOM_SCREENSHOT} ${OUTPUTDIR}${/}foo_2.jpg - +${FIRST_SCREENSHOT} screenshot_1.jpg +${SECOND_SCREENSHOT} screenshot_2.jpg *** Test Cases *** Screenshot Is Embedded in Log File - ${path}= Take Screenshot and Verify ${FIRST_SCREENSHOT} - Should Be Equal ${path} ${FIRST_SCREENSHOT} + ${path}= Take Screenshot and Verify ${FIRST_SCREENSHOT} + Should Be Equal ${path} ${OUTPUTDIR}${/}${FIRST_SCREENSHOT} Each Screenshot Gets Separate Index - Take Screenshot and Verify ${FIRST_SCREENSHOT} - Take Screenshot and Verify ${FIRST_SCREENSHOT} ${SECOND_SCREENSHOT} + Take Screenshot and Verify ${FIRST_SCREENSHOT} + Take Screenshot and Verify ${FIRST_SCREENSHOT} ${SECOND_SCREENSHOT} Basename May Be Defined - Repeat Keyword 2 Take Screenshot foo - Screenshots Should Exist ${OUTPUTDIR} ${FIRST_CUSTOM_SCREENSHOT} ${SECOND_CUSTOM_SCREENSHOT} + Repeat Keyword 2 Take Screenshot foo + Screenshots Should Exist ${OUTPUTDIR} foo_1.jpg foo_2.jpg Basename With Extension Turns Off Index Generation - Repeat Keyword 3 Take Screenshot xxx.jpg - Repeat Keyword 2 Take Screenshot yyy.jpeg - Screenshots Should Exist ${OUTPUTDIR} ${OUTPUTDIR}${/}xxx.jpg ${OUTPUTDIR}${/}yyy.jpeg + Repeat Keyword 3 Take Screenshot xxx.jpg + Repeat Keyword 2 Take Screenshot yyy.jpeg + Screenshots Should Exist ${OUTPUTDIR} xxx.jpg yyy.jpeg + +Name as `pathlib.Path` + Take Screenshot ${{pathlib.Path('name.jpg')}} + Screenshots Should Exist ${OUTPUTDIR} name.jpg Screenshot Width Can Be Given - Take Screenshot width=300px - Screenshots Should Exist ${OUTPUTDIR} ${FIRST_SCREENSHOT} + Take Screenshot width=300px + Screenshots Should Exist ${OUTPUTDIR} ${FIRST_SCREENSHOT} Basename With Non-existing Directories Fails [Documentation] FAIL Directory '${OUTPUTDIR}${/}non-existing' where to save the screenshot does not exist - Take Screenshot ${OUTPUTDIR}${/}non-existing${/}foo + Take Screenshot ${OUTPUTDIR}${/}non-existing${/}foo Without Embedding - Take Screenshot Without Embedding no_embed.jpeg - + Take Screenshot Without Embedding no_embed.jpeg *** Keywords *** -Take Screenshot And Verify [Arguments] @{expected files} - ${path}= Take Screenshot - Screenshots Should Exist ${OUTPUTDIR} @{expected files} - [Return] ${path} +Take Screenshot And Verify + [Arguments] @{expected files} + ${path}= Take Screenshot + Screenshots Should Exist ${OUTPUTDIR} @{expected files} + RETURN ${path} diff --git a/src/robot/libraries/Screenshot.py b/src/robot/libraries/Screenshot.py index c3f87edd01b..92e25daa9c7 100644 --- a/src/robot/libraries/Screenshot.py +++ b/src/robot/libraries/Screenshot.py @@ -110,7 +110,11 @@ def __init__(self, screenshot_directory=None, screenshot_module=None): def _norm_path(self, path): if not path: return path - return os.path.normpath(path.replace('/', os.sep)) + elif isinstance(path, os.PathLike): + path = str(path) + else: + path = path.replace('/', os.sep) + return os.path.normpath(path) @property def _screenshot_dir(self): @@ -179,8 +183,9 @@ def take_screenshot_without_embedding(self, name="screenshot"): self._link_screenshot(path) return path - def _save_screenshot(self, basename, directory=None): - path = self._get_screenshot_path(basename, directory) + def _save_screenshot(self, name): + name = str(name) if isinstance(name, os.PathLike) else name.replace('/', os.sep) + path = self._get_screenshot_path(name) return self._screenshot_to_file(path) def _screenshot_to_file(self, path): @@ -202,14 +207,13 @@ def _validate_screenshot_path(self, path): "does not exist" % os.path.dirname(path)) return path - def _get_screenshot_path(self, basename, directory): - directory = self._norm_path(directory) if directory else self._screenshot_dir + def _get_screenshot_path(self, basename): if basename.lower().endswith(('.jpg', '.jpeg')): - return os.path.join(directory, basename) + return os.path.join(self._screenshot_dir, basename) index = 0 while True: index += 1 - path = os.path.join(directory, "%s_%d.jpg" % (basename, index)) + path = os.path.join(self._screenshot_dir, "%s_%d.jpg" % (basename, index)) if not os.path.exists(path): return path From 14321c0ba94de42867b8884936cd81087c5533b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 18 Sep 2022 13:58:49 +0300 Subject: [PATCH 0199/1592] Add argument conversion for pathlib.Path. Fixes #4461. --- .../type_conversion/annotations.robot | 6 +++++ .../type_conversion/default_values.robot | 6 +++++ .../type_conversion/keyword_decorator.robot | 6 +++++ .../keywords/type_conversion/Annotations.py | 20 ++++++++++---- .../keywords/type_conversion/DefaultValues.py | 9 +++++++ .../type_conversion/KeywordDecorator.py | 17 ++++++++++++ .../type_conversion/annotations.robot | 23 ++++++++++++++++ .../type_conversion/conversion.resource | 7 ++++- .../type_conversion/default_values.robot | 18 +++++++++++++ .../type_conversion/keyword_decorator.robot | 26 +++++++++++++++++-- .../CreatingTestLibraries.rst | 6 +++++ src/robot/running/arguments/typeconverters.py | 13 ++++++++++ 12 files changed, 149 insertions(+), 8 deletions(-) diff --git a/atest/robot/keywords/type_conversion/annotations.robot b/atest/robot/keywords/type_conversion/annotations.robot index 6b1fedbb593..03cae28a65e 100644 --- a/atest/robot/keywords/type_conversion/annotations.robot +++ b/atest/robot/keywords/type_conversion/annotations.robot @@ -93,6 +93,12 @@ Timedelta Invalid timedelta Check Test Case ${TESTNAME} +Path + Check Test Case ${TESTNAME} + +Invalid Path + Check Test Case ${TESTNAME} + Enum Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/default_values.robot b/atest/robot/keywords/type_conversion/default_values.robot index 6a199124326..0e69b2aa134 100644 --- a/atest/robot/keywords/type_conversion/default_values.robot +++ b/atest/robot/keywords/type_conversion/default_values.robot @@ -72,6 +72,12 @@ Timedelta Invalid timedelta Check Test Case ${TESTNAME} +Path + Check Test Case ${TESTNAME} + +Invalid Path + Check Test Case ${TESTNAME} + Enum Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/keyword_decorator.robot b/atest/robot/keywords/type_conversion/keyword_decorator.robot index 00ec4af6969..d9d7c419a8a 100644 --- a/atest/robot/keywords/type_conversion/keyword_decorator.robot +++ b/atest/robot/keywords/type_conversion/keyword_decorator.robot @@ -93,6 +93,12 @@ Timedelta Invalid timedelta Check Test Case ${TESTNAME} +Path + Check Test Case ${TESTNAME} + +Invalid Path + Check Test Case ${TESTNAME} + Enum Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/Annotations.py b/atest/testdata/keywords/type_conversion/Annotations.py index a853484f85f..28d8643299d 100644 --- a/atest/testdata/keywords/type_conversion/Annotations.py +++ b/atest/testdata/keywords/type_conversion/Annotations.py @@ -1,13 +1,11 @@ from collections import abc from datetime import datetime, date, timedelta from decimal import Decimal -try: - from enum import Flag, Enum, IntFlag, IntEnum -except ImportError: # Python 3.5 - from enum import Enum, IntEnum - Flag, IntFlag = Enum, IntEnum +from enum import Flag, Enum, IntFlag, IntEnum from functools import wraps from numbers import Integral, Real +from os import PathLike +from pathlib import Path, PurePath # Needed by `eval()` in `_validate_type()`. import collections @@ -101,6 +99,18 @@ def timedelta_(argument: timedelta, expected=None): _validate_type(argument, expected) +def path(argument: Path, expected=None): + _validate_type(argument, expected) + + +def pure_path(argument: PurePath, expected=None): + _validate_type(argument, expected) + + +def path_like(argument: PathLike, expected=None): + _validate_type(argument, expected) + + def enum_(argument: MyEnum, expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/DefaultValues.py b/atest/testdata/keywords/type_conversion/DefaultValues.py index b19a18728bc..340fdc5f276 100644 --- a/atest/testdata/keywords/type_conversion/DefaultValues.py +++ b/atest/testdata/keywords/type_conversion/DefaultValues.py @@ -1,6 +1,7 @@ from enum import Flag, Enum, IntFlag, IntEnum from datetime import datetime, date, timedelta from decimal import Decimal +from pathlib import Path, PurePath # Path needed by `eval()` in `_validate_type()`. from robot.api.deco import keyword @@ -70,6 +71,14 @@ def timedelta_(argument=timedelta(), expected=None): _validate_type(argument, expected) +def path(argument=Path(), expected=None): + _validate_type(argument, expected) + + +def pure_path(argument=PurePath(), expected=None): + _validate_type(argument, expected) + + def enum(argument=MyEnum.FOO, expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/KeywordDecorator.py b/atest/testdata/keywords/type_conversion/KeywordDecorator.py index 4557482f13f..b88dca4820d 100644 --- a/atest/testdata/keywords/type_conversion/KeywordDecorator.py +++ b/atest/testdata/keywords/type_conversion/KeywordDecorator.py @@ -4,6 +4,8 @@ from enum import Flag, Enum, IntFlag, IntEnum from fractions import Fraction # Needed by `eval()` in `_validate_type()`. from numbers import Integral, Real +from os import PathLike +from pathlib import Path, PurePath from typing import Union from robot.api.deco import keyword @@ -101,6 +103,21 @@ def timedelta_(argument, expected=None): _validate_type(argument, expected) +@keyword(types={'argument': Path}) +def path(argument, expected=None): + _validate_type(argument, expected) + + +@keyword(types={'argument': PurePath}) +def pure_path(argument, expected=None): + _validate_type(argument, expected) + + +@keyword(types={'argument': PathLike}) +def path_like(argument, expected=None): + _validate_type(argument, expected) + + @keyword(types={'argument': MyEnum}) def enum(argument, expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/annotations.robot b/atest/testdata/keywords/type_conversion/annotations.robot index 22c31c8db98..b0dd5127890 100644 --- a/atest/testdata/keywords/type_conversion/annotations.robot +++ b/atest/testdata/keywords/type_conversion/annotations.robot @@ -10,6 +10,8 @@ ${FRACTION 1/2} ${{fractions.Fraction(1,2)}} ${DECIMAL 1/2} ${{decimal.Decimal('0.5')}} ${DEQUE} ${{collections.deque([1, 2, 3])}} ${MAPPING} ${{type('M', (collections.abc.Mapping,), {'__getitem__': lambda s, k: {'a': 1}[k], '__iter__': lambda s: iter({'a': 1}), '__len__': lambda s: 1})()}} +${PATH} ${{pathlib.Path('x/y')}} +${PUREPATH} ${{pathlib.PurePath('x/y')}} *** Test Cases *** Integer @@ -260,6 +262,27 @@ Invalid timedelta Timedelta 01:02:03:04 error=Invalid time string '01:02:03:04'. Timedelta ${LIST} arg_type=list +Path + Path path Path('path') + Path two/components Path(r'two${/}components') + Path two${/}components Path(r'two${/}components') + Path ${PATH} Path('x/y') + Path ${PUREPATH} Path('x/y') + PurePath path Path('path') + PurePath two/components Path(r'two${/}components') + PurePath two${/}components Path(r'two${/}components') + PurePath ${PATH} Path('x/y') + PurePath ${PUREPATH} PurePath('x/y') + PathLike path Path('path') + PathLike two/components Path(r'two${/}components') + PathLike two${/}components Path(r'two${/}components') + PathLike ${PATH} Path('x/y') + PathLike ${PUREPATH} PurePath('x/y') + +Invalid Path + [Template] Conversion Should Fail + Path ${1} type=Path arg_type=integer + Enum Enum FOO MyEnum.FOO Enum bar MyEnum.bar diff --git a/atest/testdata/keywords/type_conversion/conversion.resource b/atest/testdata/keywords/type_conversion/conversion.resource index 3d13ec3000b..c2613db084b 100644 --- a/atest/testdata/keywords/type_conversion/conversion.resource +++ b/atest/testdata/keywords/type_conversion/conversion.resource @@ -3,12 +3,17 @@ Conversion Should Fail [Arguments] ${kw} @{args} ${error}= ${type}=${kw.lower()} ${arg_type}= &{kwargs} ${arg} = Evaluate (list($args) + list($kwargs.values()))[0] ${message} = Catenate + ... ValueError: ... Argument 'argument' got value '${arg}'${{" (${arg_type})" if $arg_type else ""}} ... that cannot be converted to ${type}${{": ${error}" if $error else "."}} TRY Run Keyword ${kw} @{args} &{kwargs} - EXCEPT ValueError: ${message} type=${{'GLOB' if '*' in $error else 'LITERAL'}} + EXCEPT ${message} type=${{'GLOB' if '*' in $error else 'LITERAL'}} No Operation + EXCEPT AS ${err} + Fail Expected error\n\n \ ${message}\n\nbut got\n\n \ ${err} + ELSE + Fail Expected error '${message}' did not occur. END String None is converted to None object diff --git a/atest/testdata/keywords/type_conversion/default_values.robot b/atest/testdata/keywords/type_conversion/default_values.robot index 0617fca1a60..d2a9238c04e 100644 --- a/atest/testdata/keywords/type_conversion/default_values.robot +++ b/atest/testdata/keywords/type_conversion/default_values.robot @@ -5,6 +5,8 @@ Resource conversion.resource *** Variables *** @{LIST} foo bar &{DICT} foo=${1} bar=${2} +${PATH} ${{pathlib.Path('x/y')}} +${PUREPATH} ${{pathlib.PurePath('x/y')}} *** Test Cases *** Integer @@ -174,6 +176,22 @@ Invalid timedelta Timedelta foobar Timedelta 01:02:03:04 +Path + Path path Path('path') + Path two/components Path(r'two${/}components') + Path two${/}components Path(r'two${/}components') + Path ${PATH} Path('x/y') + Path ${PUREPATH} Path('x/y') + PurePath path Path('path') + PurePath two/components Path(r'two${/}components') + PurePath two${/}components Path(r'two${/}components') + PurePath ${PATH} Path('x/y') + PurePath ${PUREPATH} PurePath('x/y') + +Invalid Path + [Template] Invalid value is passed as-is + Path ${1} ${1} + Enum Enum FOO MyEnum.FOO Enum bar MyEnum.bar diff --git a/atest/testdata/keywords/type_conversion/keyword_decorator.robot b/atest/testdata/keywords/type_conversion/keyword_decorator.robot index 2e3e74a2e38..187431573e9 100644 --- a/atest/testdata/keywords/type_conversion/keyword_decorator.robot +++ b/atest/testdata/keywords/type_conversion/keyword_decorator.robot @@ -8,7 +8,8 @@ Resource conversion.resource &{DICT} foo=${1} bar=${2} ${FRACTION 1/2} ${{fractions.Fraction(1,2)}} ${DECIMAL 1/2} ${{decimal.Decimal('0.5')}} -${u} ${{'u' if sys.version_info[0] == 2 and sys.platform != 'cli' else ''}} +${PATH} ${{pathlib.Path('x/y')}} +${PUREPATH} ${{pathlib.PurePath('x/y')}} *** Test Cases *** Integer @@ -152,7 +153,7 @@ String String 2 '2' String ${42} '42' String ${None} 'None' - String ${LIST} "[${u}'foo', ${u}'bar']" + String ${LIST} "['foo', 'bar']" Invalid string [Template] Conversion Should Fail @@ -259,6 +260,27 @@ Invalid timedelta Timedelta 01:02:03:04 error=Invalid time string '01:02:03:04'. Timedelta ${LIST} arg_type=list +Path + Path path Path('path') + Path two/components Path(r'two${/}components') + Path two${/}components Path(r'two${/}components') + Path ${PATH} Path('x/y') + Path ${PUREPATH} Path('x/y') + PurePath path Path('path') + PurePath two/components Path(r'two${/}components') + PurePath two${/}components Path(r'two${/}components') + PurePath ${PATH} Path('x/y') + PurePath ${PUREPATH} PurePath('x/y') + PathLike path Path('path') + PathLike two/components Path(r'two${/}components') + PathLike two${/}components Path(r'two${/}components') + PathLike ${PATH} Path('x/y') + PathLike ${PUREPATH} PurePath('x/y') + +Invalid Path + [Template] Conversion Should Fail + Path ${1} type=Path arg_type=integer + Enum Enum FOO MyEnum.FOO Enum bar MyEnum.bar diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 8218ce125ab..b695ab41e8c 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1247,6 +1247,10 @@ Other types cause conversion failures. | | | | float_ | `time as time string`_ or `time as "timer" string`_. Integers | | `01:02` (same as above) | | | | | | and floats are considered to be seconds. | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | `Path | PathLike_ | | str_ | Strings are converted `Path <pathli_>`__ objects. On Windows | | `/tmp/absolute/path` | + | <pathli_>`__| | | | `/` is converted to :codesc:`\\` automatically. New in RF 5.1. | | `relative/path/file.ext` | + | | | | | | | `name_only.txt` | + +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | Enum_ | | | str_ | The specified type must be an enumeration (a subclass of Enum_ | .. sourcecode:: python | | | | | | or Flag_) and given arguments must match its member names. | | | | | | | | class Direction(Enum): | @@ -1311,6 +1315,8 @@ Other types cause conversion failures. .. _dt-mod: https://docs.python.org/library/datetime.html#datetime.datetime .. _date: https://docs.python.org/library/datetime.html#datetime.date .. _timedelta: https://docs.python.org/library/datetime.html#datetime.timedelta +.. _pathli: https://docs.python.org/library/pathlib.html +.. _PathLike: https://docs.python.org/library/os.html#os.PathLike .. _Enum: https://docs.python.org/library/enum.html#enum.Enum .. _Flag: https://docs.python.org/library/enum.html#enum.Flag .. _IntEnum: https://docs.python.org/library/enum.html#enum.IntEnum diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 6d4d7655eff..86348c69069 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -21,6 +21,8 @@ from decimal import InvalidOperation, Decimal from enum import Enum from numbers import Integral, Real +from os import PathLike +from pathlib import Path, PurePath from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time @@ -368,6 +370,17 @@ def _convert(self, value, explicit_type=True): return convert_time(value, result_format='timedelta') +@TypeConverter.register +class PathConverter(TypeConverter): + type = Path + abc = PathLike + type_name = 'Path' + value_types = (str, PurePath) + + def _convert(self, value, explicit_type=True): + return Path(value) + + @TypeConverter.register class NoneConverter(TypeConverter): type = NoneType From 82fb12c7894570540ccb94ee6ab565a635fe2262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 18 Sep 2022 14:13:57 +0300 Subject: [PATCH 0200/1592] Fix test on Windows --- atest/testdata/standard_libraries/operating_system/path.robot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atest/testdata/standard_libraries/operating_system/path.robot b/atest/testdata/standard_libraries/operating_system/path.robot index 9c7369eb683..bc1f07dc1a7 100644 --- a/atest/testdata/standard_libraries/operating_system/path.robot +++ b/atest/testdata/standard_libraries/operating_system/path.robot @@ -110,7 +110,7 @@ With Space Split Extension And Check with space.and another with space and another Path as `pathlib.Path` - Join Path And Check foo/bar ${{pathlib.Path('foo')}} ${{pathlib.Path('bar')}} + Join Path And Check foo${/}bar ${{pathlib.Path('foo')}} ${{pathlib.Path('bar')}} Normalize Path And Check ${{pathlib.Path('foo/../bar')}} bar Split Path And Check ${{pathlib.Path('foo/bar')}} foo bar Split Extension And Check ${{pathlib.Path('foo.bar')}} foo bar From 0ca44a321bae128b10ec3bcab6d3cee49b3ddf65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 18 Sep 2022 14:21:53 +0300 Subject: [PATCH 0201/1592] Ignore empty --include/--exclude/--suite/--test. Fixes #4441. --- atest/robot/tags/include_and_exclude.robot | 5 ++++- .../tags/include_and_exclude_with_rebot.robot | 14 +++++++++++--- src/robot/conf/settings.py | 11 +++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/atest/robot/tags/include_and_exclude.robot b/atest/robot/tags/include_and_exclude.robot index cfa08abda93..7bc686ddedd 100644 --- a/atest/robot/tags/include_and_exclude.robot +++ b/atest/robot/tags/include_and_exclude.robot @@ -3,7 +3,7 @@ Test Template Run And Check Include And Exclude Resource atest_resource.robot *** Variables *** -# Note: The test case Robot-exclude in +# Note: The test case Robot-exclude in # atest\testdata\tags\include_and_exclude.robot # should always be automatically excluded since it # uses the robot:exclude tag @@ -16,6 +16,9 @@ ${DATA SOURCES} tags/include_and_exclude.robot No Includes Or Excludes ${EMPTY} @{ALL} +Empty iclude and exclude are ignored + --include= --exclude= @{ALL} + One Include --include incl1 @{INCL_ALL} diff --git a/atest/robot/tags/include_and_exclude_with_rebot.robot b/atest/robot/tags/include_and_exclude_with_rebot.robot index 3990274c996..ac638db420f 100644 --- a/atest/robot/tags/include_and_exclude_with_rebot.robot +++ b/atest/robot/tags/include_and_exclude_with_rebot.robot @@ -19,6 +19,9 @@ ${INPUT FILES} ${INPUT FILE} No Includes Or Excludes ${EMPTY} @{ALL} +Empty iclude and exclude are ignored + --include= --exclude= @{ALL} times_are_none=False + One Include --include incl1 @{INCL_ALL} @@ -148,14 +151,19 @@ Create Input Files Create Output With Robot ${INPUT FILE} ${EMPTY} ${TEST FILE} Run And Check Include And Exclude - [Arguments] ${params} @{tests} + [Arguments] ${params} @{tests} ${times_are_none}=${{bool($params)}} Run Rebot ${params} ${INPUT FILES} Stderr Should Be Empty Should Contain Tests ${SUITE} @{tests} Should Be True $SUITE.statistics.passed == len($tests) Should Be True $SUITE.statistics.failed == 0 - Should Be Equal ${SUITE.starttime} ${{None if $params else $ORIG_START}} - Should Be Equal ${SUITE.endtime} ${{None if $params else $ORIG_END}} + IF ${times_are_none} + Should Be Equal ${SUITE.starttime} ${None} + Should Be Equal ${SUITE.endtime} ${None} + ELSE + Should Be Equal ${SUITE.starttime} ${ORIG_START} + Should Be Equal ${SUITE.endtime} ${ORIG_END} + END Elapsed Time Should Be Valid ${SUITE.elapsedtime} Should Be True $SUITE.elapsedtime <= $ORIG_ELAPSED + 1 diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index 59c2371d319..fb127afa195 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -391,19 +391,22 @@ def split_log(self): @property def suite_names(self): - return self['SuiteNames'] or None + return self._filter_empty(self['SuiteNames']) + + def _filter_empty(self, items): + return [i for i in items if i] or None @property def test_names(self): - return (self['TestNames'] + self['TaskNames']) or None + return self._filter_empty(self['TestNames'] + self['TaskNames']) @property def include(self): - return self['Include'] or None + return self._filter_empty(self['Include']) @property def exclude(self): - return self['Exclude'] or None + return self._filter_empty(self['Exclude']) @property def pythonpath(self): From 53c4f8f1c643870a44c90789a8ffc7efb24f330a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= <41592183+Snooz82@users.noreply.github.com> Date: Mon, 19 Sep 2022 15:24:44 +0200 Subject: [PATCH 0202/1592] Libdoc: Avoid conflicts with JS variable names Fixes #4464. --- src/robot/htmldata/libdoc/libdoc.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/htmldata/libdoc/libdoc.html b/src/robot/htmldata/libdoc/libdoc.html index 307bcc7f43d..d22946e1974 100644 --- a/src/robot/htmldata/libdoc/libdoc.html +++ b/src/robot/htmldata/libdoc/libdoc.html @@ -561,7 +561,7 @@ <h4>Documentation</h4> <span class="arg-type"> {{each types}} {{if $value in $data.typedocs}} - <<a style="cursor: pointer;" class="type" title="Click to show type information" onclick="showModal(${$data.typedocs[$value]})">${$value}</a>> + <<a style="cursor: pointer;" class="type" title="Click to show type information" onclick="showModal(document.querySelector('#${$data.typedocs[$value]}'))">${$value}</a>> {{else}} <<span class="type">${$value}</span>> {{/if}} From ec518ef2eee12376a27855dd7005ded08b327d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 20 Sep 2022 20:52:46 +0300 Subject: [PATCH 0203/1592] Add missing docs for Path converter (#4461) Also add test to make sure docs aren't missed again when new converters are added. --- .../CreatingTestLibraries.rst | 4 ++-- src/robot/libdocpkg/standardtypes.py | 7 +++++++ utest/libdoc/test_datatypes.py | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 utest/libdoc/test_datatypes.py diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index b695ab41e8c..05999e4c4f0 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1248,8 +1248,8 @@ Other types cause conversion failures. | | | | | and floats are considered to be seconds. | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | `Path | PathLike_ | | str_ | Strings are converted `Path <pathli_>`__ objects. On Windows | | `/tmp/absolute/path` | - | <pathli_>`__| | | | `/` is converted to :codesc:`\\` automatically. New in RF 5.1. | | `relative/path/file.ext` | - | | | | | | | `name_only.txt` | + | <pathli_>`__| | | | `/` is converted to :codesc:`\\` automatically. New in RF 5.1. | | `relative/path/to/file.ext` | + | | | | | | | `name.txt` | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | Enum_ | | | str_ | The specified type must be an enumeration (a subclass of Enum_ | .. sourcecode:: python | | | | | | or Flag_) and given arguments must match its member names. | | diff --git a/src/robot/libdocpkg/standardtypes.py b/src/robot/libdocpkg/standardtypes.py index a75ee726d82..328b234e25a 100644 --- a/src/robot/libdocpkg/standardtypes.py +++ b/src/robot/libdocpkg/standardtypes.py @@ -15,6 +15,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal +from pathlib import Path STANDARD_TYPE_DOCS = { @@ -106,6 +107,12 @@ See the [https://robotframework.org/robotframework/|Robot Framework User Guide] for more details about the supported time formats. +''', + Path: '''\ +Strings are converted [https://docs.python.org/library/pathlib.html|Path] objects. +On Windows ``/`` is converted to ``\\`` automatically. + +Examples: ``/tmp/absolute/path``, ``relative/path/to/file.ext``, ``name.txt`` ''', type(None): '''\ String ``NONE`` (case-insensitive) is converted to Python ``None`` object. diff --git a/utest/libdoc/test_datatypes.py b/utest/libdoc/test_datatypes.py new file mode 100644 index 00000000000..95c10b2b92f --- /dev/null +++ b/utest/libdoc/test_datatypes.py @@ -0,0 +1,19 @@ +import unittest + +from robot.libdocpkg.standardtypes import STANDARD_TYPE_DOCS +from robot.running.arguments.typeconverters import (EnumConverter, CombinedConverter, + CustomConverter, TypeConverter) + + +class TestStandardTypeDocs(unittest.TestCase): + no_std_docs = (EnumConverter, CombinedConverter, CustomConverter) + + def test_all_standard_types_have_docs(self): + for cls in TypeConverter.__subclasses__(): + if cls.type not in STANDARD_TYPE_DOCS and cls not in self.no_std_docs: + raise AssertionError(f"Standard converter '{cls.__name__}' " + f"does not have documentation.") + + +if __name__ == '__main__': + unittest.main() From 78e3574be58abeffd514cac9c298e3f9b044c217 Mon Sep 17 00:00:00 2001 From: Yusuf Can Bayrak <yusufcanbayrak@gmail.com> Date: Tue, 20 Sep 2022 20:05:53 +0200 Subject: [PATCH 0204/1592] Localization for Turkish language (#4463) --- src/robot/conf/languages.py | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index d89691e447c..394f4a1093c 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -837,3 +837,44 @@ class ZhCn(Language): then_prefix = {'则'} and_prefix = {'且'} but_prefix = {'但'} + + +class Tr(Language): + """Turkish""" + settings_header = 'Ayarlar' + variables_header = 'Değişkenler' + test_cases_header = 'Test Durumları' + tasks_header = 'Görevler' + keywords_header = 'Anahtar Kelimeler' + comments_header = 'Yorumlar' + library_setting = 'Kütüphane' + resource_setting = 'Kaynak' + variables_setting = 'Değişkenler' + documentation_setting = 'Dokümantasyon' + metadata_setting = 'Üstveri' + suite_setup_setting = 'Takım Kurulumu' + suite_teardown_setting = 'Takım Bitişi' + test_setup_setting = 'Test Kurulumu' + task_setup_setting = 'Görev Kurulumu' + test_teardown_setting = 'Test Bitişi' + task_teardown_setting = 'Görev Bitişi' + test_template_setting = 'Test Taslağı' + task_template_setting = 'Görev Taslağı' + test_timeout_setting = 'Test Zaman Aşımı' + task_timeout_setting = 'Görev Zaman Aşımı' + test_tags_setting = 'Test Etiketleri' + task_tags_setting = 'Görev Etiketleri' + keyword_tags_setting = 'Anahtar Kelime Etiketleri' + setup_setting = 'Kurulum' + teardown_setting = 'Bitiş' + template_setting = 'Taslak' + tags_setting = 'Etiketler' + timeout_setting = 'Zaman Aşımı' + arguments_setting = 'Argümanlar' + given_prefix = {'Diyelim ki'} + when_prefix = {'Eğer ki'} + then_prefix = {'O zaman'} + and_prefix = {'Ve'} + but_prefix = {'Ancak'} + true_strings = {'DOĞRU', 'EVET', 'AÇIK'} + false_strings = {'YANLIŞ', 'HAYIR', 'KAPALI'} From d9055f78d84ec14f436ff967f63230feaf823783 Mon Sep 17 00:00:00 2001 From: "J. Foederer" <32476108+JFoederer@users.noreply.github.com> Date: Tue, 20 Sep 2022 20:17:14 +0200 Subject: [PATCH 0205/1592] Keyword should exist performance (#4457) Don't look for possible recommendations. Fixes #4470. --- .../builtin/keyword_should_exist.robot | 3 +++ .../builtin/keyword_should_exist.robot | 4 ++++ src/robot/libraries/BuiltIn.py | 2 +- src/robot/running/namespace.py | 21 +++++++++++-------- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/keyword_should_exist.robot b/atest/robot/standard_libraries/builtin/keyword_should_exist.robot index 481c5491150..1cd444746e5 100644 --- a/atest/robot/standard_libraries/builtin/keyword_should_exist.robot +++ b/atest/robot/standard_libraries/builtin/keyword_should_exist.robot @@ -31,6 +31,9 @@ Keyword does not exist Keyword does not exist with custom message Check Test Case ${TESTNAME} +Recommendations not shown if keyword does not exist + Check Test Case ${TESTNAME} + Duplicate keywords Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/builtin/keyword_should_exist.robot b/atest/testdata/standard_libraries/builtin/keyword_should_exist.robot index eba0b64e89b..1fc7e5abc69 100644 --- a/atest/testdata/standard_libraries/builtin/keyword_should_exist.robot +++ b/atest/testdata/standard_libraries/builtin/keyword_should_exist.robot @@ -45,6 +45,10 @@ Keyword does not exist with custom message [Documentation] FAIL Custom message Non Existing Custom message +Recommendations not shown if keyword does not exist + [Documentation] FAIL No keyword with name 'should be eQQual' found. + should be eQQual + Duplicate keywords [Documentation] FAIL ... Multiple keywords with name 'Duplicated keyword' found. \ diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index e9e325946f5..776b6369657 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -3266,7 +3266,7 @@ def keyword_should_exist(self, name, msg=None): See also `Variable Should Exist`. """ try: - runner = self._namespace.get_runner(name) + runner = self._namespace.get_runner(name, recommend_on_failure=False) except DataError as error: raise AssertionError(msg or error.message) if isinstance(runner, UserErrorHandler): diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index fac969ee1c9..52427f7ce82 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -215,9 +215,9 @@ def reload_library(self, libname_or_instance): library.reload() return library - def get_runner(self, name): + def get_runner(self, name, recommend_on_failure=True): try: - return self._kw_store.get_runner(name) + return self._kw_store.get_runner(name, recommend_on_failure) except DataError as error: return UserErrorHandler(error, name) @@ -257,13 +257,13 @@ def _get_lib_by_instance(self, instance): return lib self._no_library_found(instance) - def get_runner(self, name): + def get_runner(self, name, recommend=True): runner = self._get_runner(name) if runner is None: - self._raise_no_keyword_found(name) + self._raise_no_keyword_found(name, recommend) return runner - def _raise_no_keyword_found(self, name): + def _raise_no_keyword_found(self, name, recommend=True): if name.strip(': ').upper() == 'FOR': raise KeywordError( f"Support for the old FOR loop syntax has been removed. " @@ -276,10 +276,13 @@ def _raise_no_keyword_found(self, name): "loop, remove escaping backslashes and end the loop with 'END'." ) message = f"No keyword with name '{name}' found." - finder = KeywordRecommendationFinder(self.user_keywords, - self.libraries, - self.resources) - raise KeywordError(finder.recommend_similar_keywords(name, message)) + if recommend: + finder = KeywordRecommendationFinder(self.user_keywords, + self.libraries, + self.resources) + raise KeywordError(finder.recommend_similar_keywords(name, message)) + else: + raise KeywordError(message) def _get_runner(self, name): if not name: From 28652520e725e237d304ac89128bef737ea97a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= <snooz@posteo.de> Date: Tue, 20 Sep 2022 18:52:24 +0000 Subject: [PATCH 0206/1592] added prefix to datatype modal webelements --- src/robot/htmldata/libdoc/libdoc.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/htmldata/libdoc/libdoc.html b/src/robot/htmldata/libdoc/libdoc.html index d22946e1974..c805b12c410 100644 --- a/src/robot/htmldata/libdoc/libdoc.html +++ b/src/robot/htmldata/libdoc/libdoc.html @@ -40,7 +40,7 @@ <h1>Opening library documentation failed</h1> }, false); window.addEventListener("hashchange", function() { if (window.location.hash.indexOf('#type-') == 0) { - const hash = '#' + decodeURI(window.location.hash.slice(6)); + const hash = '#type-modal-' + decodeURI(window.location.hash.slice(6)); const typeDoc = document.querySelector(".data-types").querySelector(hash); if (typeDoc) { showModal(typeDoc); @@ -561,7 +561,7 @@ <h4>Documentation</h4> <span class="arg-type"> {{each types}} {{if $value in $data.typedocs}} - <<a style="cursor: pointer;" class="type" title="Click to show type information" onclick="showModal(document.querySelector('#${$data.typedocs[$value]}'))">${$value}</a>> + <<a style="cursor: pointer;" class="type" title="Click to show type information" onclick="showModal(document.querySelector('#type-modal-${$data.typedocs[$value]}'))">${$value}</a>> {{else}} <<span class="type">${$value}</span>> {{/if}} @@ -584,7 +584,7 @@ <h2 id="Data types">Data types</h2> </script> <script type="text/x-jquery-tmpl" id="data-type-template"> - <div class="data-type-container {{if hidden}}no-{{/if}}match" id="${name}"> + <div class="data-type-container {{if hidden}}no-{{/if}}match" id="type-modal-${name}"> <div class="data-type-name"> <h2>${name} (${type})</h2> </div> From 1bcc03a506dd9df242219bb4f0e1cce2dca7fd56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 21 Sep 2022 15:23:29 +0300 Subject: [PATCH 0207/1592] UG: Rewrite section about embedded arguments. - Document new automatic conflict resolution support. Fixes #4454. - Document that embeddded args given as variables not matching custom regexps is deprecated. Fixes #4462. - General cleanup and enhancements. - Remove outdated info like embedded args only working with user keywords. --- .../src/CreatingTestData/AdvancedFeatures.rst | 2 + .../CreatingTestData/CreatingUserKeywords.rst | 365 ++++++++++++------ 2 files changed, 246 insertions(+), 121 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst b/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst index f7743c97e45..78976b0cb29 100644 --- a/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst +++ b/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst @@ -70,6 +70,8 @@ cases, either the files or the keywords must be renamed. The full name of the keyword is case-, space- and underscore-insensitive, similarly as normal keyword names. +.. _library search order: + Specifying explicit priority between libraries and resources ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index f377686adb7..c4410f11dc3 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -486,108 +486,223 @@ __ `Default values with user keywords`_ Embedding arguments into keyword name ------------------------------------- -Robot Framework has also another approach to pass arguments to user -keywords than specifying them in cells after the keyword name as -explained in the previous section. This method is based on embedding -the arguments directly into the keyword name, and its main benefit is -making it easier to use real and clear sentences as keywords. +The previous section explained how to pass arguments to keywords so +that they are listed separately after the keyword name. Robot +Framework has also another approach to pass arguments, embedding them +directly to the keyword name, used by the second test below: + +.. sourcecode:: robotframework + + *** Test Cases *** + Normal arguments + Select from list cat + + Embedded arguments + Select cat from list + +As the example illustrates, embedding arguments to keyword names +can make the data easier to read and understand even for people without +any Robot Framework experience. Basic syntax ~~~~~~~~~~~~ -It has always been possible to use keywords like :name:`Select dog -from list` and :name:`Selects cat from list`, but all such keywords -must have been implemented separately. The idea of embedding arguments -into the keyword name is that all you need is a keyword with name like -:name:`Select ${animal} from list`. +The previous example showed how using a keyword :name:`Select cat from list` is +more fluent than using :name:`Select from list` so that `cat` is passed to +it as an argument. We obviously could implement :name:`Select cat from list` +as a normal keyword accepting no arguments, but then we needed to implement +various other keywords like :name:`Select dog from list` for other animals. +Embedded arguments simplify this and we can instead implement just one +keyword with name :name:`Select ${animal} from list` and use it with any +animal: .. sourcecode:: robotframework + *** Test Cases *** + Embedded arguments + Select cat from list + Select dog from list + *** Keywords *** Select ${animal} from list Open Page Pet Selection Select Item From List animal_list ${animal} +As the above example shows, embedded arguments are specified simply by using +variables in keyword names. The arguments used in the name are naturally +available inside the keyword and they have different values depending on how +the keyword is called. In the above example, `${animal}` has value `cat` when +the keyword is used for the first time and `dog` when it is used for +the second time. + Keywords using embedded arguments cannot take any "normal" arguments -(specified with :setting:`[Arguments]` setting) but otherwise they are -created just like other user keywords. The arguments used in the name -will naturally be available inside the keyword and they have different -value depending on how the keyword is called. For example, -`${animal}` in the previous has value `dog` if the keyword -is used like :name:`Select dog from list`. Obviously it is not -mandatory to use all these arguments inside the keyword, and they can -thus be used as wildcards. - -These kind of keywords are also used the same way as other keywords -except that spaces and underscores are not ignored in their -names. They are, however, case-insensitive like other keywords. For -example, the keyword in the example above could be used like -:name:`select x from list`, but not like :name:`Select x fromlist`. +(specified with :setting:`[Arguments]` setting), but otherwise they are +created just like other user keywords. They are also used the same way as +other keywords except that spaces and underscores are not ignored in their +names when keywords are matched. They are, however, case-insensitive like +other keywords. For example, the keyword in the example above could be used like +:name:`select cow from list`, but not like :name:`Select cow fromlist`. Embedded arguments do not support default values or variable number of -arguments like normal arguments do. Using variables when -calling these keywords is possible but that can reduce readability. -Notice also that embedded arguments only work with user keywords. +arguments like normal arguments do. If such functionality is needed, normal +arguments should be used instead. Passing embedded arguments as variables +is possible, but that can reduce readability: -Embedded arguments matching too much -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. sourcecode:: robotframework + + *** Variables *** + ${SELECT} cat + + *** Test Cases *** + Embedded arguments with variable + Select ${SELECT} from list + + *** Keywords *** + Select ${animal} from list + Open Page Pet Selection + Select Item From List animal_list ${animal} + +Embedded arguments matching wrong values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ One tricky part in using embedded arguments is making sure that the values used when calling the keyword match the correct arguments. This is a problem especially if there are multiple arguments and characters separating them may also appear in the given values. For example, -keyword :name:`Select ${city} ${team}` does not work correctly if used -with city containing two parts like :name:`Select Los Angeles Lakers`. - -An easy solution to this problem is quoting the arguments (e.g. -:name:`Select "${city}" "${team}"`) and using the keyword in quoted -format (e.g. :name:`Select "Los Angeles" "Lakers"`). This approach is -not enough to resolve all this kind of conflicts, though, but it is -still highly recommended because it makes arguments stand out from -rest of the keyword. A more powerful but also more complicated -solution, `using custom regular expressions`_ when defining variables, -is explained in the next section. Finally, if things get complicated, -it might be a better idea to use normal positional arguments instead. +:name:`Select Los Angeles Lakers` in the following example matches +:name:`Select ${city} ${team}` so that `${city}` contains `Los` and +`${team}` contains `Angeles Lakers`: + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Select Chicago Bulls + Select Los Angeles Lakers + + *** Keywords *** + Select ${city} ${team} + Log Selected ${team} from ${city}. + +An easy solution to this problem is surrounding arguments with double quotes or +other characters not used in the actual values. This fixed example works so +that cities and teams match correctly: + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Select "Chicago" "Bulls" + Select "Los Angeles" "Lakers" + + *** Keywords *** + Select "${city}" "${team}" + Log Selected ${team} from ${city}. + +This approach is not enough to resolve all conflicts, but it helps in common +cases and is generally recommended. Another benefit is that it makes arguments +stand out from rest of the keyword. The problem of arguments matching too much occurs often when creating -keywords that `ignore given/when/then/and/but prefixes`__ . For example, +keywords that `ignore the given/when/then/and/but prefixes`__ typically used +in Behavior Driven Development (BDD). For example, :name:`${name} goes home` matches :name:`Given Janne goes home` so that `${name}` gets value `Given Janne`. Quotes around the argument, like in :name:`"${name}" goes home`, resolve this problem easily. +An alternative solution for limiting what values arguments match is +`using custom regular expressions`_. + __ `Ignoring Given/When/Then/And/But prefixes`_ -Using custom regular expressions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Resolving conflicts +~~~~~~~~~~~~~~~~~~~ -When keywords with embedded arguments are called, the values are -matched internally using `regular expressions`__ -(regexps for short). The default logic goes so that every argument in -the name is replaced with a pattern `.*?` that basically matches -any string. This logic works fairly well normally, but as just -discussed above, sometimes keywords `match more than -intended`__. Quoting or otherwise separating arguments from the other -text can help but, for example, the test below fails because keyword -:name:`I execute "ls" with "-lh"` matches both of the defined -keywords. +When using embedded arguments, it is pretty common that there are multiple +keyword implementations that match the keyword that is used. For example, +:name:`Execute "ls" with "lf"` in the example below matches both of the keywords. +It matching :name:`Execute "${cmd}" with "${opts}"` is pretty obvious and what +we want, but it also matches :name:`Execute "${cmd}"` so that `${cmd}` matches +`ls" with "-lh`. .. sourcecode:: robotframework + *** Settings *** + Library Process + *** Test Cases *** - Example - I execute "ls" - I execute "ls" with "-lh" + Automatic conflict resolution + Execute "ls" + Execute "ls" with "-lh" *** Keywords *** - I execute "${cmd}" + Execute "${cmd}" Run Process ${cmd} shell=True - I execute "${cmd}" with "${opts}" + Execute "${cmd}" with "${opts}" Run Process ${cmd} ${opts} shell=True -A solution to this problem is using a custom regular expression that -makes sure that the keyword matches only what it should in that +When this kind of conflicts occur, Robot Framework tries to automatically select +the best match and use that. In the above example, :name:`Execute "${cmd}" with "${opts}"` +is considered a better match than the more generic :name:`Execute "${cmd}"` and +running the example thus succeeds without conflicts. + +It is not always possible to find a single match that is better than others. +For example, the second test below fails because :name:`Robot Framework` matches +both of the keywords equally well. This kind of conflicts need to be resolved +manually either by renaming keywords or by `using custom regular expressions`_. + +.. sourcecode:: robotframework + + *** Test Cases *** + No conflict + Automation framework + Robot uprising + + Unresolvable conflict + Robot Framework + + *** Keywords *** + ${type} Framework + Should Be Equal ${type} Automation + + Robot ${action} + Should Be Equal ${action} uprising + +Keywords that accept only "normal" arguments or no arguments at all are +considered to match better than keywords accepting embedded arguments. +For example, if the following keyword is added to the above example, +:name:`Robot Framework` used by the latter test matches it and the test +succeeds: + +.. sourcecode:: robotframework + + *** Keywords *** + Robot Framework + No Operation + +Before looking which match is best, Robot Framework checks are some of the matching +keywords implemented in the same file as the caller keyword. If there are such keywords, +they are given precedence over other keywords. If there are still conflicts +after looking for best matches, Robot Framework checks can they be +resolved based on the `library search order`_. + +.. note:: Automatically resolving conflicts if multiple keywords with embedded + arguments match is a new feature in Robot Framework 5.1. With older + versions custom regular expressions explained below can be used instead. + +Using custom regular expressions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When keywords with embedded arguments are called, the values are matched +internally using `regular expressions`__ (regexps for short). The default +logic goes so that every argument in the name is replaced with a pattern `.*?` +that matches any string and tries to match as little as possible. This logic works +fairly well normally, but as discussed above, sometimes keywords +`match wrong values`__ and sometimes there are `conflicts that cannot +be resolved`__ . A solution in these cases is specifying a custom regular +expression that makes sure that the keyword matches only what it should in that particular context. To be able to use this feature, and to fully understand the examples in this section, you need to understand at least the basics of the regular expression syntax. @@ -595,71 +710,82 @@ least the basics of the regular expression syntax. A custom embedded argument regular expression is defined after the base name of the argument so that the argument and the regexp are separated with a colon. For example, an argument that should match -only numbers can be defined like `${arg:\d+}`. Using custom -regular expressions is illustrated by the examples below. +only numbers can be defined like `${arg:\d+}`. -.. sourcecode:: robotframework +Using custom regular expressions is illustrated by the following examples. +Notice that the first one shows how the earlier problem with +:name:`Select ${city} ${team}` not matching :name:`Select Los Angeles Lakers` +properly can be resolved without quoting. That is achieved by implementing +the keyword so that `${team}` can only contain non-whitespace characters. - *** Test Cases *** - Example - I execute "ls" - I execute "ls" with "-lh" - I type 1 + 2 - I type 53 - 11 - Today is 2011-06-27 +.. sourcecode:: robotframework - *** Keywords *** - I execute "${cmd:[^"]+}" - Run Process ${cmd} shell=True + *** Settings *** + Library DateTime - I execute "${cmd}" with "${opts}" - Run Process ${cmd} ${opts} shell=True + *** Test Cases *** + Do not match whitespace characters + Select Chicago Bulls + Select Los Angeles Lakers - I type ${num1:\d+} ${operator:[+-]} ${num2:\d+} - Calculate ${num1} ${operator} ${num2} + Match numbers and characters from set + 1 + 2 = 3 + 53 - 11 = 42 - Today is ${date:\d{4}-\d{2}-\d{2}} - Log ${date} + Match either date or literal 'today' + Deadline is 2022-09-21 + Deadline is today -In the above example keyword :name:`I execute "ls" with "-lh"` matches -only :name:`I execute "${cmd}" with "${opts}"`. That is guaranteed -because the custom regular expression `[^"]+` in :name:`I execute -"${cmd:[^"]}"` means that a matching argument cannot contain any -quotes. In this case there is no need to add custom regexps to the -other :name:`I execute` variant. + *** Keywords *** + Select ${city} ${team:\S+} + Log Selected ${team} from ${city}. + + ${number1:\d+} ${operator:[+-]} ${number2:\d+} = ${expected:\d+} + ${result} = Evaluate ${number1} ${operator} ${number2} + Should Be Equal As Integers ${result} ${expected} + + Deadline is ${date:(\d{4}-\d{2}-\d{2}|today)} + IF '${date}' == 'today' + ${date} = Get Current Date + ELSE + ${date} = Convert Date ${date} + END + Log Deadline is on ${date}. -.. tip:: If you quote arguments, using regular expression `[^"]+` - guarantees that the argument matches only until the first - closing quote. +__ http://en.wikipedia.org/wiki/Regular_expression +__ `Embedded arguments matching wrong values`_ +__ `Resolving conflicts`_ Supported regular expression syntax ''''''''''''''''''''''''''''''''''' Being implemented with Python, Robot Framework naturally uses Python's -:name:`re` module that has pretty standard `regular expressions -syntax`__. This syntax is otherwise fully supported with embedded -arguments, but regexp extensions in format `(?...)` cannot be -used. If the regular expression syntax is invalid, -creating the keyword fails with an error visible in `test execution -errors`__. +`re module`__ that has pretty standard regular expressions syntax. +This syntax is otherwise fully supported with embedded arguments, but +regexp extensions in format `(?...)` cannot be used. If the regular +expression syntax is invalid, creating the keyword fails with an error +visible in `test execution errors`__. + +__ http://docs.python.org/library/re.html +__ `Errors and warnings during execution`_ Escaping special characters ''''''''''''''''''''''''''' Regular expressions use the backslash character (:codesc:`\\`) heavily both -to escape characters that have a special meaning in regexps (e.g. `\$`) and -to form special sequences (e.g. `\d`). Typically in Robot Framework data +to form special sequences (e.g. `\d`) and to escape characters that have +a special meaning in regexps (e.g. `\$`). Typically in Robot Framework data backslash characters `need to be escaped`__ with another backslash, but that is not required in this context. If there is a need to have a literal -backslash in the pattern, then the backslash must be escaped__ like +backslash in the pattern, then the backslash must be escaped like `${path:c:\\temp\\.*}`. -__ escaping_ +__ Escaping_ Possible lone opening and closing curly braces in the pattern must be escaped -like `${open:\{}` and `${close:\}}`. If there are matching braces like -`${digits:\d{2}}`, escaping is not needed. Escaping only opening or -closing brace is not allowed. +like `${open:\{}` and `${close:\}}` or otherwise Robot Framework is not able +to parse the variable syntax correctly. If there are matching braces like in +`${digits:\d{2}}`, escaping is not needed. .. note:: Prior to Robot Framework 3.2, it was mandatory to escape all closing curly braces in the pattern like `${digits:\d{2\}}`. @@ -673,7 +799,7 @@ closing brace is not allowed. Using variables with custom embedded argument regular expressions ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' -When custom embedded argument regular expressions are used, Robot +When embedded arguments are used with custom regular expressions, Robot Framework automatically enhances the specified regexps so that they match variables in addition to the text matching the pattern. For example, the following test case would pass @@ -686,28 +812,25 @@ using the keywords from the earlier example. *** Test Cases *** Example - Today is ${DATE} - I type ${1} + ${2} + Deadline is ${DATE} + ${1} + ${2} = ${3} -Notice that the actual value of the variable does not need to match the custom -regular expression. This is likely to change in the future, though, -as discussed in `issue #4069`__. +A limitation of using variables is that their actual values are not matched against +custom regular expressions. As the result keywords may be called with +values that their custom regexps would not allow. This behavior is deprecated +starting from Robot Framework 5.1 and values will be validated in the future. +For more information see issue `#4462`__. -__ http://en.wikipedia.org/wiki/Regular_expression -__ `Embedded arguments matching too much`_ -__ http://docs.python.org/library/re.html -__ `Errors and warnings during execution`_ -__ Escaping_ -__ https://github.com/robotframework/robotframework/issues/4069 +__ https://github.com/robotframework/robotframework/issues/4462 Behavior-driven development example ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The biggest benefit of having arguments as part of the keyword name is that it -makes it easier to use higher-level sentence-like keywords when writing test -cases in `behavior-driven style`_. The example below illustrates this. Notice -also that prefixes :name:`Given`, :name:`When` and :name:`Then` are `left out -of the keyword definitions`__. +A big benefit of having arguments as part of the keyword name is that it +makes it easier to use higher-level sentence-like keywords when using the +`behavior-driven style`_ to write tests. As the example below shows, this +support is typically used in combination with the possibility to +`omit Given, When and Then prefixes`__ in keyword definitions: .. sourcecode:: robotframework @@ -737,10 +860,10 @@ of the keyword definitions`__. Should Be Equal ${result} ${expected} .. note:: Embedded arguments feature in Robot Framework is inspired by - how *step definitions* are created in a popular BDD tool Cucumber__. + how *step definitions* are created in the popular BDD tool Cucumber__. __ `Ignoring Given/When/Then/And/But prefixes`_ -__ http://cukes.info +__ https://cucumber.io User keyword return values -------------------------- From f48218517be8258f9c9250f80d10b98137c7c31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 21 Sep 2022 21:09:07 +0300 Subject: [PATCH 0208/1592] Fix `get_time` util if input is 0. This also fixes `Get Time` keyword using that util. Fixes #4438. --- src/robot/utils/robottime.py | 2 +- utest/utils/test_robottime.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 97a7d1af0dd..44ca504189d 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -214,7 +214,7 @@ def get_time(format='timestamp', time_=None): - Otherwise (and by default) the time is returned as a timestamp string in format '2006-02-24 15:08:31' """ - time_ = int(time_ or time.time()) + time_ = int(time.time() if time_ is None else time_) format = format.lower() # 1) Return time in seconds since epoc if 'epoch' in format: diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index 92b0c4de1aa..83bfaa01a4e 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -328,7 +328,10 @@ def test_parse_time_with_now_and_utc(self): parsed = parse_time(input.upper().replace('NOW', 'UtC')) zone = time.altzone if time.localtime().tm_isdst else time.timezone expected += zone - assert_true(expected <= parsed <= expected + 1), + assert_true(expected <= parsed <= expected + 1) + + def test_get_time_with_zero(self): + assert_equal(get_time('epoch', 0), 0) def test_parse_modified_time_with_invalid_times(self): for value, msg in [("-100", "Epoch time must be positive (got -100)."), From e702b1465b1d3c1131c22e04e3dd6168575914f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 21 Sep 2022 23:13:57 +0300 Subject: [PATCH 0209/1592] Release notes for 5.1b2 --- doc/releasenotes/rf-5.1b2.rst | 708 ++++++++++++++++++++++++++++++++++ 1 file changed, 708 insertions(+) create mode 100644 doc/releasenotes/rf-5.1b2.rst diff --git a/doc/releasenotes/rf-5.1b2.rst b/doc/releasenotes/rf-5.1b2.rst new file mode 100644 index 00000000000..09feab609d2 --- /dev/null +++ b/doc/releasenotes/rf-5.1b2.rst @@ -0,0 +1,708 @@ +========================== +Robot Framework 5.1 beta 2 +========================== + +.. default-role:: code + +`Robot Framework`_ 5.1 is a new feature release that starts Robot Framework's +localization efforts and also brings in other nice enhancements. +Robot Framework 5.1 preview releases are targeted especially +for people interested in translations. + +All issues targeted for Robot Framework 5.1 can be found +from the `issue tracker milestone`_. +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==5.1b2 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 5.1 beta 2 was released on Wednesday September 21, 2022. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Robot Framework Slack: https://robotframework-slack-invite.herokuapp.com +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Localization +------------ + +Robot Framework 5.1 starts our localization efforts by making it possible to translate +various markers used in the data. It is possible to translate headers (e.g. `Test Cases`) +and settings (e.g. `Documentation`) (`#4096`_), `Given/When/Then` prefixes used in BDD +(`#519`_), as well as true and false strings used in Boolean argument conversion (`#4400`_). +Future versions may allow translating syntax like `IF` and `FOR`, contents of logs and +reports, error messages, and so on. + +Languages to use are specified when starting execution using the `--language` command +line option. With languages supported by Robot Framework out-of-the-box, it is possible +to use just a language code or name like `--language fi` or `--language Finnish`. +It is also possible to create a custom language file and use it like `--language MyLang.py`. +If there is a need to support multiple languages, the `--language` option can be +used multiple times like `--language de --language uk`. + +In addition to specifying the language from the command line, it is possible to +specify it in the data file itself using `language: <lang>` syntax, where `<lang>` is +a language code or name, before the first section:: + + language: fi + + *** Asetukset *** + Dokumentaatio Example using Finnish. + +Due to technical reasons this per-file language configuration affects also parsing +subsequent files, but that behavior is likely to change and *should not* be dependent +on. Either use `language: <lang>` in each parsed file or specify the language to +use from the command line. + +Robot Framework 5.1 beta 2 contains built-in support for these languages in addition +to English that is automatically supported: + +- Bosnian (BS) +- Czech (CS) +- Dutch (NL) +- Finnish (FI) +- French (FR) +- German (DE) +- Polish (PL) +- Portuguese (PT) and Brazilian Portuguese (PT-BR) +- Russian (RU) +- Simplified Chinese (ZH-CN) +- Spanish (ES) +- Thai (TH) +- Turkish (TR) +- Ukrainian (UK) + +All these translations have been provided by our awesome community and we hope to get +more community contributed translations still before Robot Framework 5.1 final +release. If you are interested to help, head to Crowdin__ that we use +for collaboration. For more instructions see issue `#4390`_ and for general +discussion and questions join the `#localization` channel on our Slack. + +__ https://robotframework.crowdin.com/robot-framework + +Enhancements for setting keyword and test tags +---------------------------------------------- + +It is now possible to set tags for all keywords in a certain file by using +the new `Keyword Tags` setting (`#4373`_). It works in resource files and also +in test case and suite initialization files. When used in initialization files, +it only affects keywords in that file and does not propagate to lower level suites. + +The `Force Tags` setting has been renamed to `Test Tags` (`#4368`_). The motivation +is to make settings related to tests more consistent (`Test Setup`, `Test Timeout`, +`Test Tags`, ...) and to better separate settings for specifying test and keyword tags. +Consistent naming also easies translations. The old `Force Tags` setting still works but it +will be `deprecated in the future`__. When creating tasks, it is possible to use +`Task Tags` alias instead of `Test Tags`. + +To simplify setting tags, the `Default Tags` setting will `also be deprecated`__. +The functionality it provides, setting tags that some but no all tests get, +will be enabled in the future by using `-tag` syntax with the `[Tags]` setting +to indicate that a test should not get tag `tag`. This syntax will then work +also in combination with the new `Keyword Tags`. For more details see `#4374`__. + +__ `Force Tags and Default Tags settings`_ +__ `Force Tags and Default Tags settings`_ +__ https://github.com/robotframework/robotframework/issues/4374 + +Enhancements to using keywords with embedded arguments +------------------------------------------------------ + +When using keywords with embedded arguments, it is pretty common that a keyword +that is used matches multiple keyword implementations. For example, +`Execute "ls" with "-lh"` in this example matches both of the keywords: + +.. sourcecode:: robotframework + + *** Test Cases *** + Automatic conflict resolution + Execute "ls" + Execute "ls" with "-lh" + + *** Keywords *** + Execute "${cmd}" + Log Running command '${cmd}'. + + Execute "${cmd}" with "${opts}" + Log Running command '${cmd}' with options '${opts}'. + +Earlier when such conflicts have occurred, execution has failed due to there +being multiple matching keywords. Nowadays Robot Framework tries to find the +best match and use that. In the above example, `Execute "${cmd}" with "${opts}"` +is considered a better match than the more generic `Execute "${cmd}"` and +the example thus succeeds. (`#4454`_) + +There can, however, be cases where there is no single match that would be better +than others. In such cases conflicts cannot be automatically resolved and +execution fails as earlier. + +Another nice enhancement related to keywords using embedded arguments is that +if they are used with `Run Keyword` or its variants, arguments aren't anymore +always converted to strings. This allows passing arguments containing other +values as variables. (`#1595`_) + +Enhancements to keyword namespaces +---------------------------------- + +It is possible to mark keywords in resource files as private by adding +`robot:private` tag to them (`#430`_). If such a keyword is used by keywords +outside that resource file, there will be a warning. These keywords are also +excluded from HTML library documentation generated by Libdoc. + +If a keyword exists in the same resource file as a keyword using it, it will +be used even if there would be keyword with the same name in another resource +file (`#4366`_). Earlier this situation caused a conflict. + +If a keyword exists in the same resource file as a keyword using it and there +is a keyword with the same name in the test case file, the keyword in the test +case file will be used as it has been used earlier. This behavior is nowadays +deprecated__, though, and in the future local keywords will have precedence also +in these cases. + +__ `Keywords in test case files having precedence over local keywords in resource files`_ + +Possibility to disable continue-on-failure mode +----------------------------------------------- + +Robot Framework generally stops executing a keyword or a test case if there +is a failure. Exceptions to this rule include teardowns, templates and +cases where the continue-on-failure mode has been explicitly enabled with +`robot:continue-on-failure` or `robot:recursive-continue-on-failure` +tags. Robot Framework 5.1 makes it possible to disable the implicit or explicit +continue-on-failure mode when needed by using `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags (`#4303`_). + +`start/end_keyword` listener methods get more information about control structures +---------------------------------------------------------------------------------- + +When using the listener API v2, `start_keyword` and `end_keyword` methods are not +only used with keywords but also with all control structures. Earlier these methods +always got exactly the same information, but nowadays there is additional context +specific details with control structures (`#4335`_). + +Performance enhancements for executing user keywords +---------------------------------------------------- + +The overhead in executing user keywords has been reduced. The difference +can be seen especially if user keywords fail often, for example, when using +`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) + +Python 3.11 support +-------------------- + +Robot Framework 5.1 officially supports the forthcoming Python 3.11 +release (`#4401`_). Incompatibilities were not too big, so also the earlier +versions work fairly well. + +At the other end of the spectrum, Python 3.6 is deprecated and will not +anymore be supported by Robot Framework 6.0 (`#4295`_). + + +Backwards incompatible changes +============================== + +- Space is required after `Given/When/Then` prefixes used with BDD scenarios. (`#4379`_) +- Dictionary related keywords in `Collections` require dictionaries to inherit `Mapping`. (`#4413`_) +- `Dictionary Should Contain Item` from the Collections library does not anymore convert + values to strings before comparison. (`#4408`_) +- Generation time in XML and JSON spec files generated by Libdoc has been changed to + `2022-05-27T19:07:15+00:00`. With XML specs the format used to be `2022-05-27T19:07:15Z` + that is equivalent with the new format. JSON spec files did not include the timezone + information at all and the format was `2022-05-27 19:07:15`. (`#4262`_) +- `BuiltIn.run_keyword()` nowadays resolves variables in the name of the keyword to + execute when earlier they were resolved by Robot Framework before calling the keyword. + This affects programmatic usage if the used name contains variables or backslashes. + The change was done when enhancing how keywords with embedded arguments work with + `BuiltIn.run_keyword()`. (`#1595`_) + + +Deprecated features +=================== + +`Force Tags` and `Default Tags` settings +---------------------------------------- + +As `discussed above`__, new `Test Tags` setting has been added to replace `Force Tags` +and there is a plan to remove `Default Tags` altogether. Both of these settings still +work but they are considered deprecated. There is no visible deprecation warning yet, +but such a warning will be emitted starting from Robot Framework 6.0 and eventually these +settings will be removed. (`#4368`_) + +The plan is to add new `-tag` syntax that can be used with the `[Tags]` setting +to enable similar functionality that the `Default Tags` setting provides. Because +of that, using tags starting with a hyphen with the `[Tags]` setting is now deprecated. +If such literal values are needed, it is possible to use escaped format like `\-tag`. +(`#4380`_) + +__ `Enhancements for setting keyword and test tags`_ + +Keywords in test case files having precedence over local keywords in resource files +----------------------------------------------------------------------------------- + +Keywords in test cases files currently always have the highest precedence. They +are used even when a keyword in a resource file uses a keyword that would exist also +in the same resource file. This will change so that local keywords always have +highest precedence and the current behavior is deprecated. (`#4366`_) + +`WITH NAME` in favor of `AS` when giving alias to imported library +------------------------------------------------------------------ + +`WITH NAME` marker that is used when giving an alias to an imported library +will be renamed to `AS` (`#4371`_). The motivation is to be consistent with +Python that uses `as` for similar purpose. We also already use `AS` with +`TRY/EXCEPT` and reusing the same marker and internally used token simplifies +the syntax. Having less markers will also ease translations (but these markers +cannot yet be translated). + +In Robot Framework 5.1 both `AS` and `WITH NAME` work when setting an alias +for a library. `WITH NAME` is considered deprecated, but there will not be +visible deprecation warnings until Robot Framework 6.0. + +Singular section headers like `Test Case` +----------------------------------------- + +Robot Framework has earlier accepted both plural (e.g. `Test Cases`) and singular +(e.g. `Test Case`) section headers. The singular variants are now deprecated +and their support will eventually be removed (`#4431`_). The is no visible +deprecation warning yet, but they will most likely be emitted starting from +Robot Framework 6.0. + +Using variables with embedded arguments so that value does not match custom pattern +----------------------------------------------------------------------------------- + +When keywords accepting embedded arguments are used so that arguments are +passed as variables, variable values are not checked against possible custom +regular expressions. Keywords being called with arguments they explicitly do not +accept is problematic and this behavior will be changed. Due to the backwards +compatibility it is now only deprecated, but validation will be more strict +in the future. (`#4462`_) + +Custom patterns have often been used to avoid conflicts when using embedded arguments. +That need is nowadays smaller because Robot Framework 5.1 can typically resolve +conflicts automatically. (`#4454`_) + +Python 3.6 support +------------------ + +Python 3.6 `reached end-of-life`__ in December 2021. It will be still supported +by Robot Framework 5.1 and all future RF 5.x releases, but not anymore by +Robot Framework 6.0 (`#4295`_). Users are recommended to upgrade to newer +versions already now. + +__ https://endoflife.date/python + + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its ~50 member organizations. Robot Framework 5.1 team funded by the foundation +consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the wider open source community has provided several +great contributions: + +- `Elout van Leeuwen <https://github.com/leeuwe>`_ has lead the localization efforts + (`#4390`_). Individual translations have been provided by the following people: + + - Bosnian by `Namik <https://github.com/Delilovic>`_ + - Czech by `Václav Fuksa <https://github.com/MoreFamed>`_ + - Dutch by `Pim Jansen <https://github.com/pimjansen>`_ and + `Elout van Leeuwen <https://github.com/leeuwe>`_ + - French by `@lesnake <https://github.com/lesnake>`_ + - German by `René <https://github.com/Snooz82>`_ and `Markus <https://github.com/Noordsestern>`_ + - Polish by `Bartłomiej Hirsz <https://github.com/bhirsz>`_ + - Portuguese and Brazilian Portuguese by `Hélio Guilherme <https://github.com/HelioGuilherme66>`_ + - Russian by `Anatoly Kolpakov <https://github.com/axxyhtrx>`_ + - Simplified Chinese by `charis <https://github.com/mawentao119>`_ and `@nixuewei <https://github.com/nixuewei>`_ + - Spanish by Miguel Angel Apolayo Mendoza + - Thai by `Somkiat Puisungnoen <https://github.com/up1>`_ + - Turkish by `Yusuf Can Bayrak <https://github.com/yusufcanb>`_ + - Ukrainian by `@Sunshine0000000 <https://github.com/Sunshine0000000>`_ + +- `Oliver Boehmer <https://github.com/oboehmer>`_ provide several contributions: + + - Support to disable the continue-on-failure mode using `robot:stop-on-failure` and + `robot:recursive-stop-on-failure` tags. (`#4303`_) + - Document that failing test setup stops execution even if the continue-on-failure + mode is active. (`#4404`_) + - Default value to `Get From Dictionary` keyword. (`#4398`_) + - Allow passing explicit flags to regexp related keywords. (`#4429`_) + +- `Ossi R. <https://github.com/osrjv>`_ added more information to `start/end_keyword` + listener methods when they are used with control structures (`#4335`_). + +- `René <https://github.com/Snooz82>`_ fixed Libdoc's HTML outputs if type hints + matched Javascript variables in browser namespace (`#4464`_) or keyword names (`#4471`_). + +- `J. Foederer <https://github.com/JFoederer>`_ enhanced performance of + `Keyword Should Exist` when a keyword is not found (`#4470`_). + +- `Fabio Zadrozny <https://github.com/fabioz>`_ provided a pull request speeding up + user keyword execution (`#4353`_). + +- `@Apteryks <https://github.com/Apteryks>`_ added support to generate deterministic + library documentation by using `SOURCE_DATE_EPOCH`__ environment variable (`#4262`_). + +__ https://reproducible-builds.org/specs/source-date-epoch/ + +Thanks also to all community members who have submitted bug reports, helped debugging +problems, or otherwise helped with the release. + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#4096`_ + - enhancement + - critical + - Multilanguage support for markers used in data + - alpha 1 + * - `#519`_ + - enhancement + - critical + - Given/When/Then should support other languages than English + - alpha 1 + * - `#1595`_ + - bug + - high + - Embedded arguments are not passed as objects when executed with `Run Keyword` or its variants + - beta 2 + * - `#4295`_ + - enhancement + - high + - Deprecate Python 3.6 + - alpha 1 + * - `#430`_ + - enhancement + - high + - Keyword visibility modifiers for resource files + - alpha 1 + * - `#4303`_ + - enhancement + - high + - Support disabling continue-on-failure mode using `robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags + - alpha 1 + * - `#4335`_ + - enhancement + - high + - Pass more information about control structures to `start/end_keyword` listener methods + - beta 1 + * - `#4366`_ + - enhancement + - high + - Give local keywords precedence over imported keywords in resource files + - alpha 1 + * - `#4368`_ + - enhancement + - high + - New `Test Tags` setting as an alias for `Force Tags` + - alpha 1 + * - `#4373`_ + - enhancement + - high + - Support adding tags for all keywords using `Keyword Tags` setting + - alpha 1 + * - `#4380`_ + - enhancement + - high + - Deprecate setting tags starting with a hyphen like `-tag` using the `[Tags]` setting + - alpha 1 + * - `#4388`_ + - enhancement + - high + - Enhance performance of executing user keywords especially when they fail + - alpha 1 + * - `#4400`_ + - enhancement + - high + - Allow translating True and False words used in Boolean argument conversion + - beta 1 + * - `#4401`_ + - enhancement + - high + - Python 3.11 compatibility + - alpha 1 + * - `#4454`_ + - enhancement + - high + - Automatically select "best" match if there is conflict with keywords using embedded arguments + - beta 2 + * - `#4351`_ + - bug + - medium + - Libdoc can give bad error message if library argument has extension matching resource files + - alpha 1 + * - `#4355`_ + - bug + - medium + - Continuable failures terminate WHILE loops + - alpha 1 + * - `#4357`_ + - bug + - medium + - Parsing model: Creating `TRY` and `WHILE` statements using `from_params` is not possible + - alpha 1 + * - `#4359`_ + - bug + - medium + - Parsing model: `Variable.from_params` doesn't handle list values properly + - alpha 1 + * - `#4364`_ + - bug + - medium + - `@{list}` used as embedded argument not anymore expanded if keyword accepts varargs + - beta 1 + * - `#4381`_ + - bug + - medium + - Parsing errors are recognized as EmptyLines + - alpha 1 + * - `#4384`_ + - bug + - medium + - RPA aliases for settings do not work in suite initialization files + - alpha 1 + * - `#4387`_ + - bug + - medium + - Libdoc: Fix storing information about deprecated keywords to spec files + - alpha 1 + * - `#4408`_ + - bug + - medium + - Collection: `Dictionary Should Contain Item` incorrectly casts values to strings before comparison + - alpha 1 + * - `#4418`_ + - bug + - medium + - Dictionaries insider lists in YAML variable files not converted to DotDict objects + - beta 1 + * - `#4438`_ + - bug + - medium + - `Get Time` returns current time if it is given input time that matches epoch + - beta 2 + * - `#4441`_ + - bug + - medium + - Regression: Empty `--include/--exclude/--test/--suite` are not ignored + - beta 2 + * - `#4447`_ + - bug + - medium + - Evaluating expressions that modify evaluation namespace (locals) fail + - beta 1 + * - `#4455`_ + - bug + - medium + - Standard libraries don't support `pathlib.Path` objects + - beta 2 + * - `#4464`_ + - bug + - medium + - Libdoc: Type hints aren't shown for types with same name as Javascript variables available in browser namespace + - beta 2 + * - `#4262`_ + - enhancement + - medium + - Honor `SOURCE_DATE_EPOCH` environment variable when generating library documentation + - alpha 1 + * - `#4312`_ + - enhancement + - medium + - Add project URLs to PyPI + - alpha 1 + * - `#4353`_ + - enhancement + - medium + - Performance enhancements to parsing + - alpha 1 + * - `#4354`_ + - enhancement + - medium + - When merging suites with Rebot, copy documentation and metadata from merged suites + - beta 1 + * - `#4371`_ + - enhancement + - medium + - Add `AS` alias for `WITH NAME` in library imports + - alpha 1 + * - `#4379`_ + - enhancement + - medium + - Require space after Given/When/Then prefixes + - alpha 1 + * - `#4398`_ + - enhancement + - medium + - Collections: `Get From Dictionary` should accept a default value + - alpha 1 + * - `#4404`_ + - enhancement + - medium + - Document that failing test setup stops execution even if continue-on-failure mode is active + - alpha 1 + * - `#4413`_ + - enhancement + - medium + - Dictionary related keywords in `Collections` are more script about accepted values + - alpha 1 + * - `#4429`_ + - enhancement + - medium + - Allow passing flags to regexp related keywords using explicit `flags` argument + - beta 1 + * - `#4431`_ + - enhancement + - medium + - Deprecate using singular section headers + - beta 1 + * - `#4440`_ + - enhancement + - medium + - Allow using `None` as custom argument converter to enable strict type validation + - beta 1 + * - `#4461`_ + - enhancement + - medium + - Automatic argument conversion for `pathlib.Path` + - beta 2 + * - `#4462`_ + - enhancement + - medium + - Deprecate using embedded arguments using variables that do not match custom regexp + - beta 2 + * - `#4470`_ + - enhancement + - medium + - Enhance `Keyword Should Exist` performance by not looking for possible recommendations + - beta 2 + * - `#4349`_ + - bug + - low + - User Guide: Example related to YAML variable files is buggy + - alpha 1 + * - `#4358`_ + - bug + - low + - User Guide: Errors in examples related to TRY/EXCEPT + - alpha 1 + * - `#4453`_ + - bug + - low + - `Run Keywords`: Execution is not continued in teardown if keyword name contains non-existing variable + - beta 2 + * - `#4471`_ + - bug + - low + - Libdoc: If keyword and type have same case-insensitive name, opening type info opens keyword documentation + - beta 2 + * - `#4346`_ + - enhancement + - low + - Enhance documentation of the `--timestampoutputs` option + - alpha 1 + * - `#4372`_ + - enhancement + - low + - Document how to import resource files bundled into Python packages + - alpha 1 + * - `#4394`_ + - bug + - --- + - Error when `--doc` or `--metadata` value matches an existing directory + - alpha 1 + +Altogether 52 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av5.1>`__. + +.. _#4096: https://github.com/robotframework/robotframework/issues/4096 +.. _#519: https://github.com/robotframework/robotframework/issues/519 +.. _#1595: https://github.com/robotframework/robotframework/issues/1595 +.. _#4295: https://github.com/robotframework/robotframework/issues/4295 +.. _#430: https://github.com/robotframework/robotframework/issues/430 +.. _#4303: https://github.com/robotframework/robotframework/issues/4303 +.. _#4335: https://github.com/robotframework/robotframework/issues/4335 +.. _#4366: https://github.com/robotframework/robotframework/issues/4366 +.. _#4368: https://github.com/robotframework/robotframework/issues/4368 +.. _#4373: https://github.com/robotframework/robotframework/issues/4373 +.. _#4380: https://github.com/robotframework/robotframework/issues/4380 +.. _#4388: https://github.com/robotframework/robotframework/issues/4388 +.. _#4400: https://github.com/robotframework/robotframework/issues/4400 +.. _#4401: https://github.com/robotframework/robotframework/issues/4401 +.. _#4454: https://github.com/robotframework/robotframework/issues/4454 +.. _#4351: https://github.com/robotframework/robotframework/issues/4351 +.. _#4355: https://github.com/robotframework/robotframework/issues/4355 +.. _#4357: https://github.com/robotframework/robotframework/issues/4357 +.. _#4359: https://github.com/robotframework/robotframework/issues/4359 +.. _#4364: https://github.com/robotframework/robotframework/issues/4364 +.. _#4381: https://github.com/robotframework/robotframework/issues/4381 +.. _#4384: https://github.com/robotframework/robotframework/issues/4384 +.. _#4387: https://github.com/robotframework/robotframework/issues/4387 +.. _#4408: https://github.com/robotframework/robotframework/issues/4408 +.. _#4418: https://github.com/robotframework/robotframework/issues/4418 +.. _#4438: https://github.com/robotframework/robotframework/issues/4438 +.. _#4441: https://github.com/robotframework/robotframework/issues/4441 +.. _#4447: https://github.com/robotframework/robotframework/issues/4447 +.. _#4455: https://github.com/robotframework/robotframework/issues/4455 +.. _#4464: https://github.com/robotframework/robotframework/issues/4464 +.. _#4262: https://github.com/robotframework/robotframework/issues/4262 +.. _#4312: https://github.com/robotframework/robotframework/issues/4312 +.. _#4353: https://github.com/robotframework/robotframework/issues/4353 +.. _#4354: https://github.com/robotframework/robotframework/issues/4354 +.. _#4371: https://github.com/robotframework/robotframework/issues/4371 +.. _#4379: https://github.com/robotframework/robotframework/issues/4379 +.. _#4398: https://github.com/robotframework/robotframework/issues/4398 +.. _#4404: https://github.com/robotframework/robotframework/issues/4404 +.. _#4413: https://github.com/robotframework/robotframework/issues/4413 +.. _#4429: https://github.com/robotframework/robotframework/issues/4429 +.. _#4431: https://github.com/robotframework/robotframework/issues/4431 +.. _#4440: https://github.com/robotframework/robotframework/issues/4440 +.. _#4461: https://github.com/robotframework/robotframework/issues/4461 +.. _#4462: https://github.com/robotframework/robotframework/issues/4462 +.. _#4470: https://github.com/robotframework/robotframework/issues/4470 +.. _#4349: https://github.com/robotframework/robotframework/issues/4349 +.. _#4358: https://github.com/robotframework/robotframework/issues/4358 +.. _#4453: https://github.com/robotframework/robotframework/issues/4453 +.. _#4471: https://github.com/robotframework/robotframework/issues/4471 +.. _#4346: https://github.com/robotframework/robotframework/issues/4346 +.. _#4372: https://github.com/robotframework/robotframework/issues/4372 +.. _#4394: https://github.com/robotframework/robotframework/issues/4394 +.. _#4390: https://github.com/robotframework/robotframework/issues/4390 From b65c7bc6bb4a59e732e7922adbdabe88f202af7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 21 Sep 2022 23:14:06 +0300 Subject: [PATCH 0210/1592] Updated version to 5.1b2 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index e8dc1684bb9..1b16636171d 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1b2.dev1' +VERSION = '5.1b2' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 6be5c52995b..0d9e718aace 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1b2.dev1' +VERSION = '5.1b2' def get_version(naked=False): From a1802f5bed7a2c1b45330b51f688006fb46fd909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 21 Sep 2022 23:28:59 +0300 Subject: [PATCH 0211/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 1b16636171d..896b3087387 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1b2' +VERSION = '5.1b3.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 0d9e718aace..ba325116017 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1b2' +VERSION = '5.1b3.dev1' def get_version(naked=False): From 4117059b5300eeb9e7ae8f93bb51080eb17a78b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 27 Sep 2022 11:51:40 +0300 Subject: [PATCH 0212/1592] Little test enhancements --- atest/resources/TestCheckerLibrary.py | 3 ++- atest/resources/atest_resource.robot | 9 +++++++++ .../test_libraries/error_msg_and_details.robot | 17 ++++------------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index eff18e34ffa..0c5233d2878 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -320,7 +320,8 @@ def check_log_message(self, item, expected, level='INFO', html=False, pattern=Fa message = item.message.rstrip() if traceback: # Remove `^^^` lines added by Python 3.11+. - message = '\n'.join(line for line in message.splitlines() if line.strip('^ ')) + message = '\n'.join(line for line in message.splitlines() + if '^' not in line or line.strip('^ ')) b = BuiltIn() matcher = b.should_match if pattern else b.should_be_equal matcher(message, expected.rstrip(), 'Wrong log message') diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index c0ae3252ef1..ff0707bea4c 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -377,3 +377,12 @@ Setup Should Not Be Defined Teardown Should Not Be Defined [Arguments] ${model_object} Should Not Be True ${model_object.teardown} + +Traceback Should Be + [Arguments] ${msg} @{entries} ${error} + ${exp} = Set Variable Traceback (most recent call last): + FOR ${path} ${func} ${text} IN @{entries} + ${path} = Normalize Path ${DATADIR}/${path} + ${exp} = Set Variable ${exp}\n${SPACE*2}File "${path}", line *, in ${func}\n${SPACE*4}${text} + END + Check Log Message ${msg} ${exp}\n${error} DEBUG pattern=True traceback=True diff --git a/atest/robot/test_libraries/error_msg_and_details.robot b/atest/robot/test_libraries/error_msg_and_details.robot index 007184a970a..473c040dfe7 100644 --- a/atest/robot/test_libraries/error_msg_and_details.robot +++ b/atest/robot/test_libraries/error_msg_and_details.robot @@ -32,18 +32,18 @@ Multiline Error With CRLF Message And Internal Trace Are Removed From Details When Exception In Library [Template] NONE ${tc} = Verify Test Case And Error In Log Generic Failure foo != bar - Verify Traceback ${tc.kws[0].msgs[1]} + Traceback Should Be ${tc.kws[0].msgs[1]} ... ../testresources/testlibs/ExampleLibrary.py exception raise exception(msg) ... error=AssertionError: foo != bar ${tc} = Verify Test Case And Error In Log Non Generic Failure FloatingPointError: Too Large A Number !! - Verify Traceback ${tc.kws[0].msgs[1]} + Traceback Should Be ${tc.kws[0].msgs[1]} ... ../testresources/testlibs/ExampleLibrary.py exception raise exception(msg) ... error=FloatingPointError: Too Large A Number !! Message and Internal Trace Are Removed From Details When Exception In External Code [Template] NONE ${tc} = Verify Test Case And Error In Log External Failure UnboundLocalError: Raised from an external object! - Verify Traceback ${tc.kws[0].msgs[1]} + Traceback Should Be ${tc.kws[0].msgs[1]} ... ../testresources/testlibs/ExampleLibrary.py external_exception ObjectToReturn('failure').exception(name, msg) ... ../testresources/testlibs/objecttoreturn.py exception raise exception(msg) ... error=UnboundLocalError: Raised from an external object! @@ -62,7 +62,7 @@ Chained exceptions Failure in library in non-ASCII directory [Template] NONE ${tc} = Verify Test Case And Error In Log ${TEST NAME} Keyword in 'nön_äscii_dïr' fails! index=1 - Verify Traceback ${tc.kws[1].msgs[1]} + Traceback Should Be ${tc.kws[1].msgs[1]} ... test_libraries/nön_äscii_dïr/valid.py failing_keyword_in_non_ascii_dir raise AssertionError("Keyword in 'nön_äscii_dïr' fails!") ... error=AssertionError: Keyword in 'nön_äscii_dïr' fails! @@ -103,12 +103,3 @@ Verify Test Case, Error In Log And No Details [Arguments] ${name} ${error} ${msg_index}=${0} ${tc} = Verify Test Case And Error In Log ${name} ${error} 0 ${msg_index} Length Should Be ${tc.kws[0].msgs} ${msg_index + 1} - -Verify Traceback - [Arguments] ${msg} @{entries} ${error} - ${exp} = Set Variable Traceback (most recent call last): - FOR ${path} ${func} ${text} IN @{entries} - ${path} = Normalize Path ${DATADIR}/${path} - ${exp} = Set Variable ${exp}\n${SPACE*2}File "${path}", line *, in ${func}\n${SPACE*4}${text} - END - Check Log Message ${msg} ${exp}\n${error} DEBUG pattern=True traceback=True From a5659a482f491102ebf6f930dd3d2083ef3adebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 27 Sep 2022 12:29:14 +0300 Subject: [PATCH 0213/1592] Fix traceback with chained excaptions. Info about chained exceptions wasn't shown if the top level exception didn't have any traceback information. This is related to #4039. Not showing chained exceptions affected at least BuiltIn.Call Method. Fixes #4476. --- .../builtin/call_method.robot | 14 ++++++++-- src/robot/libraries/BuiltIn.py | 10 +++---- src/robot/utils/error.py | 6 ++++- utest/utils/test_error.py | 27 +++++++++++++++++-- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/call_method.robot b/atest/robot/standard_libraries/builtin/call_method.robot index e6980cf031a..86e02487518 100644 --- a/atest/robot/standard_libraries/builtin/call_method.robot +++ b/atest/robot/standard_libraries/builtin/call_method.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/call_method.robot +Suite Setup Run Tests --loglevel DEBUG standard_libraries/builtin/call_method.robot Resource atest_resource.robot *** Test Cases *** @@ -10,7 +10,17 @@ Call Method Returns Check Test Case ${TEST NAME} Called Method Fails - Check Test Case ${TEST NAME} + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.body[0].msgs[0]} Calling method 'my_method' failed: Expected failure FAIL + ${error} = Catenate SEPARATOR=\n + ... RuntimeError: Expected failure + ... + ... The above exception was the direct cause of the following exception: + ... + ... RuntimeError: Calling method 'my_method' failed: Expected failure + Traceback Should Be ${tc.body[0].msgs[1]} + ... standard_libraries/builtin/objects_for_call_method.py my_method raise RuntimeError('Expected failure') + ... error=${error} Call Method With Kwargs Check Test Case ${TEST NAME} diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 776b6369657..d3dffde4f63 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -3434,13 +3434,13 @@ def call_method(self, object, method_name, *args, **kwargs): try: method = getattr(object, method_name) except AttributeError: - raise RuntimeError("%s object does not have method '%s'." - % (type_name(object), method_name)) + raise RuntimeError(f"{type(object).__name__} object does not have " + f"method '{method_name}'.") try: return method(*args, **kwargs) - except: - raise RuntimeError("Calling method '%s' failed: %s" - % (method_name, get_error_message())) + except Exception as err: + msg = get_error_message() + raise RuntimeError(f"Calling method '{method_name}' failed: {msg}") from err def regexp_escape(self, *patterns): """Returns each argument string escaped for use as a regular expression. diff --git a/src/robot/utils/error.py b/src/robot/utils/error.py index 5d910533a1e..b2874df040e 100644 --- a/src/robot/utils/error.py +++ b/src/robot/utils/error.py @@ -88,6 +88,10 @@ def _remove_robot_traces(self, error): while tb and self._is_robot_traceback(tb): tb = tb.tb_next error.__traceback__ = tb + if error.__context__: + self._remove_robot_traces(error.__context__) + if error.__cause__: + self._remove_robot_traces(error.__cause__) def _is_robot_traceback(self, tb): module = tb.tb_frame.f_globals.get('__name__') @@ -97,7 +101,7 @@ def _get_traceback_lines(self, etype, value, tb): prefix = 'Traceback (most recent call last):\n' empty_tb = [prefix, ' None\n'] if self._full_traceback: - if tb: + if tb or value.__context__ or value.__cause__: return traceback.format_exception(etype, value, tb) else: return empty_tb + traceback.format_exception_only(etype, value) diff --git a/utest/utils/test_error.py b/utest/utils/test_error.py index 9db4c40bb72..0d1528f91f4 100644 --- a/utest/utils/test_error.py +++ b/utest/utils/test_error.py @@ -53,12 +53,35 @@ def test_chaining(self): except Exception: try: raise ValueError - except Exception as err: + except Exception: try: - raise RuntimeError('last error') from err + raise RuntimeError('last error') except Exception as err: assert_equal(ErrorDetails(err).traceback, format_traceback()) + def test_chaining_without_traceback(self): + try: + try: + raise ValueError('lower') + except ValueError as err: + raise RuntimeError('higher') from err + except Exception as err: + err.__traceback__ = None + assert_equal(ErrorDetails(err).traceback, format_traceback()) + + def test_cause(self): + try: + raise ValueError('err') from TypeError('cause') + except ValueError as err: + assert_equal(ErrorDetails(err).traceback, format_traceback()) + + def test_cause_without_traceback(self): + try: + raise ValueError('err') from TypeError('cause') + except ValueError as err: + err.__traceback__ = None + assert_equal(ErrorDetails(err).traceback, format_traceback()) + class TestRemoveRobotEntriesFromTraceback(unittest.TestCase): From d771d36b58b57d3d9eb80db3ec0d8113582d3415 Mon Sep 17 00:00:00 2001 From: F3licity <eftychia.thomaidou@humanitec.com> Date: Tue, 27 Sep 2022 17:39:32 +0200 Subject: [PATCH 0214/1592] Explain Sleep's default value is seconds (#4479) --- src/robot/libraries/BuiltIn.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index d3dffde4f63..049c0a62c7b 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -2891,7 +2891,9 @@ def sleep(self, time_, reason=None): ``time`` may be either a number or a time string. Time strings are in a format such as ``1 day 2 hours 3 minutes 4 seconds 5milliseconds`` or ``1d 2h 3m 4s 5ms``, and they are fully explained in an appendix of - Robot Framework User Guide. Optional `reason` can be used to explain why + Robot Framework User Guide. Providing a value without specifying minutes + or seconds, defaults to seconds. + Optional `reason` can be used to explain why sleeping is necessary. Both the time slept and the reason are logged. Examples: From 41053c96fb67a1d336fc34f9fc907795e308f7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 27 Sep 2022 14:19:06 +0300 Subject: [PATCH 0215/1592] Cleanup - super() - f-strings - unused imports - ... --- src/robot/running/bodyrunner.py | 93 +++++++++++++-------------------- src/robot/running/handlers.py | 29 +++++----- src/robot/running/namespace.py | 2 +- 3 files changed, 51 insertions(+), 73 deletions(-) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 9a0d077df78..9b7f2a1228e 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -153,13 +153,12 @@ def _is_dict_iteration(self, values): if all_name_value: name, value = split_from_equals(values[0]) logger.warn( - "FOR loop iteration over values that are all in 'name=value' " - "format like '%s' is deprecated. In the future this syntax " - "will mean iterating over names and values separately like " - "when iterating over '&{dict} variables. Escape at least one " - "of the values like '%s\\=%s' to use normal FOR loop " - "iteration and to disable this warning." - % (values[0], name, value) + f"FOR loop iteration over values that are all in 'name=value' " + f"format like '{values[0]}' is deprecated. In the future this syntax " + f"will mean iterating over names and values separately like " + f"when iterating over '&{{dict}} variables. Escape at least one " + f"of the values like '{name}\\={value}' to use normal FOR loop " + f"iteration and to disable this warning." ) return False @@ -172,26 +171,20 @@ def _resolve_dict_values(self, values): else: key, value = split_from_equals(item) if value is None: - raise DataError( - "Invalid FOR loop value '%s'. When iterating over " - "dictionaries, values must be '&{dict}' variables " - "or use 'key=value' syntax." % item - ) + raise DataError(f"Invalid FOR loop value '{item}'. When iterating " + f"over dictionaries, values must be '&{{dict}}' " + f"variables or use 'key=value' syntax.") try: result[replace_scalar(key)] = replace_scalar(value) except TypeError: - raise DataError( - "Invalid dictionary item '%s': %s" - % (item, get_error_message()) - ) + err = get_error_message() + raise DataError(f"Invalid dictionary item '{item}': {err}") return result.items() def _map_dict_values_to_rounds(self, values, per_round): if per_round > 2: - raise DataError( - 'Number of FOR loop variables must be 1 or 2 when iterating ' - 'over dictionaries, got %d.' % per_round - ) + raise DataError(f'Number of FOR loop variables must be 1 or 2 when ' + f'iterating over dictionaries, got {per_round}.') return values def _resolve_values(self, values): @@ -205,10 +198,9 @@ def _map_values_to_rounds(self, values, per_round): return (values[i:i+per_round] for i in range(0, count, per_round)) def _raise_wrong_variable_count(self, variables, values): - raise DataError( - 'Number of FOR loop values should be multiple of its variables. ' - 'Got %d variables but %d value%s.' % (variables, values, s(values)) - ) + raise DataError(f'Number of FOR loop values should be multiple of its ' + f'variables. Got {variables} variables but {values} ' + f'value{s(values)}.') def _run_one_round(self, data, result, values=None, run=True): result = result.body.create_iteration() @@ -234,22 +226,17 @@ class ForInRangeRunner(ForInRunner): flavor = 'IN RANGE' def _resolve_dict_values(self, values): - raise DataError( - 'FOR IN RANGE loops do not support iterating over dictionaries.' - ) + raise DataError('FOR IN RANGE loops do not support iterating over ' + 'dictionaries.') def _map_values_to_rounds(self, values, per_round): if not 1 <= len(values) <= 3: - raise DataError( - 'FOR IN RANGE expected 1-3 values, got %d.' % len(values) - ) + raise DataError(f'FOR IN RANGE expected 1-3 values, got {len(values)}.') try: values = [self._to_number_with_arithmetic(v) for v in values] - except: - raise DataError( - 'Converting FOR IN RANGE values failed: %s.' - % get_error_message() - ) + except Exception: + msg = get_error_message() + raise DataError(f'Converting FOR IN RANGE values failed: {msg}.') values = frange(*values) return ForInRunner._map_values_to_rounds(self, values, per_round) @@ -258,7 +245,7 @@ def _to_number_with_arithmetic(self, item): return item number = eval(str(item), {}) if not is_number(number): - raise TypeError("Expected number, got %s." % type_name(item)) + raise TypeError(f'Expected number, got {type_name(item)}.') return number @@ -267,17 +254,13 @@ class ForInZipRunner(ForInRunner): _start = 0 def _resolve_dict_values(self, values): - raise DataError( - 'FOR IN ZIP loops do not support iterating over dictionaries.' - ) + raise DataError('FOR IN ZIP loops do not support iterating over dictionaries.') def _map_values_to_rounds(self, values, per_round): for item in values: if not is_list_like(item): - raise DataError( - "FOR IN ZIP items must all be list-like, got %s '%s'." - % (type_name(item), item) - ) + raise DataError(f"FOR IN ZIP items must all be list-like, " + f"got {type_name(item)} '{item}'.") if len(values) % per_round != 0: self._raise_wrong_variable_count(per_round, len(values)) return zip(*(list(item) for item in values)) @@ -303,14 +286,12 @@ def _get_start(self, values): try: return int(start), values[:-1] except ValueError: - raise ValueError("Invalid FOR IN ENUMERATE start value '%s'." % start) + raise ValueError(f"Invalid FOR IN ENUMERATE start value '{start}'.") def _map_dict_values_to_rounds(self, values, per_round): if per_round > 3: - raise DataError( - 'Number of FOR IN ENUMERATE loop variables must be 1-3 when ' - 'iterating over dictionaries, got %d.' % per_round - ) + raise DataError(f'Number of FOR IN ENUMERATE loop variables must be ' + f'1-3 when iterating over dictionaries, got {per_round}.') if per_round == 2: return ((i, v) for i, v in enumerate(values, start=self._start)) return ((i,) + v for i, v in enumerate(values, start=self._start)) @@ -321,11 +302,9 @@ def _map_values_to_rounds(self, values, per_round): return ([i] + v for i, v in enumerate(values, start=self._start)) def _raise_wrong_variable_count(self, variables, values): - raise DataError( - 'Number of FOR IN ENUMERATE loop values should be multiple of ' - 'its variables (excluding the index). Got %d variables but %d ' - 'value%s.' % (variables, values, s(values)) - ) + raise DataError(f'Number of FOR IN ENUMERATE loop values should be multiple of ' + f'its variables (excluding the index). Got {variables} ' + f'variables but {values} value{s(values)}.') class WhileRunner: @@ -431,7 +410,7 @@ def _run_if_branch(self, branch, recursive_dry_run=False, error=None): else: try: run_branch = self._should_run_branch(branch, context, recursive_dry_run) - except: + except Exception: error = get_error_message() run_branch = False with StatusReporter(branch, result, context, run_branch): @@ -605,9 +584,9 @@ def create(cls, limit, variables): return InvalidLimit(error) def limit_exceeded(self): - raise ExecutionFailed(f"WHILE loop was aborted because it did not finish within the " - f"limit of {self}. Use the 'limit' argument to increase or " - f"remove the limit if needed.") + raise ExecutionFailed(f"WHILE loop was aborted because it did not finish " + f"within the limit of {self}. Use the 'limit' argument " + f"to increase or remove the limit if needed.") def __enter__(self): raise NotImplementedError diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py index a62a01d36ee..5ec67e529b6 100644 --- a/src/robot/running/handlers.py +++ b/src/robot/running/handlers.py @@ -74,8 +74,7 @@ def _parse_arguments(self, handler_method): def _get_tags_from_attribute(self, handler_method): tags = getattr(handler_method, 'robot_tags', ()) if not is_list_like(tags): - raise DataError("Expected tags to be list-like, got %s." - % type_name(tags)) + raise DataError(f"Expected tags to be list-like, got {type_name(tags)}.") return tags def _get_initial_handler(self, library, name, method): @@ -93,7 +92,7 @@ def doc(self): @property def longname(self): - return '%s.%s' % (self.library.name, self.name) + return f'{self.library.name}.{self.name}' @property def shortdoc(self): @@ -135,8 +134,7 @@ def _get_handler(self, lib_instance, handler_name): class _PythonHandler(_RunnableHandler): def __init__(self, library, handler_name, handler_method): - _RunnableHandler.__init__(self, library, handler_name, handler_method, - getdoc(handler_method)) + super().__init__(library, handler_name, handler_method, getdoc(handler_method)) def _parse_arguments(self, handler_method): return PythonArgumentParser().parse(handler_method, self.longname) @@ -166,25 +164,26 @@ def lineno(self): class _DynamicHandler(_RunnableHandler): - def __init__(self, library, handler_name, dynamic_method, doc='', - argspec=None, tags=None): + def __init__(self, library, handler_name, dynamic_method, doc='', argspec=None, + tags=None): self._argspec = argspec self._run_keyword_method_name = dynamic_method.name self._supports_kwargs = dynamic_method.supports_kwargs - _RunnableHandler.__init__(self, library, handler_name, - dynamic_method.method, doc, tags) + # Cannot use super() here due to multi-inheritance in _DynamicRunKeywordHandler + _RunnableHandler.__init__(self, library, handler_name, dynamic_method.method, + doc, tags) self._source_info = None def _parse_arguments(self, handler_method): spec = DynamicArgumentParser().parse(self._argspec, self.longname) if not self._supports_kwargs: + name = self._run_keyword_method_name if spec.var_named: - raise DataError("Too few '%s' method parameters for **kwargs " - "support." % self._run_keyword_method_name) + raise DataError(f"Too few '{name}' method parameters for " + f"**kwargs support.") if spec.named_only: - raise DataError("Too few '%s' method parameters for " - "keyword-only arguments support." - % self._run_keyword_method_name) + raise DataError(f"Too few '{name}' method parameters for " + f"keyword-only arguments support.") get_keyword_types = GetKeywordTypes(self.library.get_instance()) spec.types = get_keyword_types(self._handler_name) return spec @@ -264,7 +263,7 @@ class _DynamicRunKeywordHandler(_DynamicHandler, _RunKeywordHandler): class _PythonInitHandler(_PythonHandler): def __init__(self, library, handler_name, handler_method, docgetter): - _PythonHandler.__init__(self, library, handler_name, handler_method) + super().__init__(library, handler_name, handler_method) self._docgetter = docgetter def _get_name(self, handler_name, handler_method): diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 52427f7ce82..e8d87fc1bd5 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -22,7 +22,7 @@ from robot.libraries import STDLIBS from robot.output import LOGGER, Message from robot.utils import (RecommendationFinder, eq, find_file, is_string, normalize, - plural_or_not as s, printable_name, seq2str, seq2str2) + printable_name, seq2str2) from .context import EXECUTION_CONTEXTS from .importer import ImportCache, Importer From ffa0d4e65795d2392bf3166b33eb7ff9800e27c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 27 Sep 2022 16:20:00 +0300 Subject: [PATCH 0216/1592] Make DataError normal catchable error by default. It can be turned into a syntax error with `syntax=True`. Makes IF and WHILE condition errors not being catchable. That fixes #4348. Also changes error if a keyword is used with wrong number of arguments to a normal error. That's consistent with keywords not being found already earlier causing catchable errors. There could be similar semantical changes already elsewhere. --- atest/robot/running/if/invalid_if.robot | 12 ++++ atest/robot/running/test_template.robot | 2 +- atest/robot/running/while/invalid_while.robot | 9 +++ .../builtin/run_keyword_with_errors.robot | 8 +-- .../builtin/wait_until_keyword_succeeds.robot | 11 ++-- atest/testdata/running/if/invalid_if.robot | 56 +++++++++++++++++++ atest/testdata/running/test_template.robot | 19 +++++-- .../test_template_with_embeded_args.robot | 17 +++++- .../running/while/invalid_while.robot | 31 +++++++++- .../run_keyword_and_continue_on_failure.robot | 7 ++- .../run_keyword_and_warn_on_failure.robot | 7 ++- .../builtin/run_keyword_with_errors.robot | 9 ++- .../builtin/wait_until_keyword_succeeds.robot | 22 ++++++-- src/robot/errors.py | 10 +++- src/robot/running/bodyrunner.py | 46 ++++++++------- src/robot/running/model.py | 6 +- src/robot/running/namespace.py | 4 +- src/robot/running/statusreporter.py | 3 +- src/robot/variables/assigner.py | 10 ++-- 19 files changed, 223 insertions(+), 66 deletions(-) diff --git a/atest/robot/running/if/invalid_if.robot b/atest/robot/running/if/invalid_if.robot index a720be8347d..76c97c9277b 100644 --- a/atest/robot/running/if/invalid_if.robot +++ b/atest/robot/running/if/invalid_if.robot @@ -64,6 +64,18 @@ Invalid IF inside FOR Multiple errors FAIL NOT RUN NOT RUN NOT RUN NOT RUN +Invalid data causes syntax error + [Template] NONE + Check Test Case ${TEST NAME} + +Invalid condition causes normal error + [Template] NONE + Check Test Case ${TEST NAME} + +Non-existing variable in condition causes normal error + [Template] NONE + Check Test Case ${TEST NAME} + *** Keywords *** Branch statuses should be [Arguments] @{statuses} diff --git a/atest/robot/running/test_template.robot b/atest/robot/running/test_template.robot index c6306bc90d2..a6faecac8dd 100644 --- a/atest/robot/running/test_template.robot +++ b/atest/robot/running/test_template.robot @@ -116,7 +116,7 @@ Templated test with for loop continues after keyword timeout Templated test ends after syntax errors Check Test Case ${TESTNAME} -Templated test continues after variable error +Templated test continues after non-syntax errors Check Test Case ${TESTNAME} Templates and fatal errors diff --git a/atest/robot/running/while/invalid_while.robot b/atest/robot/running/while/invalid_while.robot index c6687e9b247..d650540183b 100644 --- a/atest/robot/running/while/invalid_while.robot +++ b/atest/robot/running/while/invalid_while.robot @@ -21,3 +21,12 @@ No body No END Check Test Case ${TESTNAME} + +Invalid data causes syntax error + Check Test Case ${TEST NAME} + +Invalid condition causes normal error + Check Test Case ${TEST NAME} + +Non-existing variable in condition causes normal error + Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/run_keyword_with_errors.robot b/atest/robot/standard_libraries/builtin/run_keyword_with_errors.robot index f3924a1d034..dbd75652954 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_with_errors.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_with_errors.robot @@ -41,10 +41,10 @@ Ignore Error When Timeout Occurs Ignore Error When Timeout Occurs In UK Check Test Case ${TEST NAME} -Ignore Error When Syntax Error At Parsing Time +Ignore Error Cannot Catch Syntax Errors Check Test Case ${TEST NAME} -Ignore Error When Syntax Error At Run Time +Ignore Error Can Catch Non-Syntax Errors Check Test Case ${TEST NAME} Ignore Error When Syntax Error In Setting Variables @@ -119,10 +119,10 @@ Expect Error When Timeout Occurs Expect Error When Timeout Occurs In UK Check Test Case ${TEST NAME} -Expect Error When Syntax Error At Parsing Time +Expect Error Cannot Catch Syntax Errors Check Test Case ${TEST NAME} -Expect Error When Syntax Error At Run Time +Expect Error Can Catch Non-Syntax Errors Check Test Case ${TEST NAME} Expect Error When Syntax Error In Setting Variables diff --git a/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot b/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot index 6fb39ca708b..a8ab7e8bbda 100644 --- a/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot +++ b/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot @@ -69,16 +69,19 @@ Retry count must be positive Check Test Case ${TESTNAME} 1 Check Test Case ${TESTNAME} 2 -Invalid Number Of Arguments Inside Wait Until Keyword Succeeds +No retry after syntax error Check Test Case ${TESTNAME} -Invalid Keyword Inside Wait Until Keyword Succeeds +No retry if keyword name is not string Check Test Case ${TESTNAME} -Keyword Not Found Inside Wait Until Keyword Succeeds +Retry if keyword is not found Check Test Case ${TESTNAME} -Fail With Nonexisting Variable Inside Wait Until Keyword Succeeds +Retry if wrong number of arguments + Check Test Case ${TESTNAME} + +Retry if variable is not found ${tc} = Check Test Case ${TESTNAME} Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Variable '\${nonexisting}' not found. FAIL Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} Variable '\${nonexisting}' not found. FAIL diff --git a/atest/testdata/running/if/invalid_if.robot b/atest/testdata/running/if/invalid_if.robot index f2e8594b256..946fb9dd609 100644 --- a/atest/testdata/running/if/invalid_if.robot +++ b/atest/testdata/running/if/invalid_if.robot @@ -167,3 +167,59 @@ Multiple errors ELSE oops ELSE IF ELSE + +Invalid data causes syntax error + [Documentation] FAIL IF branch cannot be empty. + TRY + IF True + END + EXCEPT + Fail Syntax error cannot be caught + END + +Invalid condition causes normal error + [Documentation] FAIL Teardown failed: + ... Several failures occurred: + ... + ... 1) Evaluating IF condition failed: Evaluating expression 'bad in teardown' failed: NameError: name 'bad' is not defined nor importable as module + ... + ... 2) Should be run in teardown + TRY + IF bad + Fail Should not be run + END + EXCEPT Evaluating IF condition failed: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + No Operation + END + [Teardown] Invalid condition + +Non-existing variable in condition causes normal error + [Documentation] FAIL Teardown failed: + ... Several failures occurred: + ... + ... 1) Evaluating IF condition failed: Variable '\${bad}' not found. + ... + ... 2) Should be run in teardown + TRY + IF ${bad} + Fail Should not be run + END + EXCEPT Evaluating IF condition failed: Variable '\${bad}' not found. + No Operation + END + [Teardown] Non-existing variable in condition + +*** Keywords *** +Invalid condition + IF bad in teardown + Fail Should not be run + ELSE + Fail Sould not be run either + END + Fail Should be run in teardown + +Non-existing variable in condition + IF ${bad} + Fail Should not be run + END + Fail Should be run in teardown diff --git a/atest/testdata/running/test_template.robot b/atest/testdata/running/test_template.robot index 708eefd9543..92c2529706c 100644 --- a/atest/testdata/running/test_template.robot +++ b/atest/testdata/running/test_template.robot @@ -327,18 +327,22 @@ Templated test with for loop continues after keyword timeout END Templated test ends after syntax errors - [Documentation] FAIL Keyword 'BuiltIn.Should Be Equal' expected 2 to 8 arguments, got 9. - The syntax error makes any test end again here - Not compared anymore + [Documentation] FAIL IF must have closing END. + [Template] Syntax Error + fails here + not run -Templated test continues after variable error +Templated test continues after non-syntax errors [Documentation] FAIL ... Several failures occurred: ... ... 1) Variable '\${this does not exist}' not found. ... - ... 2) Compared and not equal != Fails + ... 2) Keyword 'BuiltIn.Should Be Equal' expected 2 to 8 arguments, got 1. + ... + ... 3) Compared and not equal != Fails ${this does not exist} ${this does not exist either} + Too few args Compared and equal Compared and equal Compared and not equal Fails @@ -393,3 +397,8 @@ Template with timeout [Timeout] ${timeout} Sleep ${sleep} Fail Failing after ${sleep} sleep and before ${timeout} timeout. + +Syntax Error + [Arguments] ${arg} + IF ${arg} + Fail Should not be run due to END missing. diff --git a/atest/testdata/running/test_template_with_embeded_args.robot b/atest/testdata/running/test_template_with_embeded_args.robot index dbb0db0f09c..b665506e8cb 100644 --- a/atest/testdata/running/test_template_with_embeded_args.robot +++ b/atest/testdata/running/test_template_with_embeded_args.robot @@ -14,7 +14,12 @@ Argument names do not need to be same as in definition 1 + 3 5 Some arguments can be hard-coded - [Documentation] FAIL Several failures occurred:\n\n1) 2 != 3\n\n2) 4 != 3 + [Documentation] FAIL + ... Several failures occurred: + ... + ... 1) 2 != 3 + ... + ... 2) 4 != 3 [Template] The result of ${expression} should be 3 1 + 1 1 + 2 @@ -31,12 +36,18 @@ Can use variables ${1} + ${2} ${3} Cannot have more arguments than variables - [Documentation] FAIL Keyword 'The result of ${calculation} should be ${expected}' expected 0 arguments, got 2. + [Documentation] FAIL + ... Keyword 'The result of \${calculation} should be \${expected}' expected 0 arguments, got 2. [Template] The result of ${calc} should be 3 1 + 2 extra Cannot have less arguments than variables - [Documentation] FAIL Keyword 'The result of ${calculation} should be ${expected}' expected 0 arguments, got 1. + [Documentation] FAIL + ... Several failures occurred: + ... + ... 1) Keyword 'The result of \${calculation} should be \${expected}' expected 0 arguments, got 1. + ... + ... 2) Keyword 'The result of \${calculation} should be \${expected}' expected 0 arguments, got 1. [Template] The result of ${calc} should be ${extra} 1 + 2 4 - 1 diff --git a/atest/testdata/running/while/invalid_while.robot b/atest/testdata/running/while/invalid_while.robot index 42f9e73e9b8..a86f0061b10 100644 --- a/atest/testdata/running/while/invalid_while.robot +++ b/atest/testdata/running/while/invalid_while.robot @@ -12,13 +12,13 @@ Multiple conditions END Invalid condition - [Documentation] FAIL STARTS: Evaluating WHILE loop condition failed: Evaluating expression 'ooops!' failed: SyntaxError: + [Documentation] FAIL STARTS: Evaluating WHILE condition failed: Evaluating expression 'ooops!' failed: SyntaxError: WHILE ooops! Fail Not executed! END Non-existing variable in condition - [Documentation] FAIL Evaluating WHILE loop condition failed: Variable '\${ooops}' not found. + [Documentation] FAIL Evaluating WHILE condition failed: Variable '\${ooops}' not found. WHILE ${ooops} Fail Not executed! END @@ -32,3 +32,30 @@ No END [Documentation] FAIL WHILE loop must have closing END. WHILE True Fail Not executed! + +Invalid data causes syntax error + [Documentation] FAIL WHILE loop cannot be empty. + TRY + WHILE False + END + EXCEPT + Fail Syntax error cannot be caught + END + +Invalid condition causes normal error + TRY + WHILE bad + Fail Should not be run + END + EXCEPT Evaluating WHILE condition failed: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + No Operation + END + +Non-existing variable in condition causes normal error + TRY + WHILE ${bad} + Fail Should not be run + END + EXCEPT Evaluating WHILE condition failed: Variable '\${bad}' not found. + No Operation + END diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_and_continue_on_failure.robot b/atest/testdata/standard_libraries/builtin/run_keyword_and_continue_on_failure.robot index 1b620fefc07..1ab5416bed4 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_and_continue_on_failure.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_and_continue_on_failure.robot @@ -57,8 +57,8 @@ Run Keyword And Continue On Failure with failure in keyoword teardown Fail The End Run Keyword And Continue On Failure With Syntax Error - [Documentation] FAIL Keyword 'BuiltIn.No Operation' expected 0 arguments, got 1. - Run keyword And Continue On Failure No Operation illegal argument + [Documentation] FAIL Assign mark '=' can be used only with the last variable. + Run keyword And Continue On Failure Syntax Error Fail This Should Not Be Executed! Run Keyword And Continue On Failure With Timeout @@ -107,3 +107,6 @@ RKACOF in UK 2 Keyword With Failing Teardown No Operation [Teardown] Fail Expected error + +Syntax Error + ${x} = ${y} = Create List x y diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_and_warn_on_failure.robot b/atest/testdata/standard_libraries/builtin/run_keyword_and_warn_on_failure.robot index 1cd366751a4..4b12c82f866 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_and_warn_on_failure.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_and_warn_on_failure.robot @@ -18,8 +18,8 @@ Run User keyword And Warn On Failure Log This should be executed Run Keyword And Warn On Failure With Syntax Error - [Documentation] FAIL Keyword 'BuiltIn.No Operation' expected 0 arguments, got 1. - Run keyword And Warn On Failure No Operation illegal argument + [Documentation] FAIL Assign mark '=' can be used only with the last variable. + Run keyword And Continue On Failure Syntax Error Fail This Should Not Be Executed! Run Keyword And Warn On Failure With Failure On Test Teardown @@ -40,3 +40,6 @@ Failing Keyword Teardown Exception In User Defined Keyword Fail Expected Warn In User Keyword + +Syntax Error + ${x} = ${y} = Create List x y diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot b/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot index 9feaf85a515..b34fabd254e 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot @@ -51,12 +51,11 @@ Ignore Error When Timeout Occurs In UK [Documentation] FAIL Keyword timeout 100 milliseconds exceeded. Run Keyword And Ignore Error Timeouting UK -Ignore Error When Syntax Error At Parsing Time +Ignore Error Cannot Catch Syntax Errors [Documentation] FAIL Keyword name cannot be empty. Run Keyword And Ignore Error Broken User Keyword -Ignore Error When Syntax Error At Run Time - [Documentation] FAIL Keyword 'BuiltIn.No Operation' expected 0 arguments, got 4. +Ignore Error Can Catch Non-Syntax Errors Run Keyword And Ignore Error No Operation wrong number of arguments Ignore Error When Syntax Error In Setting Variables @@ -155,11 +154,11 @@ Expect Error When Timeout Occurs In UK [Documentation] FAIL Keyword timeout 100 milliseconds exceeded. Run Keyword And Expect Error * Timeouting UK -Expect Error When Syntax Error At Parsing Time +Expect Error Cannot Catch Syntax Errors [Documentation] FAIL Keyword name cannot be empty. Run Keyword And Expect Error * Broken User Keyword -Expect Error When Syntax Error At Run Time +Expect Error Can Catch Non-Syntax Errors Run Keyword And Expect Error ... No keyword with name 'Non existing keyword' found. ... Non existing keyword diff --git a/atest/testdata/standard_libraries/builtin/wait_until_keyword_succeeds.robot b/atest/testdata/standard_libraries/builtin/wait_until_keyword_succeeds.robot index c3fc10b7028..1ebf347ea9f 100644 --- a/atest/testdata/standard_libraries/builtin/wait_until_keyword_succeeds.robot +++ b/atest/testdata/standard_libraries/builtin/wait_until_keyword_succeeds.robot @@ -106,22 +106,28 @@ Retry count must be positive 2 [Documentation] FAIL ValueError: Retry count -8 is not positive. Wait Until Keyword Succeeds -8x 1s No Operation -Invalid Number Of Arguments Inside Wait Until Keyword Succeeds - [Documentation] FAIL Keyword 'BuiltIn.No Operation' expected 0 arguments, got 3. - Wait Until Keyword Succeeds 1 second 0.1s No Operation No args accepted +No retry after syntax error + [Documentation] FAIL FOR loop cannot be empty. + Wait Until Keyword Succeeds 10 second 1s Syntax Error -Invalid Keyword Inside Wait Until Keyword Succeeds +No retry if keyword name is not string [Documentation] FAIL Keyword name must be a string. ${list} = Create List 1 2 Wait Until Keyword Succeeds 1 second 0.1s ${list} -Keyword Not Found Inside Wait Until Keyword Succeeds +Retry if keyword is not found [Documentation] FAIL ... Keyword 'Non Existing KW' failed after retrying for 300 milliseconds. \ ... The last error was: No keyword with name 'Non Existing KW' found. Wait Until Keyword Succeeds 0.3s 0.1s Non Existing KW -Fail With Nonexisting Variable Inside Wait Until Keyword Succeeds +Retry if wrong number of arguments + [Documentation] FAIL + ... Keyword 'No Operation' failed after retrying for 50 milliseconds. \ + ... The last error was: Keyword 'BuiltIn.No Operation' expected 0 arguments, got 3. + Wait Until Keyword Succeeds 0.05 second 0.01s No Operation No args accepted + +Retry if variable is not found [Documentation] FAIL ... Keyword 'Access Nonexisting Variable' failed after retrying 3 times. \ ... The last error was: Variable '\${nonexisting}' not found. @@ -166,3 +172,7 @@ Access Nonexisting Variable Access Initially Nonexisting Variable Log ${created after accessing first time} [Teardown] Set Test Variable ${created after accessing first time} created in keyword teardown + +Syntax Error + FOR ${x} IN cannot have empty body + END diff --git a/src/robot/errors.py b/src/robot/errors.py index 2d5c4c0c044..29b1dc4e1db 100644 --- a/src/robot/errors.py +++ b/src/robot/errors.py @@ -57,6 +57,9 @@ class DataError(RobotError): DataErrors are not caught by keywords that run other keywords (e.g. `Run Keyword And Expect Error`). """ + def __init__(self, message='', details='', syntax=False): + super().__init__(message, details) + self.syntax = syntax class VariableError(DataError): @@ -65,6 +68,8 @@ class VariableError(DataError): VariableErrors are caught by keywords that run other keywords (e.g. `Run Keyword And Expect Error`). """ + def __init__(self, message='', details=''): + super().__init__(message, details) class KeywordError(DataError): @@ -73,6 +78,8 @@ class KeywordError(DataError): KeywordErrors are caught by keywords that run other keywords (e.g. `Run Keyword And Expect Error`). """ + def __init__(self, message='', details=''): + super().__init__(message, details) class TimeoutError(RobotError): @@ -165,8 +172,7 @@ def __init__(self, details): timeout = isinstance(error, TimeoutError) test_timeout = timeout and error.test_timeout keyword_timeout = timeout and error.keyword_timeout - syntax = (isinstance(error, DataError) - and not isinstance(error, (KeywordError, VariableError))) + syntax = isinstance(error, DataError) and error.syntax exit_on_failure = self._get(error, 'EXIT_ON_FAILURE') continue_on_failure = self._get(error, 'CONTINUE_ON_FAILURE') skip = self._get(error, 'SKIP_EXECUTION') diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 9b7f2a1228e..f278a1a5482 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -98,7 +98,7 @@ def run(self, data): run_at_least_once = False if self._run: if data.error: - raise DataError(data.error) + raise DataError(data.error, syntax=True) run_at_least_once = self._run_loop(data, result) if not run_at_least_once: status.pass_status = result.NOT_RUN @@ -173,7 +173,7 @@ def _resolve_dict_values(self, values): if value is None: raise DataError(f"Invalid FOR loop value '{item}'. When iterating " f"over dictionaries, values must be '&{{dict}}' " - f"variables or use 'key=value' syntax.") + f"variables or use 'key=value' syntax.", syntax=True) try: result[replace_scalar(key)] = replace_scalar(value) except TypeError: @@ -184,7 +184,8 @@ def _resolve_dict_values(self, values): def _map_dict_values_to_rounds(self, values, per_round): if per_round > 2: raise DataError(f'Number of FOR loop variables must be 1 or 2 when ' - f'iterating over dictionaries, got {per_round}.') + f'iterating over dictionaries, got {per_round}.', + syntax=True) return values def _resolve_values(self, values): @@ -227,11 +228,12 @@ class ForInRangeRunner(ForInRunner): def _resolve_dict_values(self, values): raise DataError('FOR IN RANGE loops do not support iterating over ' - 'dictionaries.') + 'dictionaries.', syntax=True) def _map_values_to_rounds(self, values, per_round): if not 1 <= len(values) <= 3: - raise DataError(f'FOR IN RANGE expected 1-3 values, got {len(values)}.') + raise DataError(f'FOR IN RANGE expected 1-3 values, got {len(values)}.', + syntax=True) try: values = [self._to_number_with_arithmetic(v) for v in values] except Exception: @@ -254,7 +256,8 @@ class ForInZipRunner(ForInRunner): _start = 0 def _resolve_dict_values(self, values): - raise DataError('FOR IN ZIP loops do not support iterating over dictionaries.') + raise DataError('FOR IN ZIP loops do not support iterating over dictionaries.', + syntax=True) def _map_values_to_rounds(self, values, per_round): for item in values: @@ -282,7 +285,7 @@ def _get_start(self, values): return 0, values start = self._context.variables.replace_string(values[-1][6:]) if len(values) == 1: - raise DataError('FOR loop has no loop values.') + raise DataError('FOR loop has no loop values.', syntax=True) try: return int(start), values[:-1] except ValueError: @@ -290,8 +293,9 @@ def _get_start(self, values): def _map_dict_values_to_rounds(self, values, per_round): if per_round > 3: - raise DataError(f'Number of FOR IN ENUMERATE loop variables must be ' - f'1-3 when iterating over dictionaries, got {per_round}.') + raise DataError(f'Number of FOR IN ENUMERATE loop variables must be 1-3 ' + f'when iterating over dictionaries, got {per_round}.', + syntax=True) if per_round == 2: return ((i, v) for i, v in enumerate(values, start=self._start)) return ((i,) + v for i, v in enumerate(values, start=self._start)) @@ -326,7 +330,7 @@ def run(self, data): pass return if data.error: - raise DataError(data.error) + raise DataError(data.error, syntax=True) limit = WhileLimit.create(data.limit, self._context.variables) errors = [] while self._should_run(data.condition, self._context.variables) \ @@ -362,8 +366,9 @@ def _should_run(self, condition, variables): if is_string(condition): return evaluate_expression(condition, variables.current.store) return bool(condition) - except DataError as err: - raise DataError(f'Evaluating WHILE loop condition failed: {err}') + except Exception: + msg = get_error_message() + raise DataError(f'Evaluating WHILE condition failed: {msg}') class IfRunner: @@ -402,23 +407,25 @@ def _dry_run_recursion_detection(self, data): if dry_run: self._dry_run_stack.pop() - def _run_if_branch(self, branch, recursive_dry_run=False, error=None): + def _run_if_branch(self, branch, recursive_dry_run=False, syntax_error=None): context = self._context result = IfBranchResult(branch.type, branch.condition) - if error: + error = None + if syntax_error: run_branch = False + error = DataError(syntax_error, syntax=True) else: try: run_branch = self._should_run_branch(branch, context, recursive_dry_run) - except Exception: - error = get_error_message() + except DataError as err: + error = err run_branch = False with StatusReporter(branch, result, context, run_branch): runner = BodyRunner(context, run_branch, self._templated) if not recursive_dry_run: runner.run(branch.body) if error and self._run: - raise DataError(error) + raise error return run_branch def _should_run_branch(self, branch, context, recursive_dry_run=False): @@ -435,8 +442,9 @@ def _should_run_branch(self, branch, context, recursive_dry_run=False): if is_string(condition): return evaluate_expression(condition, variables.current.store) return bool(condition) - except DataError as err: - raise DataError(f'Evaluating {branch.type} condition failed: {err}') + except Exception: + msg = get_error_message() + raise DataError(f'Evaluating {branch.type} condition failed: {msg}') class TryRunner: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 2d12eb77d34..3bb8bb3c4ce 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -193,7 +193,7 @@ def run(self, context, run=True, templated=False): with StatusReporter(self, ReturnResult(self.values), context, run): if run: if self.error: - raise DataError(self.error) + raise DataError(self.error, syntax=True) raise ReturnFromKeyword(self.values) @@ -213,7 +213,7 @@ def source(self): def run(self, context, run=True, templated=False): with StatusReporter(self, ContinueResult(), context, run): if self.error: - raise DataError(self.error) + raise DataError(self.error, syntax=True) if run: raise ContinueLoop() @@ -234,7 +234,7 @@ def source(self): def run(self, context, run=True, templated=False): with StatusReporter(self, BreakResult(), context, run): if self.error: - raise DataError(self.error) + raise DataError(self.error, syntax=True) if run: raise BreakLoop() diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index e8d87fc1bd5..bce60e2f82c 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -286,9 +286,9 @@ def _raise_no_keyword_found(self, name, recommend=True): def _get_runner(self, name): if not name: - raise DataError('Keyword name cannot be empty.') + raise DataError('Keyword name cannot be empty.', syntax=True) if not is_string(name): - raise DataError('Keyword name must be a string.') + raise DataError('Keyword name must be a string.', syntax=True) runner = self._get_runner_from_suite_file(name) if not runner and '.' in name: runner = self._get_explicit_runner(name) diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index 951b5c13d1f..c417b44bc20 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -74,8 +74,7 @@ def _get_failure(self, exc_type, exc_value, exc_tb, context): if isinstance(exc_value, DataError): msg = exc_value.message context.fail(msg) - syntax = not isinstance(exc_value, (KeywordError, VariableError)) - return ExecutionFailed(msg, syntax=syntax) + return ExecutionFailed(msg, syntax=exc_value.syntax) error = ErrorDetails(exc_value) failure = HandlerExecutionFailed(error) if failure.timeout: diff --git a/src/robot/variables/assigner.py b/src/robot/variables/assigner.py index 24b052b6269..be18e55ceae 100644 --- a/src/robot/variables/assigner.py +++ b/src/robot/variables/assigner.py @@ -63,7 +63,8 @@ def validate(self, variable): def _validate_assign_mark(self, variable): if self._seen_assign_mark: - raise DataError("Assign mark '=' can be used only with the last variable.") + raise DataError("Assign mark '=' can be used only with the last variable.", + syntax=True) if variable.endswith('='): self._seen_assign_mark = True return variable[:-1].rstrip() @@ -71,10 +72,11 @@ def _validate_assign_mark(self, variable): def _validate_state(self, is_list, is_dict): if is_list and self._seen_list: - raise DataError('Assignment can contain only one list variable.') + raise DataError('Assignment can contain only one list variable.', + syntax=True) if self._seen_dict or is_dict and self._seen_any_var: - raise DataError('Dictionary variable cannot be assigned with ' - 'other variables.') + raise DataError('Dictionary variable cannot be assigned with other ' + 'variables.', syntax=True) self._seen_list += is_list self._seen_dict += is_dict self._seen_any_var = True From d651efd254317677ad1c9617b1fe82a75e2a7ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 27 Sep 2022 17:06:08 +0300 Subject: [PATCH 0217/1592] Fix generating log/report if WHILE has no condition. Fixes #4480. --- src/robot/result/model.py | 10 ++++++++-- utest/result/test_resultmodel.py | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index ba35c934c84..8e1ff902b11 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -222,7 +222,8 @@ class While(model.While, StatusMixin, DeprecatedAttributesMixin): iteration_class = WhileIteration __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, condition=None, limit=None, parent=None, status='FAIL', starttime=None, endtime=None, doc=''): + def __init__(self, condition=None, limit=None, parent=None, status='FAIL', + starttime=None, endtime=None, doc=''): super().__init__(condition, limit, parent) self.status = status self.starttime = starttime @@ -236,7 +237,12 @@ def body(self, iterations): @property @deprecated def name(self): - return self.condition + (f' | limit={self.limit}' if self.limit else '') + parts = [] + if self.condition: + parts.append(self.condition) + if self.limit: + parts.append(f'limit={self.limit}') + return ' | '.join(parts) class IfBranch(model.IfBranch, StatusMixin, DeprecatedAttributesMixin): diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index cb350a5e4ca..a6c53317c97 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -166,6 +166,12 @@ def test_while(self): self._verify(While()) self._verify(While().body.create_iteration()) + def test_while_name(self): + assert_equal(While().name, '') + assert_equal(While('$x > 0').name, '$x > 0') + assert_equal(While('True', '1 minute').name, 'True | limit=1 minute') + assert_equal(While(limit='1 minute').name, 'limit=1 minute') + def test_break_continue_return(self): for cls in Break, Continue, Return: self._verify(cls()) From 64582109f6c26a810650e53916605578f93aa4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 28 Sep 2022 00:20:09 +0300 Subject: [PATCH 0218/1592] WHILE and BREAK/CONTINUE enhancements. Go WHILE loop body through also if there are failurs. This makes loop contents visible in the log file. #4482 Don't raise errors about invalid syntax with BREAK and CONTINUE when they are just gone through to show in log, not really executed. Fixes #4481. --- .../running/while/break_and_continue.robot | 14 ++- atest/robot/running/while/invalid_while.robot | 32 +++++-- atest/robot/running/while/while.resource | 5 +- .../running/while/break_and_continue.robot | 26 ++++++ .../running/while/invalid_while.robot | 11 +++ .../testdata/running/while/while_limit.robot | 10 +- src/robot/running/bodyrunner.py | 93 +++++++++---------- src/robot/running/model.py | 17 ++-- 8 files changed, 136 insertions(+), 72 deletions(-) diff --git a/atest/robot/running/while/break_and_continue.robot b/atest/robot/running/while/break_and_continue.robot index 8fb4aac3eb5..a0c59e24884 100644 --- a/atest/robot/running/while/break_and_continue.robot +++ b/atest/robot/running/while/break_and_continue.robot @@ -1,7 +1,7 @@ *** Settings *** Resource while.resource Suite Setup Run Tests ${EMPTY} running/while/break_and_continue.robot -Test Template Check while loop +Test Template Check WHILE loop *** Test Cases *** With CONTINUE @@ -31,6 +31,18 @@ With BREAK inside EXCEPT With BREAK inside TRY-ELSE PASS 1 +Invalid BREAK + FAIL 1 + +Invalid CONTINUE + FAIL 1 + +Invalid BREAK not executed + PASS 1 + +Invalid CONTINUE not executed + NOT RUN 1 + With CONTINUE in UK PASS 5 body[0].body[0] diff --git a/atest/robot/running/while/invalid_while.robot b/atest/robot/running/while/invalid_while.robot index d650540183b..9a5d49f1d2c 100644 --- a/atest/robot/running/while/invalid_while.robot +++ b/atest/robot/running/while/invalid_while.robot @@ -1,26 +1,30 @@ *** Settings *** Resource while.resource -Suite Setup Run Tests ${EMPTY} running/while/invalid_while.robot +Suite Setup Run Tests --log test_result_model_as_well running/while/invalid_while.robot *** Test Cases *** No condition - Check Test Case ${TESTNAME} + ${tc} = Check Invalid WHILE Test Case + Should Be Equal ${tc.body[0].condition} ${NONE} Multiple conditions - ${tc} = Check Test Case ${TESTNAME} + ${tc} = Check Invalid WHILE Test Case Should Be Equal ${tc.body[0].condition} Too, many, ! Invalid condition - Check Test Case ${TESTNAME} + Check Invalid WHILE Test Case + +Invalid condition on second round + Check Test Case ${TEST NAME} Non-existing variable in condition - Check Test Case ${TESTNAME} + Check Invalid WHILE Test Case No body - Check Test Case ${TESTNAME} + Check Invalid WHILE Test Case body=False No END - Check Test Case ${TESTNAME} + Check Invalid WHILE Test Case Invalid data causes syntax error Check Test Case ${TEST NAME} @@ -30,3 +34,17 @@ Invalid condition causes normal error Non-existing variable in condition causes normal error Check Test Case ${TEST NAME} + +*** Keywords *** +Check Invalid WHILE Test Case + [Arguments] ${body}=True + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.body[0].type} WHILE + Should Be Equal ${tc.body[0].status} FAIL + Should Be Equal ${tc.body[0].body[0].type} ITERATION + Should Be Equal ${tc.body[0].body[0].status} NOT RUN + IF ${body} + Should Be Equal ${tc.body[0].body[0].body[0].name} BuiltIn.Fail + Should Be Equal ${tc.body[0].body[0].body[0].status} NOT RUN + END + RETURN ${tc} diff --git a/atest/robot/running/while/while.resource b/atest/robot/running/while/while.resource index 4ee25df6dc0..f0c49be600f 100644 --- a/atest/robot/running/while/while.resource +++ b/atest/robot/running/while/while.resource @@ -1,9 +1,8 @@ *** Settings *** -Resource atest_resource.robot - +Resource atest_resource.robot *** Keywords *** -Check while loop +Check WHILE loop [Arguments] ${status} ${iterations} ${path}=body[0] ${tc}= Check test case ${TEST NAME} ${loop}= Check loop attributes ${tc.${path}} ${status} ${iterations} diff --git a/atest/testdata/running/while/break_and_continue.robot b/atest/testdata/running/while/break_and_continue.robot index 5df6343969c..61c519d361d 100644 --- a/atest/testdata/running/while/break_and_continue.robot +++ b/atest/testdata/running/while/break_and_continue.robot @@ -106,6 +106,32 @@ With BREAK inside TRY-ELSE END Should be equal ${variable} ${2} +Invalid BREAK + [Documentation] FAIL BREAK does not accept arguments, got 'bad'. + WHILE True + BREAK bad + END + +Invalid CONTINUE + [Documentation] FAIL CONTINUE does not accept arguments, got 'bad'. + WHILE True + CONTINUE bad + END + +Invalid BREAK not executed + WHILE True + IF False + BREAK bad + ELSE + BREAK + END + END + +Invalid CONTINUE not executed + WHILE False + CONTINUE bad + END + With CONTINUE in UK With CONTINUE in UK diff --git a/atest/testdata/running/while/invalid_while.robot b/atest/testdata/running/while/invalid_while.robot index a86f0061b10..42f9a164c19 100644 --- a/atest/testdata/running/while/invalid_while.robot +++ b/atest/testdata/running/while/invalid_while.robot @@ -17,6 +17,17 @@ Invalid condition Fail Not executed! END +Invalid condition on second round + [Documentation] FAIL Evaluating WHILE condition failed: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + ${condition} = Set Variable True + WHILE ${condition} + IF ${condition} + ${condition} = Set Variable bad + ELSE + Fail Not executed! + END + END + Non-existing variable in condition [Documentation] FAIL Evaluating WHILE condition failed: Variable '\${ooops}' not found. WHILE ${ooops} diff --git a/atest/testdata/running/while/while_limit.robot b/atest/testdata/running/while/while_limit.robot index 40429108565..688eb3cd41f 100644 --- a/atest/testdata/running/while/while_limit.robot +++ b/atest/testdata/running/while/while_limit.robot @@ -1,7 +1,7 @@ *** Variables *** ${variable} ${1} ${limit} 11 -${number} ${0.7} +${number} ${0.2} *** Test Cases *** Default limit is 10000 iterations @@ -29,8 +29,8 @@ Limit with iteration count with underscore END Limit as timestr - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 0.5 seconds. Use the 'limit' argument to increase or remove the limit if needed. - WHILE $variable < 2 limit=0.5s + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 0.1 seconds. Use the 'limit' argument to increase or remove the limit if needed. + WHILE $variable < 2 limit=0.1s Log ${variable} END @@ -41,7 +41,7 @@ Limit from variable END Part of limit from variable - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 0.7 seconds. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 0.2 seconds. Use the 'limit' argument to increase or remove the limit if needed. WHILE $variable < 2 limit=${number} s Log ${variable} END @@ -59,7 +59,7 @@ Invalid limit invalid suffix END Invalid limit invalid value - [Documentation] FAIL Invalid WHILE loop limit: Iteration limit must be a positive integer, got: '-100'. + [Documentation] FAIL Invalid WHILE loop limit: Iteration count must be a positive integer, got '-100'. WHILE $variable < 2 limit=-100 Log ${variable} END diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index f278a1a5482..f88cc4f5c8a 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -319,43 +319,45 @@ def __init__(self, context, run=True, templated=False): self._templated = templated def run(self, data): - run = self._run - executed_once = False - result = WhileResult(data.condition, data.limit) - with StatusReporter(data, result, self._context, run) as status: - if self._context.dry_run or not run: + ctx = self._context + error = None + run = False + limit = None + if self._run: + if data.error: + error = DataError(data.error, syntax=True) + elif not ctx.dry_run: try: - self._run_iteration(data, result, run) - except (BreakLoop, ContinueLoop): - pass + limit = WhileLimit.create(data.limit, ctx.variables) + run = self._should_run(data.condition, ctx.variables) + except DataError as err: + error = err + result = WhileResult(data.condition, data.limit) + with StatusReporter(data, result, self._context, run): + if ctx.dry_run or not run: + self._run_iteration(data, result, run) + if error: + raise error return - if data.error: - raise DataError(data.error, syntax=True) - limit = WhileLimit.create(data.limit, self._context.variables) errors = [] - while self._should_run(data.condition, self._context.variables) \ - and limit.is_valid: - executed_once = True + while True: try: with limit: - self._run_iteration(data, result, run) + self._run_iteration(data, result) except BreakLoop: break except ContinueLoop: - continue + pass except ExecutionFailed as err: errors.extend(err.get_errors()) - if not err.can_continue(self._context, self._templated): + if not err.can_continue(ctx, self._templated): break - if not executed_once: - status.pass_status = result.NOT_RUN - self._run_iteration(data, result, run=False) + if not self._should_run(data.condition, ctx.variables): + break if errors: raise ExecutionFailures(errors) - if not limit.is_valid: - raise DataError(limit.reason) - def _run_iteration(self, data, result, run): + def _run_iteration(self, data, result, run=True): runner = BodyRunner(self._context, run, self._templated) with StatusReporter(data, result.body.create_iteration(), self._context, run): runner.run(data.body) @@ -570,26 +572,29 @@ def _run_finally(self, data, run): class WhileLimit: - is_valid = True @classmethod def create(cls, limit, variables): + if not limit: + return IterationCountLimit(DEFAULT_WHILE_LIMIT) + value = variables.replace_string(limit) + if value.upper() == 'NONE': + return NoLimit() try: - if not limit: - return IterationCountLimit(DEFAULT_WHILE_LIMIT) - if limit.upper() == 'NONE': - return NoLimit() - value = variables.replace_string(limit) - try: - count = int(value.replace(' ', '')) - if count <= 0: - return InvalidLimit(f"Iteration limit must be a positive integer, " - f"got: '{count}'.") - return IterationCountLimit(count) - except ValueError: - return DurationLimit(timestr_to_secs(value)) - except Exception as error: - return InvalidLimit(error) + count = int(value.replace(' ', '')) + except ValueError: + pass + else: + if count <= 0: + raise DataError(f"Invalid WHILE loop limit: Iteration count must be " + f"a positive integer, got '{count}'.") + return IterationCountLimit(count) + try: + secs = timestr_to_secs(value) + except ValueError as err: + raise DataError(f'Invalid WHILE loop limit: {err.args[0]}') + else: + return DurationLimit(secs) def limit_exceeded(self): raise ExecutionFailed(f"WHILE loop was aborted because it did not finish " @@ -638,13 +643,3 @@ class NoLimit(WhileLimit): def __enter__(self): pass - - -class InvalidLimit(WhileLimit): - is_valid = False - - def __init__(self, reason): - self.reason = f'Invalid WHILE loop limit: {reason}' - - def __enter__(self): - raise DataError(self.reason) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 3bb8bb3c4ce..83a409d66ba 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -194,7 +194,8 @@ def run(self, context, run=True, templated=False): if run: if self.error: raise DataError(self.error, syntax=True) - raise ReturnFromKeyword(self.values) + if not context.dry_run: + raise ReturnFromKeyword(self.values) @Body.register @@ -212,10 +213,11 @@ def source(self): def run(self, context, run=True, templated=False): with StatusReporter(self, ContinueResult(), context, run): - if self.error: - raise DataError(self.error, syntax=True) if run: - raise ContinueLoop() + if self.error: + raise DataError(self.error, syntax=True) + if not context.dry_run: + raise ContinueLoop() @Body.register @@ -233,10 +235,11 @@ def source(self): def run(self, context, run=True, templated=False): with StatusReporter(self, BreakResult(), context, run): - if self.error: - raise DataError(self.error, syntax=True) if run: - raise BreakLoop() + if self.error: + raise DataError(self.error, syntax=True) + if not context.dry_run: + raise BreakLoop() class TestCase(model.TestCase): From 6ddd9a883cfe1f9b0cb1077bc9881409d1ee8af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 28 Sep 2022 02:13:23 +0300 Subject: [PATCH 0219/1592] FOR loop enhancements. Go FOR loop body through also if there are failurs. This makes loop contents visible in the log file. Fixes #4482. Handle `start` with IN ENUMERATE loops better. --- .../remove_keywords/for_loop_keywords.robot | 6 +- atest/robot/running/for/for.resource | 9 ++- atest/robot/running/for/for.robot | 5 +- .../robot/running/for/for_in_enumerate.robot | 6 +- atest/testdata/cli/dryrun/reserved.robot | 2 +- .../running/for/for_in_enumerate.robot | 4 +- src/robot/running/bodyrunner.py | 60 ++++++++++++------- 7 files changed, 57 insertions(+), 35 deletions(-) diff --git a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot index 10af4a534df..aa8b1c29403 100644 --- a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot @@ -51,8 +51,10 @@ Steps From Loops In Keywords From Loops Are Removed Empty Loops Are Handled Correctly ${tc}= Check Test Case Empty body - Should Be Empty ${tc.kws[0].kws} - Should Be Equal ${tc.kws[0].doc} ${0 REMOVED} + Should Be Equal ${tc.body[0].body[0].type} ITERATION + Should Be Equal ${tc.body[0].body[0].status} NOT RUN + Should Be Empty ${tc.body[0].body[0].body} + Should Be Equal ${tc.body[0].doc} ${0 REMOVED} *** Keywords *** Remove For Loop Keywords With Rebot diff --git a/atest/robot/running/for/for.resource b/atest/robot/running/for/for.resource index 258045e7995..350433731fd 100644 --- a/atest/robot/running/for/for.resource +++ b/atest/robot/running/for/for.resource @@ -5,18 +5,21 @@ Resource atest_resource.robot Check test and get loop [Arguments] ${test name} ${loop index}=0 ${tc} = Check Test Case ${test name} - [Return] ${tc.kws}[${loop index}] + RETURN ${tc.kws}[${loop index}] Check test and failed loop [Arguments] ${test name} ${type}=FOR ${loop index}=0 ${loop} = Check test and get loop ${test name} ${loop index} - Run Keyword Should Be ${type} loop ${loop} 0 FAIL + Length Should Be ${loop.body} 2 + Should Be Equal ${loop.body[0].type} ITERATION + Should Be Equal ${loop.body[1].type} MESSAGE + Run Keyword Should Be ${type} loop ${loop} 1 FAIL Should be FOR loop [Arguments] ${loop} ${iterations} ${status}=PASS ${flavor}=IN Should Be Equal ${loop.type} FOR Should Be Equal ${loop.flavor} ${flavor} - Length Should Be ${loop.kws} ${iterations} + Length Should Be ${loop.body.filter(messages=False)} ${iterations} Should Be Equal ${loop.status} ${status} Should be IN RANGE loop diff --git a/atest/robot/running/for/for.robot b/atest/robot/running/for/for.robot index 4551f2f371f..f603b302c66 100644 --- a/atest/robot/running/for/for.robot +++ b/atest/robot/running/for/for.robot @@ -252,11 +252,10 @@ Invalid END Check test and failed loop ${TEST NAME} No loop values - ${tc} = Check test case ${TEST NAME} - Should be FOR loop ${tc.body[0]} 0 FAIL + Check test and failed loop ${TEST NAME} No loop variables - Check Test Case ${TESTNAME} + Check test and failed loop ${TEST NAME} Invalid loop variable Check test and failed loop ${TEST NAME} 1 diff --git a/atest/robot/running/for/for_in_enumerate.robot b/atest/robot/running/for/for_in_enumerate.robot index a47d2a809d0..dd839246c15 100644 --- a/atest/robot/running/for/for_in_enumerate.robot +++ b/atest/robot/running/for/for_in_enumerate.robot @@ -33,12 +33,10 @@ Escape start Should be IN ENUMERATE loop ${loop} 2 Invalid start - ${loop} = Check test and get loop ${TEST NAME} - Should be IN ENUMERATE loop ${loop} 0 status=FAIL + Check test and failed loop ${TEST NAME} IN ENUMERATE Invalid variable in start - ${loop} = Check test and get loop ${TEST NAME} - Should be IN ENUMERATE loop ${loop} 0 status=FAIL + Check test and failed loop ${TEST NAME} IN ENUMERATE Index and two items ${loop} = Check test and get loop ${TEST NAME} 1 diff --git a/atest/testdata/cli/dryrun/reserved.robot b/atest/testdata/cli/dryrun/reserved.robot index 56e5cf4ebd0..71b2dcb607a 100644 --- a/atest/testdata/cli/dryrun/reserved.robot +++ b/atest/testdata/cli/dryrun/reserved.robot @@ -47,7 +47,7 @@ End End End after valid FOR header - [Documentation] FAIL FOR loop must have closing END. + [Documentation] FAIL 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. FOR ${x} IN whatever Log ${x} End diff --git a/atest/testdata/running/for/for_in_enumerate.robot b/atest/testdata/running/for/for_in_enumerate.robot index ea6f2057291..d0297078568 100644 --- a/atest/testdata/running/for/for_in_enumerate.robot +++ b/atest/testdata/running/for/for_in_enumerate.robot @@ -34,13 +34,13 @@ Escape start Should Be True ${result} == [0, 1] Invalid start - [Documentation] FAIL ValueError: Invalid FOR IN ENUMERATE start value 'invalid'. + [Documentation] FAIL Invalid start value: Start value must be an integer, got 'invalid'. FOR ${index} ${item} IN ENUMERATE xxx start=invalid Fail Should not be executed END Invalid variable in start - [Documentation] FAIL Variable '${invalid}' not found. + [Documentation] FAIL Invalid start value: Variable '\${invalid}' not found. FOR ${index} ${item} IN ENUMERATE xxx start=${invalid} Fail Should not be executed END diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index f88cc4f5c8a..0ccc2fa54d0 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -93,21 +93,32 @@ def __init__(self, context, run=True, templated=False): self._templated = templated def run(self, data): + error = None + run = False + if self._run: + if data.error: + error = DataError(data.error, syntax=True) + else: + run = True result = ForResult(data.variables, data.flavor, data.values) - with StatusReporter(data, result, self._context, self._run) as status: - run_at_least_once = False - if self._run: - if data.error: - raise DataError(data.error, syntax=True) - run_at_least_once = self._run_loop(data, result) - if not run_at_least_once: - status.pass_status = result.NOT_RUN - self._run_one_round(data, result, run=False) - - def _run_loop(self, data, result): + with StatusReporter(data, result, self._context, run) as status: + if run: + try: + values_for_rounds = self._get_values_for_rounds(data) + except DataError as err: + error = err + else: + if self._run_loop(data, result, values_for_rounds): + return + status.pass_status = result.NOT_RUN + self._run_one_round(data, result, run=False) + if error: + raise error + + def _run_loop(self, data, result, values_for_rounds): errors = [] executed = False - for values in self._get_values_for_rounds(data): + for values in values_for_rounds: executed = True try: self._run_one_round(data, result, values) @@ -124,8 +135,7 @@ def _run_loop(self, data, result): raise exception except ExecutionFailed as exception: errors.extend(exception.get_errors()) - if not exception.can_continue(self._context, - self._templated): + if not exception.can_continue(self._context, self._templated): break if errors: raise ExecutionFailures(errors) @@ -150,7 +160,7 @@ def _is_dict_iteration(self, values): return True if split_from_equals(item)[1] is None: all_name_value = False - if all_name_value: + if all_name_value and values: name, value = split_from_equals(values[0]) logger.warn( f"FOR loop iteration over values that are all in 'name=value' " @@ -272,6 +282,11 @@ def _map_values_to_rounds(self, values, per_round): class ForInEnumerateRunner(ForInRunner): flavor = 'IN ENUMERATE' + def _is_dict_iteration(self, values): + if values and values[-1].startswith('start='): + values = values[:-1] + return super()._is_dict_iteration(values) + def _resolve_dict_values(self, values): self._start, values = self._get_start(values) return ForInRunner._resolve_dict_values(self, values) @@ -283,13 +298,18 @@ def _resolve_values(self, values): def _get_start(self, values): if not values[-1].startswith('start='): return 0, values - start = self._context.variables.replace_string(values[-1][6:]) - if len(values) == 1: + *values, start = values + if not values: raise DataError('FOR loop has no loop values.', syntax=True) try: - return int(start), values[:-1] - except ValueError: - raise ValueError(f"Invalid FOR IN ENUMERATE start value '{start}'.") + start = self._context.variables.replace_string(start[6:]) + try: + start = int(start) + except ValueError: + raise DataError(f"Start value must be an integer, got '{start}'.") + except DataError as err: + raise DataError(f'Invalid start value: {err}') + return start, values def _map_dict_values_to_rounds(self, values, per_round): if per_round > 3: From 192d26e160d72a5f824c62e34942c6e1230cc4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 28 Sep 2022 02:39:44 +0300 Subject: [PATCH 0220/1592] Fix BREAK/CONTINUE with continuable failures in WHILE loops. Fixes #4483. --- .../running/while/break_and_continue.robot | 6 +++ .../running/while/break_and_continue.robot | 29 +++++++++++++ src/robot/running/bodyrunner.py | 43 ++++++++++--------- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/atest/robot/running/while/break_and_continue.robot b/atest/robot/running/while/break_and_continue.robot index a0c59e24884..bc932d39d12 100644 --- a/atest/robot/running/while/break_and_continue.robot +++ b/atest/robot/running/while/break_and_continue.robot @@ -31,6 +31,12 @@ With BREAK inside EXCEPT With BREAK inside TRY-ELSE PASS 1 +BREAK with continuable failures + FAIL 1 + +CONTINUE with continuable failures + FAIL 2 + Invalid BREAK FAIL 1 diff --git a/atest/testdata/running/while/break_and_continue.robot b/atest/testdata/running/while/break_and_continue.robot index 61c519d361d..967d8f3b45c 100644 --- a/atest/testdata/running/while/break_and_continue.robot +++ b/atest/testdata/running/while/break_and_continue.robot @@ -106,6 +106,35 @@ With BREAK inside TRY-ELSE END Should be equal ${variable} ${2} +BREAK with continuable failures + [Documentation] FAIL + ... Several failures occurred: + ... + ... 1) Failure + ... + ... 2) Another failure + [Tags] robot:continue-on-failure + WHILE True + Fail Failure + Fail Another failure + BREAK + Fail Not run + END + +CONTINUE with continuable failures + [Documentation] FAIL + ... Several failures occurred: + ... + ... 1) Failure 1 + ... + ... 2) Failure 0 + WHILE $variable >= 0 + Run Keyword And Continue On Failure Fail Failure ${variable} + ${variable} = Set Variable ${variable - 1} + CONTINUE + Fail Not run + END + Invalid BREAK [Documentation] FAIL BREAK does not accept arguments, got 'bad'. WHILE True diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 0ccc2fa54d0..1777695d224 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -122,20 +122,17 @@ def _run_loop(self, data, result, values_for_rounds): executed = True try: self._run_one_round(data, result, values) - except BreakLoop as exception: - if exception.earlier_failures: - errors.extend(exception.earlier_failures.get_errors()) - break - except ContinueLoop as exception: - if exception.earlier_failures: - errors.extend(exception.earlier_failures.get_errors()) - continue - except ExecutionPassed as exception: - exception.set_earlier_failures(errors) - raise exception - except ExecutionFailed as exception: - errors.extend(exception.get_errors()) - if not exception.can_continue(self._context, self._templated): + except (BreakLoop, ContinueLoop) as ctrl: + if ctrl.earlier_failures: + errors.extend(ctrl.earlier_failures.get_errors()) + if isinstance(ctrl, BreakLoop): + break + except ExecutionPassed as passed: + passed.set_earlier_failures(errors) + raise passed + except ExecutionFailed as failed: + errors.extend(failed.get_errors()) + if not failed.can_continue(self._context, self._templated): break if errors: raise ExecutionFailures(errors) @@ -364,13 +361,17 @@ def run(self, data): try: with limit: self._run_iteration(data, result) - except BreakLoop: - break - except ContinueLoop: - pass - except ExecutionFailed as err: - errors.extend(err.get_errors()) - if not err.can_continue(ctx, self._templated): + except (BreakLoop, ContinueLoop) as ctrl: + if ctrl.earlier_failures: + errors.extend(ctrl.earlier_failures.get_errors()) + if isinstance(ctrl, BreakLoop): + break + except ExecutionPassed as passed: + passed.set_earlier_failures(errors) + raise passed + except ExecutionFailed as failed: + errors.extend(failed.get_errors()) + if not failed.can_continue(ctx, self._templated): break if not self._should_run(data.condition, ctx.variables): break From 2f67f8f39d3b2279225f39d807e7249c6ca9da3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 28 Sep 2022 03:29:26 +0300 Subject: [PATCH 0221/1592] Enhance handling syntax errors with TRY/EXCEPT. Show unexecuted TRY/EXCEPT branches if there are syntax errors. Make invalid TRY/EXCEPT itself a syntax error. Fixes #4484. --- .../running/try_except/invalid_try_except.robot | 3 +++ atest/robot/running/try_except/try_except.robot | 2 +- .../running/try_except/invalid_try_except.robot | 13 +++++++++++++ atest/testdata/running/try_except/try_except.robot | 2 ++ src/robot/running/bodyrunner.py | 10 ++++------ src/robot/running/statusreporter.py | 2 +- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/atest/robot/running/try_except/invalid_try_except.robot b/atest/robot/running/try_except/invalid_try_except.robot index e69a824b577..41c452a00e8 100644 --- a/atest/robot/running/try_except/invalid_try_except.robot +++ b/atest/robot/running/try_except/invalid_try_except.robot @@ -81,3 +81,6 @@ CONTINUE in FINALLY RETURN in FINALLY TRY:PASS FINALLY:FAIL path=body[0].body[0] + +Invalid TRY/EXCEPT causes syntax error that cannot be caught + TRY:FAIL EXCEPT:NOT RUN ELSE:NOT RUN diff --git a/atest/robot/running/try_except/try_except.robot b/atest/robot/running/try_except/try_except.robot index aca14fc3d85..1b599b5c98f 100644 --- a/atest/robot/running/try_except/try_except.robot +++ b/atest/robot/running/try_except/try_except.robot @@ -35,7 +35,7 @@ Default except pattern FAIL PASS Syntax errors cannot be caught - FAIL + FAIL NOT RUN NOT RUN Finally block executed when no failures [Template] None diff --git a/atest/testdata/running/try_except/invalid_try_except.robot b/atest/testdata/running/try_except/invalid_try_except.robot index 2e251df2584..7feb94db67a 100644 --- a/atest/testdata/running/try_except/invalid_try_except.robot +++ b/atest/testdata/running/try_except/invalid_try_except.robot @@ -261,6 +261,19 @@ RETURN in FINALLY [Documentation] FAIL RETURN cannot be used in FINALLY branch. RETURN in FINALLY +Invalid TRY/EXCEPT causes syntax error that cannot be caught + [Documentation] FAIL TRY branch cannot be empty. + TRY + TRY + EXCEPT + Fail Not run + END + EXCEPT + Fail Not run because error cannot be caught + ELSE + Fail Not run either + END + *** Keywords *** RETURN in FINALLY TRY diff --git a/atest/testdata/running/try_except/try_except.robot b/atest/testdata/running/try_except/try_except.robot index d5c39e9e27d..f9ed5174dfb 100644 --- a/atest/testdata/running/try_except/try_except.robot +++ b/atest/testdata/running/try_except/try_except.robot @@ -91,6 +91,8 @@ Syntax errors cannot be caught ${y} = ${x} Set Variable EXCEPT Fail Should not be run + ELSE + Fail Should not be run END Finally block executed when no failures diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 1777695d224..e6ce91f572a 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -482,7 +482,7 @@ def run(self, data): with StatusReporter(data, TryResult(), self._context, run): if data.error: self._run_invalid(data) - return False + return error = self._run_try(data, run) run_excepts_or_else = self._should_run_excepts_or_else(error, run) if error: @@ -504,8 +504,8 @@ def _run_invalid(self, data): runner.run(branch.body) if not error_reported: error_reported = True - raise ExecutionFailed(data.error) - raise ExecutionFailed(data.error) + raise DataError(data.error, syntax=True) + raise ExecutionFailed(data.error, syntax=True) def _run_try(self, data, run): result = TryBranchResult(data.TRY) @@ -516,7 +516,7 @@ def _should_run_excepts_or_else(self, error, run): return False if not error: return True - return not (error.skip or isinstance(error, ExecutionPassed)) + return not (error.skip or error.syntax or isinstance(error, ExecutionPassed)) def _run_branch(self, branch, result, run=True, error=None): try: @@ -526,8 +526,6 @@ def _run_branch(self, branch, result, run=True, error=None): runner = BodyRunner(self._context, run, self._templated) runner.run(branch.body) except ExecutionStatus as err: - if isinstance(err, ExecutionFailed) and err.syntax: - raise err return err else: return None diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index c417b44bc20..5ad7aba1ba8 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -62,7 +62,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): context.test.status = result.status result.endtime = get_timestamp() context.end_keyword(ModelCombiner(self.data, result)) - if failure is not exc_val: + if failure is not exc_val and not self.suppress: raise failure return self.suppress From 5eeeb18a405967375007c46eb1133a3995f9b687 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Sep 2022 09:15:08 +0300 Subject: [PATCH 0222/1592] Bump codecov/codecov-action from 3.1.0 to 3.1.1 (#4468) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.0 to 3.1.1. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/81cd2dc8148241f03f5839d295e000b8f761e378...d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 4548ca7f38a..b05f416e148 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -47,7 +47,7 @@ jobs: python -m coverage xml -i if: always() - - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 + - uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70 with: name: ${{ matrix.python-version }}-${{ matrix.os }} if: always() From a56d0521db6351133063a6b885236033b3d630d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 28 Sep 2022 10:04:10 +0300 Subject: [PATCH 0223/1592] Unit test fix for Python < 3.11 --- utest/utils/test_error.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/utest/utils/test_error.py b/utest/utils/test_error.py index 0d1528f91f4..8b03ee0667c 100644 --- a/utest/utils/test_error.py +++ b/utest/utils/test_error.py @@ -7,8 +7,14 @@ from robot.utils.error import get_error_details, get_error_message, ErrorDetails -def format_traceback(): - return ''.join(traceback.format_exception(*sys.exc_info())).rstrip() +def format_traceback(no_tb=False): + e, v, tb = sys.exc_info() + # This is needed when testing chaining and cause without traceback. + # We set `err.__traceback__ = None` in tests and apparently that makes + # `tb` here `None´ with Python 3.11 but not with others. + if sys.version_info < (3, 11) and no_tb: + tb = None + return ''.join(traceback.format_exception(e, v, tb)).rstrip() def format_message(): @@ -67,7 +73,7 @@ def test_chaining_without_traceback(self): raise RuntimeError('higher') from err except Exception as err: err.__traceback__ = None - assert_equal(ErrorDetails(err).traceback, format_traceback()) + assert_equal(ErrorDetails(err).traceback, format_traceback(no_tb=True)) def test_cause(self): try: @@ -80,7 +86,7 @@ def test_cause_without_traceback(self): raise ValueError('err') from TypeError('cause') except ValueError as err: err.__traceback__ = None - assert_equal(ErrorDetails(err).traceback, format_traceback()) + assert_equal(ErrorDetails(err).traceback, format_traceback(no_tb=True)) class TestRemoveRobotEntriesFromTraceback(unittest.TestCase): From 60d6f0e8776c080007587962cf437abc525bde63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 28 Sep 2022 18:49:12 +0300 Subject: [PATCH 0224/1592] Support generics in type conversion. #4433 --- .../annotations_with_typing.robot | 49 +++++- .../type_conversion/custom_converters.robot | 3 + .../type_conversion/standard_generics.robot | 44 +++++ atest/robot/libdoc/datatypes_json-xml.robot | 1 - atest/robot/libdoc/datatypes_py-json.robot | 21 ++- atest/robot/libdoc/datatypes_py-xml.robot | 17 +- atest/robot/libdoc/type_annotations.robot | 2 +- .../libdoc/types_via_keyword_decorator.robot | 2 +- .../type_conversion/AnnotationsWithTyping.py | 41 +++-- .../type_conversion/CustomConverters.py | 10 +- .../type_conversion/StandardGenerics.py | 43 +++++ .../annotations_with_typing.robot | 139 ++++++++++----- .../type_conversion/custom_converters.robot | 7 + .../type_conversion/standard_generics.robot | 68 ++++++++ .../CreatingTestLibraries.rst | 42 ++++- src/robot/libdocpkg/datatypes.py | 18 +- src/robot/libdocpkg/robotbuilder.py | 2 +- src/robot/libdocpkg/standardtypes.py | 15 ++ src/robot/running/arguments/typeconverters.py | 163 +++++++++++++++--- src/robot/utils/robottypes.py | 10 +- utest/utils/test_robottypes.py | 4 + 21 files changed, 581 insertions(+), 120 deletions(-) create mode 100644 atest/robot/keywords/type_conversion/standard_generics.robot create mode 100644 atest/testdata/keywords/type_conversion/StandardGenerics.py create mode 100644 atest/testdata/keywords/type_conversion/standard_generics.robot diff --git a/atest/robot/keywords/type_conversion/annotations_with_typing.robot b/atest/robot/keywords/type_conversion/annotations_with_typing.robot index b1e03308e49..e61be332a4a 100644 --- a/atest/robot/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/robot/keywords/type_conversion/annotations_with_typing.robot @@ -1,30 +1,57 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} keywords/type_conversion/annotations_with_typing.robot -Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/annotations_with_typing.robot +Resource atest_resource.robot *** Test Cases *** List Check Test Case ${TESTNAME} -List with params +List with types + Check Test Case ${TESTNAME} + +List with incompatible types Check Test Case ${TESTNAME} Invalid list Check Test Case ${TESTNAME} +Tuple + Check Test Case ${TESTNAME} + +Tuple with types + Check Test Case ${TESTNAME} + +Tuple with homogenous types + Check Test Case ${TESTNAME} + +Tuple with incompatible types + Check Test Case ${TESTNAME} + +Tuple with wrong number of values + Check Test Case ${TESTNAME} + +Invalid tuple + Check Test Case ${TESTNAME} + Sequence Check Test Case ${TESTNAME} -Sequence with params +Sequence with types Check Test Case ${TESTNAME} -Invalid Sequence +Sequence with incompatible types + Check Test Case ${TESTNAME} + +Invalid sequence Check Test Case ${TESTNAME} Dict Check Test Case ${TESTNAME} -Dict with params +Dict with types + Check Test Case ${TESTNAME} + +Dict with incompatible types Check Test Case ${TESTNAME} TypedDict @@ -36,7 +63,10 @@ Invalid dictionary Mapping Check Test Case ${TESTNAME} -Mapping with params +Mapping with types + Check Test Case ${TESTNAME} + +Mapping with incompatible types Check Test Case ${TESTNAME} Invalid mapping @@ -45,7 +75,10 @@ Invalid mapping Set Check Test Case ${TESTNAME} -Set with params +Set with types + Check Test Case ${TESTNAME} + +Set with incompatible types Check Test Case ${TESTNAME} Invalid Set diff --git a/atest/robot/keywords/type_conversion/custom_converters.robot b/atest/robot/keywords/type_conversion/custom_converters.robot index 4e329dacca7..e1fa0daab42 100644 --- a/atest/robot/keywords/type_conversion/custom_converters.robot +++ b/atest/robot/keywords/type_conversion/custom_converters.robot @@ -21,6 +21,9 @@ Custom in Union Accept subscripted generics Check Test Case ${TESTNAME} +With generics + Check Test Case ${TESTNAME} + Failing conversion Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/standard_generics.robot b/atest/robot/keywords/type_conversion/standard_generics.robot new file mode 100644 index 00000000000..aa005a4b91d --- /dev/null +++ b/atest/robot/keywords/type_conversion/standard_generics.robot @@ -0,0 +1,44 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/standard_generics.robot +Force Tags require-py3.9 +Resource atest_resource.robot + +*** Test Cases *** +List + Check Test Case ${TESTNAME} + +Incompatible list + Check Test Case ${TESTNAME} + +Tuple + Check Test Case ${TESTNAME} + +Homogenous tuple + Check Test Case ${TESTNAME} + +Incompatible tuple + Check Test Case ${TESTNAME} + +Dict + Check Test Case ${TESTNAME} + +Incompatible dict + Check Test Case ${TESTNAME} + +Set + Check Test Case ${TESTNAME} + +Incompatible set + Check Test Case ${TESTNAME} + +Invalid list + Check Test Case ${TESTNAME} + +Invalid tuple + Check Test Case ${TESTNAME} + +Invalid dict + Check Test Case ${TESTNAME} + +Invalid set + Check Test Case ${TESTNAME} diff --git a/atest/robot/libdoc/datatypes_json-xml.robot b/atest/robot/libdoc/datatypes_json-xml.robot index 7af7db79c34..7b9e5f37e49 100644 --- a/atest/robot/libdoc/datatypes_json-xml.robot +++ b/atest/robot/libdoc/datatypes_json-xml.robot @@ -38,7 +38,6 @@ Custom ... <p>Class doc is used when converter method has no doc.</p> Accepted types - Accepted Types Should Be 1 Standard boolean ... string integer float None Accepted Types Should Be 2 Custom CustomType diff --git a/atest/robot/libdoc/datatypes_py-json.robot b/atest/robot/libdoc/datatypes_py-json.robot index 128831043ed..7f080bd8a6e 100644 --- a/atest/robot/libdoc/datatypes_py-json.robot +++ b/atest/robot/libdoc/datatypes_py-json.robot @@ -102,6 +102,17 @@ Standard types ${MODEL}[typedocs][1][name] boolean ${MODEL}[typedocs][1][doc] <p>Strings <code>TRUE</code>, <code>YES</code>, start=True +Standard types with generics + ${MODEL}[typedocs][4][type] Standard + ${MODEL}[typedocs][4][name] Dict[str, int] + ${MODEL}[typedocs][4][doc] <p>Strings must be Python <a start=True + ${MODEL}[typedocs][8][type] Standard + ${MODEL}[typedocs][8][name] List[Any] + ${MODEL}[typedocs][8][doc] <p>Strings must be Python <a start=True + ${MODEL}[typedocs][9][type] Standard + ${MODEL}[typedocs][9][name] List[str] + ${MODEL}[typedocs][9][doc] <p>Strings must be Python <a start=True + Accepted types ${MODEL}[typedocs][1][type] Standard ${MODEL}[typedocs][1][accepts] ['string', 'integer', 'float', 'None'] @@ -113,18 +124,20 @@ Accepted types ${MODEL}[typedocs][6][accepts] ['string'] ${MODEL}[typedocs][0][type] Enum ${MODEL}[typedocs][0][accepts] ['string'] - ${MODEL}[typedocs][10][type] Enum - ${MODEL}[typedocs][10][accepts] ['string', 'integer'] + ${MODEL}[typedocs][11][type] Enum + ${MODEL}[typedocs][11][accepts] ['string', 'integer'] Usages ${MODEL}[typedocs][1][type] Standard ${MODEL}[typedocs][1][usages] ['Funny Unions'] + ${MODEL}[typedocs][4][type] Standard + ${MODEL}[typedocs][4][usages] ['Typing Types'] ${MODEL}[typedocs][2][type] Custom ${MODEL}[typedocs][2][usages] ['Custom'] ${MODEL}[typedocs][6][type] TypedDict ${MODEL}[typedocs][6][usages] ['Funny Unions', 'Set Location'] - ${MODEL}[typedocs][10][type] Enum - ${MODEL}[typedocs][10][usages] ['__init__', 'Funny Unions'] + ${MODEL}[typedocs][11][type] Enum + ${MODEL}[typedocs][11][usages] ['__init__', 'Funny Unions'] Typedoc links in arguments ${MODEL}[keywords][0][args][1][typedocs] {'AssertionOperator': 'AssertionOperator', 'None': 'None'} diff --git a/atest/robot/libdoc/datatypes_py-xml.robot b/atest/robot/libdoc/datatypes_py-xml.robot index ec0a6823789..696be5ab5de 100644 --- a/atest/robot/libdoc/datatypes_py-xml.robot +++ b/atest/robot/libdoc/datatypes_py-xml.robot @@ -55,6 +55,17 @@ Standard ... boolean ... Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``, +Standard with generics + DataType Standard Should Be 1 + ... Dict[str, int] + ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#dict|dictionary] + DataType Standard Should Be 4 + ... List[Any] + ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#list|list] + DataType Standard Should Be 5 + ... List[str] + ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#list|list] + Accepted types Accepted Types Should Be 1 Standard boolean ... string integer float None @@ -65,17 +76,19 @@ Accepted types ... string Accepted Types Should Be 0 Enum AssertionOperator ... string - Accepted Types Should Be 10 Enum Small + Accepted Types Should Be 11 Enum Small ... string integer Usages Usages Should Be 1 Standard boolean ... Funny Unions + Usages Should Be 4 Standard Dict[str, int] + ... Typing Types Usages Should Be 2 Custom CustomType ... Custom Usages Should be 6 TypedDict GeoLocation ... Funny Unions Set Location - Usages Should Be 10 Enum Small + Usages Should Be 11 Enum Small ... __init__ Funny Unions Typedoc links in arguments diff --git a/atest/robot/libdoc/type_annotations.robot b/atest/robot/libdoc/type_annotations.robot index e3d8983ec63..a27100e3125 100644 --- a/atest/robot/libdoc/type_annotations.robot +++ b/atest/robot/libdoc/type_annotations.robot @@ -22,7 +22,7 @@ Varargs and kwargs Keyword Arguments Should Be 4 *varargs: int **kwargs: bool Unknown types - Keyword Arguments Should Be 5 unknown: UnknownType unrecognized: Ellipsis + Keyword Arguments Should Be 5 unknown: UnknownType unrecognized: ... Non-type annotations Keyword Arguments Should Be 6 arg: One of the usages in PEP-3107 diff --git a/atest/robot/libdoc/types_via_keyword_decorator.robot b/atest/robot/libdoc/types_via_keyword_decorator.robot index 3d9576da3d8..6befe63f266 100644 --- a/atest/robot/libdoc/types_via_keyword_decorator.robot +++ b/atest/robot/libdoc/types_via_keyword_decorator.robot @@ -13,7 +13,7 @@ Varargs and kwargs Keyword Arguments Should Be 2 *varargs: int **kwargs: bool Unknown types - Keyword Arguments Should Be 3 unknown: UnknownType unrecognized: Ellipsis + Keyword Arguments Should Be 3 unknown: UnknownType unrecognized: ... Non-type annotations Keyword Arguments Should Be 4 arg: One of the usages in PEP-3107 diff --git a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py index 78a17ba0883..fc351791b76 100644 --- a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py +++ b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py @@ -1,6 +1,5 @@ -from typing import (List, Sequence, MutableSequence, - Dict, Mapping, MutableMapping, - Set, MutableSet) +from typing import (Dict, List, Mapping, MutableMapping, MutableSet, MutableSequence, + Set, Sequence, Tuple, Union) try: from typing import TypedDict except ImportError: @@ -25,7 +24,19 @@ def list_(argument: List, expected=None): _validate_type(argument, expected) -def list_with_params(argument: List[int], expected=None): +def list_with_types(argument: List[int], expected=None): + _validate_type(argument, expected) + + +def tuple_(argument: Tuple, expected=None): + _validate_type(argument, expected) + + +def tuple_with_types(argument: Tuple[bool, int], expected=None): + _validate_type(argument, expected) + + +def homogenous_tuple(argument: Tuple[int, ...], expected=None): _validate_type(argument, expected) @@ -33,7 +44,7 @@ def sequence(argument: Sequence, expected=None): _validate_type(argument, expected) -def sequence_with_params(argument: Sequence[bool], expected=None): +def sequence_with_types(argument: Sequence[Union[int, float]], expected=None): _validate_type(argument, expected) @@ -41,7 +52,7 @@ def mutable_sequence(argument: MutableSequence, expected=None): _validate_type(argument, expected) -def mutable_sequence_with_params(argument: MutableSequence[bool], expected=None): +def mutable_sequence_with_types(argument: MutableSequence[int], expected=None): _validate_type(argument, expected) @@ -49,7 +60,7 @@ def dict_(argument: Dict, expected=None): _validate_type(argument, expected) -def dict_with_params(argument: Dict[str, int], expected=None): +def dict_with_types(argument: Dict[int, float], expected=None): _validate_type(argument, expected) @@ -61,7 +72,7 @@ def mapping(argument: Mapping, expected=None): _validate_type(argument, expected) -def mapping_with_params(argument: Mapping[bool, int], expected=None): +def mapping_with_types(argument: Mapping[int, float], expected=None): _validate_type(argument, expected) @@ -69,7 +80,7 @@ def mutable_mapping(argument: MutableMapping, expected=None): _validate_type(argument, expected) -def mutable_mapping_with_params(argument: MutableMapping[bool, int], expected=None): +def mutable_mapping_with_types(argument: MutableMapping[int, float], expected=None): _validate_type(argument, expected) @@ -77,7 +88,7 @@ def set_(argument: Set, expected=None): _validate_type(argument, expected) -def set_with_params(argument: Set[bool], expected=None): +def set_with_types(argument: Set[int], expected=None): _validate_type(argument, expected) @@ -85,7 +96,7 @@ def mutable_set(argument: MutableSet, expected=None): _validate_type(argument, expected) -def mutable_set_with_params(argument: MutableSet[bool], expected=None): +def mutable_set_with_types(argument: MutableSet[float], expected=None): _validate_type(argument, expected) @@ -97,7 +108,7 @@ def forward_reference(argument: 'List', expected=None): _validate_type(argument, expected) -def forward_ref_with_params(argument: 'List[int]', expected=None): +def forward_ref_with_types(argument: 'List[int]', expected=None): _validate_type(argument, expected) @@ -109,6 +120,6 @@ def _validate_type(argument, expected): if isinstance(expected, str): expected = eval(expected) if argument != expected or type(argument) != type(expected): - raise AssertionError('%r (%s) != %r (%s)' - % (argument, type(argument).__name__, - expected, type(expected).__name__)) + atype = type(argument).__name__ + etype = type(expected).__name__ + raise AssertionError(f'{argument!r} ({atype}) != {expected!r} ({etype})') diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index dbe56008fbf..076cae5ae54 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -1,5 +1,5 @@ from datetime import date, datetime -from typing import List, Union +from typing import Dict, List, Set, Tuple, Union class Number: @@ -130,6 +130,14 @@ def accept_subscripted_generics(argument: AcceptSubscriptedGenerics, expected): assert argument.sum == expected +def with_generics(a: List[Number], b: Tuple[FiDate, UsDate], c: Dict[Number, FiDate], d: Set[Number]): + expected_date = date(2022, 9, 28) + assert a == [1, 2, 3], a + assert b == (expected_date, expected_date), b + assert c == {1: expected_date}, c + assert d == {1, 2, 3}, d + + def number_or_int(number: Union[Number, int]): assert number == 1 diff --git a/atest/testdata/keywords/type_conversion/StandardGenerics.py b/atest/testdata/keywords/type_conversion/StandardGenerics.py new file mode 100644 index 00000000000..1636167cb6a --- /dev/null +++ b/atest/testdata/keywords/type_conversion/StandardGenerics.py @@ -0,0 +1,43 @@ +def list_(argument: list[int], expected=None): + _validate_type(argument, expected) + + +def tuple_(argument: tuple[int, bool, float], expected=None): + _validate_type(argument, expected) + + +def homogenous_tuple(argument: tuple[int, ...], expected=None): + _validate_type(argument, expected) + + +def dict_(argument: dict[int, float], expected=None): + _validate_type(argument, expected) + + +def set_(argument: set[bool], expected=None): + _validate_type(argument, expected) + + +def invalid_list(a: list[int, float]): + pass + + +def invalid_tuple(a: tuple[int, float, ...]): + pass + + +def invalid_dict(a: dict[int]): + pass + + +def invalid_set(a: set[int, float]): + pass + + +def _validate_type(argument, expected): + if isinstance(expected, str): + expected = eval(expected) + if argument != expected or type(argument) != type(expected): + atype = type(argument).__name__ + etype = type(expected).__name__ + raise AssertionError(f'{argument!r} ({atype}) != {expected!r} ({etype})') diff --git a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot index 0edbeccb6cf..dce1845e74d 100644 --- a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot @@ -1,3 +1,5 @@ +Language: Finnish + *** Settings *** Library AnnotationsWithTyping.py Resource conversion.resource @@ -8,43 +10,91 @@ List List ['foo', 'bar'] ['foo', 'bar'] List [1, 2, 3.14, -42] [1, 2, 3.14, -42] -List with params - List with params [] [] - List with params ['foo', 'bar'] ['foo', 'bar'] - List with params [1, 2, 3.14, -42] [1, 2, 3.14, -42] +List with types + List with types [] [] + List with types [1, 2, 3, -42] [1, 2, 3, -42] + List with types [1, '2', 3.0] [1, 2, 3] + +List with incompatible types + [Template] Conversion Should Fail + List with types ['foo', 'bar'] type=List[int] error=Item '0' got value 'foo' that cannot be converted to integer. + List with types [0, 1, 2, 3, 4, 5, 6.1] type=List[int] error=Item '6' got value '6.1' (float) that cannot be converted to integer: Conversion would lose precision. Invalid list [Template] Conversion Should Fail - List [1, oops] error=Invalid expression. - List () error=Value is tuple, not list. - List with params ooops type=list error=Invalid expression. + List [1, oops] error=Invalid expression. + List () error=Value is tuple, not list. + List with types ooops type=List[int] error=Invalid expression. + +Tuple + Tuple () () + Tuple ('foo', 'bar') ('foo', 'bar') + Tuple (1, 2, 3.14, -42) (1, 2, 3.14, -42) + +Tuple with types + Tuple with types ('true', 1) (True, 1) + Tuple with types ('ei', '2') (False, 2) # 'ei' -> False is due to language config + +Tuple with homogenous types + Homogenous tuple () () + Homogenous tuple (1,) (1,) + Homogenous tuple (1, 2) (1, 2) + Homogenous tuple (1, 2, 3, 4, 5, 6, 7) (1, 2, 3, 4, 5, 6, 7) + +Tuple with incompatible types + [Template] Conversion Should Fail + Tuple with types ('bad', 'values') type=Tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. + Homogenous tuple ('bad', 'values') type=Tuple[int, ...] error=Item '0' got value 'bad' that cannot be converted to integer. + +Tuple with wrong number of values + [Template] Conversion Should Fail + Tuple with types ('false',) type=Tuple[bool, int] error=Expected 2 items, got 1. + Tuple with types ('too', 'many', '!') type=Tuple[bool, int] error=Expected 2 items, got 3. + +Invalid tuple + [Template] Conversion Should Fail + Tuple (1, oops) error=Invalid expression. + Tuple with types [] type=Tuple[bool, int] error=Value is list, not tuple. + Homogenous tuple ooops type=Tuple[int, ...] error=Invalid expression. Sequence Sequence [] [] Sequence ['foo', 'bar'] ['foo', 'bar'] Mutable sequence [1, 2, 3.14, -42] [1, 2, 3.14, -42] -Sequence with params - Sequence with params [] [] - Sequence with params ['foo', 'bar'] ['foo', 'bar'] - Mutable sequence with params - ... [1, 2, 3.14, -42] [1, 2, 3.14, -42] +Sequence with types + Sequence with types [] [] + Sequence with types [1, 2.3, '4', '5.6'] [1, 2.3, 4, 5.6] + Mutable sequence with types + ... [1, 2, 3.0, '4'] [1, 2, 3, 4] -Invalid Sequence +Sequence with incompatible types [Template] Conversion Should Fail - Sequence [1, oops] type=list error=Invalid expression. - Mutable sequence () type=list error=Value is tuple, not list. - Sequence with params ooops type=list error=Invalid expression. + Sequence with types [()] type=Sequence[int | float] error=Item '0' got value '()' (tuple) that cannot be converted to integer or float. + Mutable sequence with types [1, 2, 'x', 4] type=MutableSequence[int] error=Item '2' got value 'x' that cannot be converted to integer. + +Invalid sequence + [Template] Conversion Should Fail + Sequence [1, oops] type=list error=Invalid expression. + Mutable sequence () type=list error=Value is tuple, not list. + Sequence with types ooops type=Sequence[int | float] error=Invalid expression. Dict Dict {} {} Dict {'foo': 1, "bar": 2} {'foo': 1, "bar": 2} Dict {1: 2, 3.14: -42} {1: 2, 3.14: -42} -Dict with params - Dict with params {} {} - Dict with params {'foo': 1, "bar": 2} {'foo': 1, "bar": 2} - Dict with params {1: 2, 3.14: -42} {1: 2, 3.14: -42} +Dict with types + Dict with types {} {} + Dict with types {1: 1.1, 2: 2.2} {1: 1.1, 2: 2.2} + Dict with types {'1': '2', 3.0: 4} {1: 2, 3: 4} + +Dict with incompatible types + [Template] Conversion Should Fail + Dict with types {1: 2, 'bad': 3} type=Dict[int, float] error=Key 'bad' cannot be converted to integer. + Dict with types {None: 0} type=Dict[int, float] error=Key 'None' (None) cannot be converted to integer. + Dict with types {666: 'bad'} type=Dict[int, float] error=Item '666' got value 'bad' that cannot be converted to float. + Dict with types {0: None} type=Dict[int, float] error=Item '0' got value 'None' (None) that cannot be converted to float. TypedDict TypedDict {'x': 1} {'x': 1} @@ -55,42 +105,51 @@ TypedDict Invalid dictionary [Template] Conversion Should Fail - Dict {1: ooops} type=dictionary error=Invalid expression. - Dict [] type=dictionary error=Value is list, not dict. - Dict with params ooops type=dictionary error=Invalid expression. + Dict {1: ooops} type=dictionary error=Invalid expression. + Dict [] type=dictionary error=Value is list, not dict. + Dict with types ooops type=Dict[int, float] error=Invalid expression. Mapping Mapping {} {} Mapping {'foo': 1, "bar": 2} {'foo': 1, "bar": 2} Mutable mapping {1: 2, 3.14: -42} {1: 2, 3.14: -42} -Mapping with params - Mapping with params {} {} - Mapping with params {'foo': 1, "bar": 2} {'foo': 1, "bar": 2} - Mutable mapping with params - ... {1: 2, 3.14: -42} {1: 2, 3.14: -42} +Mapping with types + Mapping with types {} {} + Mapping with types {1: 2, '3': 4.0} {1: 2, 3: 4} + Mutable mapping with types {1: 2, '3': 4.0} {1: 2, 3: 4} + +Mapping with incompatible types + [Template] Conversion Should Fail + Mutable mapping with types {'bad': 2} type=MutableMapping[int, float] error=Key 'bad' cannot be converted to integer. + Mapping with types {1: 'bad'} type=Mapping[int, float] error=Item '1' got value 'bad' that cannot be converted to float. Invalid mapping [Template] Conversion Should Fail - Mapping {1: ooops} type=dictionary error=Invalid expression. - Mutable mapping [] type=dictionary error=Value is list, not dict. - Mapping with params ooops type=dictionary error=Invalid expression. + Mapping {1: ooops} type=dictionary error=Invalid expression. + Mutable mapping [] type=dictionary error=Value is list, not dict. + Mapping with types ooops type=Mapping[int, float] error=Invalid expression. Set Set set() set() - Set {'foo', 'bar'} {'foo', 'bar'} + Set {1, 2.0, '3'} {1, 2.0, '3'} Mutable set {1, 2, 3.14, -42} {1, 2, 3.14, -42} -Set with params - Set with params set() set() - Set with params {'foo', 'bar'} {'foo', 'bar'} - Mutable set with params {1, 2, 3.14, -42} {1, 2, 3.14, -42} +Set with types + Set with types set() set() + Set with types {1, 2.0, '3'} {1, 2, 3} + Mutable set with types {1, 2, 3.14, -42} {1, 2, 3.14, -42} + +Set with incompatible types + [Template] Conversion Should Fail + Set with types {1, 2.0, 'three'} type=Set[int] error=Item 'three' cannot be converted to integer. + Mutable set with types {1, 2.0, 'three'} type=MutableSet[float] error=Item 'three' cannot be converted to float. Invalid Set [Template] Conversion Should Fail - Set {1, ooops} error=Invalid expression. - Set {} error=Value is dictionary, not set. - Set ooops error=Invalid expression. + Set {1, ooops} error=Invalid expression. + Set {} error=Value is dictionary, not set. + Set ooops error=Invalid expression. None as default None as default @@ -98,7 +157,7 @@ None as default Forward references Forward reference [1, 2, 3, 4] [1, 2, 3, 4] - Forward ref with params [1, 2, 3, 4] [1, 2, 3, 4] + Forward ref with types [1, '2', 3, 4.0] [1, 2, 3, 4] Type hint not liking `isinstance` Not liking isinstance 42 42 diff --git a/atest/testdata/keywords/type_conversion/custom_converters.robot b/atest/testdata/keywords/type_conversion/custom_converters.robot index 94c6cf43a35..b6772768e20 100644 --- a/atest/testdata/keywords/type_conversion/custom_converters.robot +++ b/atest/testdata/keywords/type_conversion/custom_converters.robot @@ -42,6 +42,13 @@ Custom in Union Accept subscripted generics Accept subscripted generics ${{[1, 2, 3]}} ${6} +With generics + With generics + ... ['one', 'two', 'three'] + ... ('28.9.2022', '9/28/2022') + ... {'one': '28.9.2022'} + ... {'one', 'two', 'three'} + Failing conversion [Template] Conversion should fail Number wrong type=Number error=Don't know number 'wrong'. diff --git a/atest/testdata/keywords/type_conversion/standard_generics.robot b/atest/testdata/keywords/type_conversion/standard_generics.robot new file mode 100644 index 00000000000..97848d27aed --- /dev/null +++ b/atest/testdata/keywords/type_conversion/standard_generics.robot @@ -0,0 +1,68 @@ +language: fi + +*** Settings *** +Library StandardGenerics.py +Resource conversion.resource +Force Tags require-py3.9 + +*** Test Cases *** +List + List [] [] + List [1, 2, 3] [1, 2, 3] + List ['1', 2.0] [1, 2] + +Incompatible list + [Template] Conversion should fail + List [1, 'bad'] type=list[int] error=Item '1' got value 'bad' that cannot be converted to integer. + List [1, 2, 3.4] type=list[int] error=Item '2' got value '3.4' (float) that cannot be converted to integer: Conversion would lose precision. + +Tuple + Tuple (1, 'true', 3.14) (1, True, 3.14) + Tuple ('1', 'ei', '3.14') (1, False, 3.14) # 'ei' -> False conversion is due to language config. + +Homogenous tuple + Homogenous Tuple () () + Homogenous Tuple (1,) (1,) + Homogenous Tuple (1, 2, '3', 4.0, 5) (1, 2, 3, 4, 5) + +Incompatible tuple + [Template] Conversion should fail + Tuple (1, 2, 'bad') type=tuple[int, bool, float] error=Item '2' got value 'bad' that cannot be converted to float. + Homogenous Tuple (1, '2', 3.0, 'four') type=tuple[int, ...] error=Item '3' got value 'four' that cannot be converted to integer. + Tuple ('too', 'few') type=tuple[int, bool, float] error=Expected 3 items, got 2. + Tuple ('too', 'many', '!', '!') type=tuple[int, bool, float] error=Expected 3 items, got 4. + +Dict + Dict {} {} + Dict {1: 2} {1: 2} + Dict {1: 2, '3': 4.0} {1: 2, 3: 4} + +Incompatible dict + [Template] Conversion should fail + Dict {1: 2, 'bad': 'item'} type=dict[int, float] error=Key 'bad' cannot be converted to integer. + Dict {1: 'bad'} type=dict[int, float] error=Item '1' got value 'bad' that cannot be converted to float. + +Set + Set set() set() + Set {True} {True} + Set {'kyllä', 'ei'} {True, False} # 'kyllä' and 'ei' conversions are due to language config. + +Incompatible set + [Template] Conversion should fail + Set {()} type=set[bool] error=Item '()' (tuple) cannot be converted to boolean. + +Invalid list + [Documentation] FAIL TypeError: list[] construct used as a type hint requires exactly 1 nested type, got 2. + Invalid List whatever + +Invalid tuple + [Documentation] FAIL TypeError: Homogenous tuple used as a type hint requires exactly one nested type, got 2. + Invalid Tuple whatever + +Invalid dict + [Documentation] FAIL TypeError: dict[] construct used as a type hint requires exactly 2 nested types, got 1. + Invalid Dict whatever + +Invalid set + [Documentation] FAIL TypeError: set[] construct used as a type hint requires exactly 1 nested type, got 2. + Invalid set whatever diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 05999e4c4f0..0cbc15adf12 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1161,13 +1161,9 @@ __ `Implicit argument types based on default values`_ The type to use can be specified either using concrete types (e.g. list_), by using Abstract Base Classes (ABC) (e.g. Sequence_), or by using sub -classes of these types (e.g. MutableSequence_). In all these cases the -argument is converted to the concrete type. - -Also types in in the typing_ module that map to the supported concrete -types or ABCs (e.g. `List`) are supported. With generics also the subscription -syntax (e.g. `List[int]`) works, but no validation is done for container -contents. +classes of these types (e.g. MutableSequence_). Also types in in the typing_ +module that map to the supported concrete types or ABCs (e.g. `List`) are +supported. In all these cases the argument is converted to the concrete type. In addition to using the actual types (e.g. `int`), it is possible to specify the type using type names as a string (e.g. `'int'`) and some types also have @@ -1433,6 +1429,38 @@ __ https://github.com/robotframework/robotframework/issues/3897 __ https://github.com/robotframework/robotframework/issues/3908 .. _Union: https://docs.python.org/3/library/typing.html#typing.Union +Type conversion with generics +''''''''''''''''''''''''''''' + +With generics also the parameterized syntax like `list[int]` or `dict[str, int]` +works. When this syntax is used, the given value is first converted to the base +type and then individual items are converted to the nested types. Conversion +with different generic types works according to these rules: + +- With lists there can be only one type like `list[float]`. All list items are + converted to that type. +- With tuples there can be any number of types like `tuple[int, int]` and + `tuple[str, int, bool]`. Tuples used as arguments are expected to have + exactly that amount of items and they are converted to matching types. +- To create a homogeneous tuple, it is possible to use exactly one type and + ellipsis like `tuple[int, ...]`. In this case tuple can have any number + of items and they are all converted to the specified type. +- With dictionaries there must be exactly two types like `dict[str, int]`. + Dictionary keys are converted using the former type and values using the latter. +- With sets there can be exactly one type like `set[float]`. Conversion logic + is the same as with lists. + +.. note:: Support for converting nested types with generics is new in + Robot Framework 5.1. Same syntax works also with earlier versions, + but arguments are only converted to the base type and nested types + are not used for anything. + +.. note:: Using generics with Python standard types like `list[int]` is new + in `Python 3.9`__. With earlier versions matching types from + the typing_ module can be used like `List[int]`. + +__ https://peps.python.org/pep-0585/ + Custom argument converters '''''''''''''''''''''''''' diff --git a/src/robot/libdocpkg/datatypes.py b/src/robot/libdocpkg/datatypes.py index e4aa6361d92..4f3bb3c744e 100644 --- a/src/robot/libdocpkg/datatypes.py +++ b/src/robot/libdocpkg/datatypes.py @@ -31,12 +31,13 @@ class TypeDoc(Sortable): CUSTOM = 'Custom' STANDARD = 'Standard' - def __init__(self, type, name, doc, accepts=(), usages=None, + def __init__(self, type, name, doc, accepts=(), usages=None, origin=None, members=None, items=None): self.type = type self.name = name self.doc = doc or '' # doc parsed from XML can be None. self.accepts = [type_name(t) if not isinstance(t, str) else t for t in accepts] + self.origin = origin or name self.usages = usages or [] # Enum members and TypedDict items are used only with appropriate types. self.members = members @@ -47,12 +48,12 @@ def _sort_key(self): return self.name.lower() @classmethod - def for_type(cls, type, converters): - if isinstance(type, EnumType): - return cls.for_enum(type) - if isinstance(type, typeddict_types): - return cls.for_typed_dict(type) - converter = TypeConverter.converter_for(type, converters) + def for_type(cls, type_hint, converters): + if isinstance(type_hint, EnumType): + return cls.for_enum(type_hint) + if isinstance(type_hint, typeddict_types): + return cls.for_typed_dict(type_hint) + converter = TypeConverter.converter_for(type_hint, converters) if not converter: return None elif not converter.type: @@ -60,7 +61,8 @@ def for_type(cls, type, converters): converter.value_types) else: return cls(cls.STANDARD, converter.type_name, - STANDARD_TYPE_DOCS[converter.type], converter.value_types) + STANDARD_TYPE_DOCS[converter.type], converter.value_types, + origin=type(converter).type_name) @classmethod def for_enum(cls, enum): diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index c8de00a3836..ba1616d234f 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -73,7 +73,7 @@ def _get_type_docs(self, keywords, custom_converters): for typ in arg.types: type_doc = TypeDoc.for_type(typ, custom_converters) if type_doc: - kw.type_docs[arg.name][type_repr(typ)] = type_doc.name + kw.type_docs[arg.name][type_repr(typ)] = type_doc.origin type_docs.setdefault(type_doc, set()).add(kw.name) for type_doc, usages in type_docs.items(): type_doc.usages = sorted(usages, key=str.lower) diff --git a/src/robot/libdocpkg/standardtypes.py b/src/robot/libdocpkg/standardtypes.py index 328b234e25a..87558329d3e 100644 --- a/src/robot/libdocpkg/standardtypes.py +++ b/src/robot/libdocpkg/standardtypes.py @@ -125,6 +125,9 @@ function. They can contain any values ``ast.literal_eval`` supports, including lists and other containers. +If the type has nested types like ``list[int]``, items are converted +to those types automatically. This in new in Robot Framework 5.1. + Examples: ``['one', 'two']``, ``[('one', 1), ('two', 2)]`` ''', tuple: '''\ @@ -134,6 +137,9 @@ function. They can contain any values ``ast.literal_eval`` supports, including tuples and other containers. +If the type has nested types like ``tuple[str, int, int]``, items are converted +to those types automatically. This in new in Robot Framework 5.1. + Examples: ``('one', 'two')``, ``(('one', 1), ('two', 2))`` ''', dict: '''\ @@ -143,6 +149,9 @@ function. They can contain any values ``ast.literal_eval`` supports, including dictionaries and other containers. +If the type has nested types like ``dict[str, int]``, items are converted +to those types automatically. This in new in Robot Framework 5.1. + Examples: ``{'a': 1, 'b': 2}``, ``{'key': 1, 'nested': {'key': 2}}`` ''', set: '''\ @@ -151,6 +160,9 @@ [https://docs.python.org/library/ast.html#ast.literal_eval|ast.literal_eval] function. They can contain any values ``ast.literal_eval`` supports. +If the type has nested types like ``set[int]``, items are converted +to those types automatically. This in new in Robot Framework 5.1. + Examples: ``{1, 2, 3, 42}``, ``set()`` (an empty set) ''', frozenset: '''\ @@ -160,6 +172,9 @@ function and then converted to ``frozenset`` objects. They can contain any values ``ast.literal_eval`` supports. +If the type has nested types like ``frozenset[int]``, items are converted +to those types automatically. This in new in Robot Framework 5.1. + Examples: ``{1, 2, 3, 42}``, ``set()`` (an empty set) ''' } diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 86348c69069..18d4ed2ac83 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -16,7 +16,7 @@ from ast import literal_eval from collections import OrderedDict from collections.abc import ByteString, Container, Mapping, Sequence, Set -from typing import Any, Union +from typing import Any, Tuple, TypeVar, Union from datetime import datetime, date, timedelta from decimal import InvalidOperation, Decimal from enum import Enum @@ -26,8 +26,8 @@ from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time -from robot.utils import (eq, get_error_message, is_string, is_union, - safe_str, seq2str, type_name) +from robot.utils import (eq, get_error_message, is_string, is_union, plural_or_not as s, + safe_str, seq2str, type_name, type_repr) NoneType = type(None) @@ -62,22 +62,23 @@ def converter_for(cls, type_, custom_converters=None, languages=None): hash(type_) except TypeError: return None - if getattr(type_, '__origin__', None) and type_.__origin__ is not Union: - type_ = type_.__origin__ if isinstance(type_, str): try: type_ = cls._type_aliases[type_.lower()] except KeyError: return None + used_type = type_ + if getattr(type_, '__origin__', None) and type_.__origin__ is not Union: + type_ = type_.__origin__ if custom_converters: info = custom_converters.get_converter_info(type_) if info: - return CustomConverter(type_, info) + return CustomConverter(used_type, info) if type_ in cls._converters: - return cls._converters[type_](type_, languages=languages) + return cls._converters[type_](used_type, custom_converters, languages) for converter in cls._converters.values(): if converter.handles(type_): - return converter(type_, custom_converters, languages) + return converter(used_type, custom_converters, languages) return None @classmethod @@ -85,17 +86,17 @@ def handles(cls, type_): handled = (cls.type, cls.abc) if cls.abc else cls.type return isinstance(type_, type) and issubclass(type_, handled) - def convert(self, name, value, explicit_type=True, strict=True): + def convert(self, name, value, explicit_type=True, strict=True, kind='Argument'): if self.no_conversion_needed(value): return value if not self._handles_value(value): - return self._handle_error(name, value, strict=strict) + return self._handle_error(name, value, kind, strict=strict) try: if not isinstance(value, str): return self._non_string_convert(value, explicit_type) return self._convert(value, explicit_type) except ValueError as error: - return self._handle_error(name, value, error, strict) + return self._handle_error(name, value, kind, error, strict) def no_conversion_needed(self, value): try: @@ -116,13 +117,19 @@ def _non_string_convert(self, value, explicit_type=True): def _convert(self, value, explicit_type=True): raise NotImplementedError - def _handle_error(self, name, value, error=None, strict=True): + def _handle_error(self, name, value, kind, error=None, strict=True): if not strict: return value value_type = '' if isinstance(value, str) else f' ({type_name(value)})' + value = safe_str(value) ending = f': {error}' if (error and error.args) else '.' + if name is None: + raise ValueError( + f"{kind} '{value}'{value_type} " + f"cannot be converted to {self.type_name}{ending}" + ) raise ValueError( - f"Argument '{name}' got value '{safe_str(value)}'{value_type} that " + f"{kind} '{name}' got value '{value}'{value_type} that " f"cannot be converted to {self.type_name}{ending}" ) @@ -141,6 +148,19 @@ def _literal_eval(self, value, expected): raise ValueError(f'Value is {type_name(value)}, not {expected.__name__}.') return value + def _get_nested_types(self, type_hint, expected_count=None): + types = getattr(type_hint, '__args__', ()) + # With generics from typing like Dict, __args__ is None with Python 3.6 and + # contains TypeVars with 3.7-3.8. Newer versions don't have __args__ at all. + # Subscripted usages like Dict[x, y].__args__ work fine with all. + if not types or all(isinstance(a, TypeVar) for a in types): + return () + if expected_count and len(types) != expected_count: + raise TypeError(f'{type_hint.__name__}[] construct used as a type hint ' + f'requires exactly {expected_count} nested ' + f'type{s(expected_count)}, got {len(types)}.') + return types + def _remove_number_separators(self, value): if is_string(value): for sep in ' ', '_': @@ -403,16 +423,36 @@ class ListConverter(TypeConverter): abc = Sequence value_types = (str, Sequence) + def __init__(self, used_type, custom_converters=None, languages=None): + super().__init__(used_type, custom_converters, languages) + types = self._get_nested_types(used_type, expected_count=1) + if not types: + self.converter = None + else: + self.type_name = type_repr(used_type) + self.converter = self.converter_for(types[0], custom_converters, languages) + + @classmethod + def handles(cls, type_): + # `type_ is not Tuple` is needed with Python 3.6. + return super().handles(type_) and type_ is not Tuple + def no_conversion_needed(self, value): if isinstance(value, str): return False return super().no_conversion_needed(value) def _non_string_convert(self, value, explicit_type=True): - return list(value) + return self._convert_items(list(value), explicit_type) def _convert(self, value, explicit_type=True): - return self._literal_eval(value, list) + return self._convert_items(self._literal_eval(value, list), explicit_type) + + def _convert_items(self, value, explicit_type): + if not self.converter: + return value + return [self.converter.convert(i, v, explicit_type, kind='Item') + for i, v in enumerate(value)] @TypeConverter.register @@ -421,11 +461,41 @@ class TupleConverter(TypeConverter): type_name = 'tuple' value_types = (str, Sequence) + def __init__(self, used_type, custom_converters=None, languages=None): + super().__init__(used_type, custom_converters, languages) + self.converters = () + self.homogenous = False + types = self._get_nested_types(used_type) + if not types: + return + if types[-1] is Ellipsis: + types = types[:-1] + if len(types) != 1: + raise TypeError(f'Homogenous tuple used as a type hint requires ' + f'exactly one nested type, got {len(types)}.') + self.homogenous = True + self.type_name = type_repr(used_type) + self.converters = tuple(self.converter_for(t, custom_converters, languages) + for t in types) + def _non_string_convert(self, value, explicit_type=True): - return tuple(value) + return self._convert_items(tuple(value), explicit_type) def _convert(self, value, explicit_type=True): - return self._literal_eval(value, tuple) + return self._convert_items(self._literal_eval(value, tuple), explicit_type) + + def _convert_items(self, value, explicit_type): + if not self.converters: + return value + if self.homogenous: + conv = self.converters[0] + return tuple(conv.convert(str(i), v, explicit_type, kind='Item') + for i, v in enumerate(value)) + if len(self.converters) != len(value): + raise ValueError(f'Expected {len(self.converters)} ' + f'item{s(self.converters)}, got {len(value)}.') + return tuple(conv.convert(i, v, explicit_type, kind='Item') + for i, (conv, v) in enumerate(zip(self.converters, value))) @TypeConverter.register @@ -436,13 +506,36 @@ class DictionaryConverter(TypeConverter): aliases = ('dict', 'map') value_types = (str, Mapping) + def __init__(self, used_type, custom_converters=None, languages=None): + super().__init__(used_type, custom_converters, languages) + types = self._get_nested_types(used_type, expected_count=2) + if not types: + self.converters = () + else: + self.type_name = type_repr(used_type) + self.converters = tuple(self.converter_for(t, custom_converters, languages) + for t in types) + def _non_string_convert(self, value, explicit_type=True): if issubclass(self.used_type, dict) and not isinstance(value, dict): - return dict(value) - return value + value = dict(value) + return self._convert_items(value, explicit_type) def _convert(self, value, explicit_type=True): - return self._literal_eval(value, dict) + return self._convert_items(self._literal_eval(value, dict), explicit_type) + + def _convert_items(self, value, explicit_type): + if not self.converters: + return value + convert_key = self.__get_converter(self.converters[0], explicit_type, 'Key') + convert_value = self.__get_converter(self.converters[1], explicit_type, 'Item') + return {convert_key(None, k): convert_value(str(k), v) for k, v in value.items()} + + def __get_converter(self, converter, explicit_type, kind): + if not converter: + return lambda name, value: value + return lambda name, value: converter.convert(name, value, explicit_type, + kind=kind) @TypeConverter.register @@ -452,27 +545,41 @@ class SetConverter(TypeConverter): type_name = 'set' value_types = (str, Container) + def __init__(self, used_type, custom_converters=None, languages=None): + super().__init__(used_type, custom_converters, languages) + types = self._get_nested_types(used_type, expected_count=1) + if not types: + self.converter = None + else: + self.type_name = type_repr(used_type) + self.converter = self.converter_for(types[0], custom_converters, languages) + def _non_string_convert(self, value, explicit_type=True): - return set(value) + return self._convert_items(set(value), explicit_type) def _convert(self, value, explicit_type=True): - return self._literal_eval(value, set) + return self._convert_items(self._literal_eval(value, set), explicit_type) + + def _convert_items(self, value, explicit_type): + if not self.converter: + return value + return {self.converter.convert(None, v, explicit_type, kind='Item') + for v in value} @TypeConverter.register -class FrozenSetConverter(TypeConverter): +class FrozenSetConverter(SetConverter): type = frozenset type_name = 'frozenset' - value_types = (str, Container) def _non_string_convert(self, value, explicit_type=True): - return frozenset(value) + return frozenset(super()._non_string_convert(value, explicit_type)) def _convert(self, value, explicit_type=True): # There are issues w/ literal_eval. See self._literal_eval for details. if value == 'frozenset()': return frozenset() - return frozenset(self._literal_eval(value, set)) + return frozenset(super()._convert(value, explicit_type)) @TypeConverter.register @@ -481,8 +588,8 @@ class CombinedConverter(TypeConverter): def __init__(self, union, custom_converters, languages=None): super().__init__(self._get_types(union)) - self.converters = [TypeConverter.converter_for(t, custom_converters, languages) - for t in self.used_type] + self.converters = tuple(self.converter_for(t, custom_converters, languages) + for t in self.used_type) def _get_types(self, union): if not union: diff --git a/src/robot/utils/robottypes.py b/src/robot/utils/robottypes.py index 72e000ca537..99f3bb9b4b1 100644 --- a/src/robot/utils/robottypes.py +++ b/src/robot/utils/robottypes.py @@ -116,6 +116,8 @@ def type_repr(typ): """ if typ is type(None): return 'None' + if typ is Ellipsis: + return '...' if typ is Any: # Needed with Python 3.6, with newer `Any._name` exists. return 'Any' if is_union(typ): @@ -137,9 +139,11 @@ def _get_type_name(typ): def _has_args(typ): args = getattr(typ, '__args__', ()) - # TypeVar check needed due to Python 3.6 having such thing in `__args__` - # even if using just `List`. - return args and not isinstance(typ.__args__[0], TypeVar) + # __args__ contains TypeVars when accessed directly from typing.List and other + # such types withPython 3.7-3.8. With Python 3.6 __args__ is None in that case + # and with Python 3.9+ it doesn't exist at all. When using like List[int].__args__ + # everything works the same way regardless the version. + return args and not all(isinstance(t, TypeVar) for t in args) def is_truthy(item): diff --git a/utest/utils/test_robottypes.py b/utest/utils/test_robottypes.py index 7aa73b9fef0..2c19d7afa11 100644 --- a/utest/utils/test_robottypes.py +++ b/utest/utils/test_robottypes.py @@ -188,6 +188,9 @@ class Foo: def test_none(self): assert_equal(type_repr(None), 'None') + def test_ellipsis(self): + assert_equal(type_repr(...), '...') + def test_string(self): assert_equal(type_repr('MyType'), 'MyType') @@ -197,6 +200,7 @@ def test_no_typing_prefix(self): def test_generics_from_typing(self): assert_equal(type_repr(List[Any]), 'List[Any]') assert_equal(type_repr(Dict[int, None]), 'Dict[int, None]') + assert_equal(type_repr(Tuple[int, ...]), 'Tuple[int, ...]') if PY_VERSION >= (3, 9): def test_generics(self): From cbf0d44fb50aeff5f7881551ced57b31f2e55e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 29 Sep 2022 10:03:28 +0300 Subject: [PATCH 0225/1592] TypedDict conversion. Fixes #4477. --- .../annotations_with_typing.robot | 18 +++++-- .../type_conversion/custom_converters.robot | 3 ++ .../type_conversion/AnnotationsWithTyping.py | 25 ++++++--- .../type_conversion/CustomConverters.py | 15 ++++++ .../annotations_with_typing.robot | 36 ++++++++++--- .../type_conversion/custom_converters.robot | 3 ++ src/robot/running/arguments/typeconverters.py | 51 ++++++++++++++++++- utest/libdoc/test_datatypes.py | 7 +-- 8 files changed, 138 insertions(+), 20 deletions(-) diff --git a/atest/robot/keywords/type_conversion/annotations_with_typing.robot b/atest/robot/keywords/type_conversion/annotations_with_typing.robot index e61be332a4a..67f9ec4836f 100644 --- a/atest/robot/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/robot/keywords/type_conversion/annotations_with_typing.robot @@ -54,9 +54,6 @@ Dict with types Dict with incompatible types Check Test Case ${TESTNAME} -TypedDict - Check Test Case ${TESTNAME} - Invalid dictionary Check Test Case ${TESTNAME} @@ -72,6 +69,21 @@ Mapping with incompatible types Invalid mapping Check Test Case ${TESTNAME} +TypedDict + Check Test Case ${TESTNAME} + +Optional TypedDict keys can be omitted + Check Test Case ${TESTNAME} + +Required TypedDict keys cannot be omitted + Check Test Case ${TESTNAME} + +Incompatible TypedDict + Check Test Case ${TESTNAME} + +Invalid TypedDict + Check Test Case ${TESTNAME} + Set Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/custom_converters.robot b/atest/robot/keywords/type_conversion/custom_converters.robot index e1fa0daab42..a4d70fb7213 100644 --- a/atest/robot/keywords/type_conversion/custom_converters.robot +++ b/atest/robot/keywords/type_conversion/custom_converters.robot @@ -24,6 +24,9 @@ Accept subscripted generics With generics Check Test Case ${TESTNAME} +With TypedDict + Check Test Case ${TESTNAME} + Failing conversion Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py index fc351791b76..41af7ecf4be 100644 --- a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py +++ b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py @@ -1,9 +1,9 @@ from typing import (Dict, List, Mapping, MutableMapping, MutableSet, MutableSequence, Set, Sequence, Tuple, Union) try: - from typing import TypedDict -except ImportError: from typing_extensions import TypedDict +except ImportError: + from typing import TypedDict from robot.api.deco import not_keyword @@ -11,6 +11,15 @@ TypedDict = not_keyword(TypedDict) +class Point2D(TypedDict): + x: int + y: int + + +class Point(Point2D, total=False): + z: int + + class BadIntMeta(type(int)): def __instancecheck__(self, instance): raise TypeError('Bang!') @@ -64,10 +73,6 @@ def dict_with_types(argument: Dict[int, float], expected=None): _validate_type(argument, expected) -def typeddict(argument: TypedDict('X', x=int), expected=None): - _validate_type(argument, expected) - - def mapping(argument: Mapping, expected=None): _validate_type(argument, expected) @@ -84,6 +89,14 @@ def mutable_mapping_with_types(argument: MutableMapping[int, float], expected=No _validate_type(argument, expected) +def typeddict(argument: Point2D, expected=None): + _validate_type(argument, expected) + + +def typeddict_with_optional(argument: Point, expected=None): + _validate_type(argument, expected) + + def set_(argument: Set, expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index 076cae5ae54..681ecdcdd30 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -1,5 +1,14 @@ from datetime import date, datetime from typing import Dict, List, Set, Tuple, Union +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict + +from robot.api.deco import not_keyword + + +not_keyword(TypedDict) class Number: @@ -138,6 +147,12 @@ def with_generics(a: List[Number], b: Tuple[FiDate, UsDate], c: Dict[Number, FiD assert d == {1, 2, 3}, d +def typeddict(dates: TypedDict('Dates', {'fi': FiDate, 'us': UsDate})): + fi, us = dates['fi'], dates['us'] + exp = date(2022, 9, 29) + assert isinstance(fi, FiDate) and isinstance(us, UsDate) and fi == us == exp + + def number_or_int(number: Union[Number, int]): assert number == 1 diff --git a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot index dce1845e74d..311f0ce80f0 100644 --- a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot @@ -96,13 +96,6 @@ Dict with incompatible types Dict with types {666: 'bad'} type=Dict[int, float] error=Item '666' got value 'bad' that cannot be converted to float. Dict with types {0: None} type=Dict[int, float] error=Item '0' got value 'None' (None) that cannot be converted to float. -TypedDict - TypedDict {'x': 1} {'x': 1} - # Following would fail if we'd validate TypedDict and didn't only convert to dict. - TypedDict {} {} - TypedDict {'foo': 1, "bar": 2} {'foo': 1, "bar": 2} - TypedDict {1: 2, 3.14: -42} {1: 2, 3.14: -42} - Invalid dictionary [Template] Conversion Should Fail Dict {1: ooops} type=dictionary error=Invalid expression. @@ -130,6 +123,35 @@ Invalid mapping Mutable mapping [] type=dictionary error=Value is list, not dict. Mapping with types ooops type=Mapping[int, float] error=Invalid expression. +TypedDict + TypedDict {'x': 1, 'y': 2} {'x': 1, 'y': 2} + TypedDict {'x': -10_000, 'y': '2'} {'x': -10000, 'y': 2} + TypedDict with optional {'x': 1, 'y': 2, 'z': 3} {'x': 1, 'y': 2, 'z': 3} + +Optional TypedDict keys can be omitted + TypedDict with optional {'x': 0, 'y': 0} {'x': 0, 'y': 0} + +Required TypedDict keys cannot be omitted + [Documentation] This test would fail if using Python 3.8 without typing_extensions! + ... In that case there's no information about required/optional keys. + [Template] Conversion Should Fail + TypedDict {'x': 123} type=Point2D error=Required item 'y' missing. + TypedDict {} type=Point2D error=Required items 'x' and 'y' missing. + TypedDict with optional {} type=Point error=Required items 'x' and 'y' missing. + +Incompatible TypedDict + [Template] Conversion Should Fail + TypedDict {'x': 'bad'} type=Point2D error=Item 'x' got value 'bad' that cannot be converted to integer. + TypedDict {'bad': 1} type=Point2D error=Item 'bad' not allowed. Available items: 'x' and 'y' + TypedDict {'x': 1, 'y': 2, 'z': 3} type=Point2D error=Item 'z' not allowed. + TypedDict with optional {'x': 1, 'b': 2, 'z': 3} type=Point error=Item 'b' not allowed. Available item: 'y' + TypedDict with optional {'b': 1, 'a': 2, 'd': 3} type=Point error=Items 'a', 'b' and 'd' not allowed. Available items: 'x', 'y' and 'z' + +Invalid TypedDict + [Template] Conversion Should Fail + TypedDict {'x': oops} type=Point2D error=Invalid expression. + TypedDict [] type=Point2D error=Value is list, not dict. + Set Set set() set() Set {1, 2.0, '3'} {1, 2.0, '3'} diff --git a/atest/testdata/keywords/type_conversion/custom_converters.robot b/atest/testdata/keywords/type_conversion/custom_converters.robot index b6772768e20..3328e8ef8b4 100644 --- a/atest/testdata/keywords/type_conversion/custom_converters.robot +++ b/atest/testdata/keywords/type_conversion/custom_converters.robot @@ -49,6 +49,9 @@ With generics ... {'one': '28.9.2022'} ... {'one', 'two', 'three'} +With TypedDict + TypedDict {'fi': '29.9.2022', 'us': '9/29/2022'} + Failing conversion [Template] Conversion should fail Number wrong type=Number error=Don't know number 'wrong'. diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 18d4ed2ac83..18f5adc2966 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, is_string, is_union, plural_or_not as s, - safe_str, seq2str, type_name, type_repr) + safe_str, seq2str, type_name, type_repr, typeddict_types) NoneType = type(None) @@ -498,6 +498,55 @@ def _convert_items(self, value, explicit_type): for i, (conv, v) in enumerate(zip(self.converters, value))) +@TypeConverter.register +class TypedDictConverter(TypeConverter): + type = 'TypedDict' + value_types = (str, Mapping) + + def __init__(self, used_type, custom_converters, languages=None): + super().__init__(used_type, custom_converters, languages) + self.converters = {n: self.converter_for(t, custom_converters, languages) + for n, t in used_type.__annotations__.items()} + self.type_name = used_type.__name__ + # __required_keys__ is new in Python 3.9. + self.required_keys = getattr(used_type, '__required_keys__', frozenset()) + + @classmethod + def handles(cls, type_): + return isinstance(type_, typeddict_types) + + def no_conversion_needed(self, value): + return False + + def _non_string_convert(self, value, explicit_type=True): + return self._convert_items(value) + + def _convert(self, value, explicit_type=True): + return self._convert_items(self._literal_eval(value, dict)) + + def _convert_items(self, value): + not_allowed = [] + for key in value: + try: + converter = self.converters[key] + except KeyError: + not_allowed.append(key) + else: + if converter: + value[key] = converter.convert(key, value[key], kind='Item') + if not_allowed: + error = f'Item{s(not_allowed)} {seq2str(sorted(not_allowed))} not allowed.' + available = [key for key in self.converters if key not in value] + if available: + error += f' Available item{s(available)}: {seq2str(sorted(available))}' + raise ValueError(error) + missing = [key for key in self.required_keys if key not in value] + if missing: + raise ValueError(f"Required item{s(missing)} " + f"{seq2str(sorted(missing))} missing.") + return value + + @TypeConverter.register class DictionaryConverter(TypeConverter): type = dict diff --git a/utest/libdoc/test_datatypes.py b/utest/libdoc/test_datatypes.py index 95c10b2b92f..e6020b697cb 100644 --- a/utest/libdoc/test_datatypes.py +++ b/utest/libdoc/test_datatypes.py @@ -1,12 +1,13 @@ import unittest from robot.libdocpkg.standardtypes import STANDARD_TYPE_DOCS -from robot.running.arguments.typeconverters import (EnumConverter, CombinedConverter, - CustomConverter, TypeConverter) +from robot.running.arguments.typeconverters import ( + EnumConverter, CombinedConverter, CustomConverter, TypeConverter, TypedDictConverter +) class TestStandardTypeDocs(unittest.TestCase): - no_std_docs = (EnumConverter, CombinedConverter, CustomConverter) + no_std_docs = (EnumConverter, CombinedConverter, CustomConverter, TypedDictConverter) def test_all_standard_types_have_docs(self): for cls in TypeConverter.__subclasses__(): From bed589e8314168b47b7ddbbce06ecc2be0e8dac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 29 Sep 2022 13:25:00 +0300 Subject: [PATCH 0226/1592] Add missing TypedDict conversion documentation. #4477 --- .../CreatingTestLibraries.rst | 23 +++++++++++++------ doc/userguide/src/SupportingTools/Libdoc.rst | 1 - 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 0cbc15adf12..d1f39334fa0 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1278,17 +1278,25 @@ Other types cause conversion failures. | | | | | that are not lists are converted to lists. If the type hint is | | | | | | | generic Sequence_, sequences are used without conversion. | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | tuple_ | | | str_, | Same as list_, but string arguments must tuple literals. | | `('one', 'two')` | + | tuple_ | | | str_, | Same as `list`, but string arguments must tuple literals. | | `('one', 'two')` | | | | | Sequence_ | | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | dict_ | Mapping_ | dictionary,| str_, | Same as list_, but string arguments must be dictionary | | `{'a': 1, 'b': 2}` | - | | | map | Mapping_ | literals. | | `{'key': 1, 'nested': {'key': 2}}` | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | set_ | `Set | | str_, | Same as list_, but string arguments must be set literals or | | `{1, 2, 3, 42}` | + | set_ | `Set | | str_, | Same as `list`, but string arguments must be set literals or | | `{1, 2, 3, 42}` | | | <abc.Set_>`__ | | Container_ | `set()` to create an empty set. | | `set()` | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | frozenset_ | | | str_, | Same conversion as with set_, but the result is a frozenset_. | | - | | | | Container_ | | | + | frozenset_ | | | str_, | Same as `set`, but the result is a frozenset_. | | `{1, 2, 3, 42}` | + | | | | Container_ | | | `frozenset()` | + +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | dict_ | Mapping_ | dictionary,| str_, | Same as `list`, but string arguments must be dictionary | | `{'a': 1, 'b': 2}` | + | | | map | Mapping_ | literals. | | `{'key': 1, 'nested': {'key': 2}}` | + +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | TypedDict_ | | | str_, | Same as `dict`, but dictionary items are also converted | .. sourcecode:: python | + | | | | Mapping_ | to the specified types and items not included in the type | | + | | | | | spec are not allowed. | class Config(TypedDict): | + | | | | | | width: int | + | | | | | New in RF 5.1. Normal `dict` conversion was used earlier. | enabled: bool | + | | | | | | | + | | | | | | | `{'width': 1600, 'enabled': True}` | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ .. note:: Starting from Robot Framework 5.0, types that are automatically converted are @@ -1327,6 +1335,7 @@ Other types cause conversion failures. .. _set: https://docs.python.org/library/stdtypes.html#set .. _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 .. _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 diff --git a/doc/userguide/src/SupportingTools/Libdoc.rst b/doc/userguide/src/SupportingTools/Libdoc.rst index 93f1a6900da..2fbfab63346 100644 --- a/doc/userguide/src/SupportingTools/Libdoc.rst +++ b/doc/userguide/src/SupportingTools/Libdoc.rst @@ -736,7 +736,6 @@ members and types based on `TypedDict` show the dictionary structure. __ `Supported conversions`_ __ `Custom argument converters`_ -.. _TypedDict: https://docs.python.org/library/typing.html?highlight=typeddict#typing.TypedDict Libdoc example -------------- From 0c8fb6bd8b6b4d27d60d16265957fdbd8a8ad78e Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Thu, 29 Sep 2022 12:53:48 +0200 Subject: [PATCH 0227/1592] French True/False strings added (#4487) provided by @mmalorni (https://github.com/mmalorni) --- src/robot/conf/languages.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 394f4a1093c..e09a74f6a33 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -480,6 +480,8 @@ class Fr(Language): then_prefix = {'Alors'} and_prefix = {'Et'} but_prefix = {'Mais'} + true_strings = {'VRAI', 'OUI', 'ACTIF'} + false_strings = {'FAUX', 'NON', 'Désactivé', 'AUCUN'} class De(Language): From b57910654da9ba08e72aa6ff2bbeaa8ad142ef72 Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Thu, 29 Sep 2022 13:01:48 +0200 Subject: [PATCH 0228/1592] Updated Chinese Simplified and added Traditional (#4488) provided by Ming Ni https://github.com/nixuewei --- src/robot/conf/languages.py | 75 +++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index e09a74f6a33..e8ea7dbad61 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -810,35 +810,78 @@ class ZhCn(Language): tasks_header = '任务' keywords_header = '关键字' comments_header = '备注' - library_setting = '库' - resource_setting = '资源' - variables_setting = '变量' - documentation_setting = '说明文档' + library_setting = '程序库' + resource_setting = '资源文件' + variables_setting = '变量文件' + documentation_setting = '说明' metadata_setting = '元数据' - suite_setup_setting = '用例集预置' - suite_teardown_setting = '用例集收尾' - test_setup_setting = '用例预置' - test_teardown_setting = '用例收尾' - test_template_setting = '测试模板' + suite_setup_setting = '用例集启程' + suite_teardown_setting = '用例集终程' + test_setup_setting = '用例启程' + test_teardown_setting = '用例终程' + test_template_setting = '用例模板' test_timeout_setting = '用例超时' - test_tags_setting = '测试标签' + test_tags_setting = '用例标签' task_setup_setting = '任务启程' - task_teardown_setting = '任务收尾' + task_teardown_setting = '任务终程' task_template_setting = '任务模板' task_timeout_setting = '任务超时' task_tags_setting = '任务标签' keyword_tags_setting = '关键字标签' tags_setting = '标签' - setup_setting = '预设' + setup_setting = '启程' teardown_setting = '终程' template_setting = '模板' timeout_setting = '超时' arguments_setting = '参数' - given_prefix = {'输入'} + given_prefix = {'假定'} when_prefix = {'当'} - then_prefix = {'则'} - and_prefix = {'且'} - but_prefix = {'但'} + then_prefix = {'那么'} + and_prefix = {'并且'} + but_prefix = {'但是'} + true_strings = {'真', '是', '开'} + false_strings = {'假', '否', '关', '空'} + + +class ZhTw(Language): + """Chinese Traditional""" + settings_header = '設置' + variables_header = '變量' + test_cases_header = '案例' + tasks_header = '任務' + keywords_header = '關鍵字' + comments_header = '備註' + library_setting = '函式庫' + resource_setting = '資源文件' + variables_setting = '變量文件' + documentation_setting = '說明' + metadata_setting = '元數據' + suite_setup_setting = '測試套啟程' + suite_teardown_setting = '測試套終程' + test_setup_setting = '測試啟程' + test_teardown_setting = '測試終程' + test_template_setting = '測試模板' + test_timeout_setting = '測試逾時' + test_tags_setting = '測試標籤' + task_setup_setting = '任務啟程' + task_teardown_setting = '任務終程' + task_template_setting = '任務模板' + task_timeout_setting = '任務逾時' + task_tags_setting = '任務標籤' + keyword_tags_setting = '關鍵字標籤' + tags_setting = '標籤' + setup_setting = '啟程' + teardown_setting = '終程' + template_setting = '模板' + timeout_setting = '逾時' + arguments_setting = '参数' + given_prefix = {'假定'} + when_prefix = {'當'} + then_prefix = {'那麼'} + and_prefix = {'並且'} + but_prefix = {'但是'} + true_strings = {'真', '是', '開'} + false_strings = {'假', '否', '關', '空'} class Tr(Language): From c95d5842c1f78289883d6acfdeeb9f11e71cfc1f Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Thu, 29 Sep 2022 13:24:46 +0200 Subject: [PATCH 0229/1592] Update Spanish Boolean values (#4489) Provided by Miguel Angel Apolayo Mendoza Part of #4390. --- src/robot/conf/languages.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index e8ea7dbad61..3d178eb9c17 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -761,6 +761,8 @@ class Es(Language): then_prefix = {'Entonces'} and_prefix = {'Y'} but_prefix = {'Pero'} + true_strings = {'Verdadero', 'Si', 'ON'} + false_strings = {'Falso', 'No', 'OFF', 'Ninguno'} class Ru(Language): From 10654e8d2e0ef28019200d03de1a69822ec50406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 29 Sep 2022 20:44:11 +0300 Subject: [PATCH 0230/1592] Libdoc: Fix linking to type info with generics. #4433 --- atest/robot/libdoc/datatypes_py-json.robot | 15 ++++++--------- atest/robot/libdoc/datatypes_py-xml.robot | 13 +++++-------- src/robot/libdocpkg/datatypes.py | 10 +++++----- src/robot/libdocpkg/robotbuilder.py | 2 +- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/atest/robot/libdoc/datatypes_py-json.robot b/atest/robot/libdoc/datatypes_py-json.robot index 7f080bd8a6e..91d034f2eb1 100644 --- a/atest/robot/libdoc/datatypes_py-json.robot +++ b/atest/robot/libdoc/datatypes_py-json.robot @@ -104,14 +104,11 @@ Standard types Standard types with generics ${MODEL}[typedocs][4][type] Standard - ${MODEL}[typedocs][4][name] Dict[str, int] + ${MODEL}[typedocs][4][name] dictionary ${MODEL}[typedocs][4][doc] <p>Strings must be Python <a start=True ${MODEL}[typedocs][8][type] Standard - ${MODEL}[typedocs][8][name] List[Any] + ${MODEL}[typedocs][8][name] list ${MODEL}[typedocs][8][doc] <p>Strings must be Python <a start=True - ${MODEL}[typedocs][9][type] Standard - ${MODEL}[typedocs][9][name] List[str] - ${MODEL}[typedocs][9][doc] <p>Strings must be Python <a start=True Accepted types ${MODEL}[typedocs][1][type] Standard @@ -124,8 +121,8 @@ Accepted types ${MODEL}[typedocs][6][accepts] ['string'] ${MODEL}[typedocs][0][type] Enum ${MODEL}[typedocs][0][accepts] ['string'] - ${MODEL}[typedocs][11][type] Enum - ${MODEL}[typedocs][11][accepts] ['string', 'integer'] + ${MODEL}[typedocs][10][type] Enum + ${MODEL}[typedocs][10][accepts] ['string', 'integer'] Usages ${MODEL}[typedocs][1][type] Standard @@ -136,8 +133,8 @@ Usages ${MODEL}[typedocs][2][usages] ['Custom'] ${MODEL}[typedocs][6][type] TypedDict ${MODEL}[typedocs][6][usages] ['Funny Unions', 'Set Location'] - ${MODEL}[typedocs][11][type] Enum - ${MODEL}[typedocs][11][usages] ['__init__', 'Funny Unions'] + ${MODEL}[typedocs][10][type] Enum + ${MODEL}[typedocs][10][usages] ['__init__', 'Funny Unions'] Typedoc links in arguments ${MODEL}[keywords][0][args][1][typedocs] {'AssertionOperator': 'AssertionOperator', 'None': 'None'} diff --git a/atest/robot/libdoc/datatypes_py-xml.robot b/atest/robot/libdoc/datatypes_py-xml.robot index 696be5ab5de..bf1378a789f 100644 --- a/atest/robot/libdoc/datatypes_py-xml.robot +++ b/atest/robot/libdoc/datatypes_py-xml.robot @@ -57,13 +57,10 @@ Standard Standard with generics DataType Standard Should Be 1 - ... Dict[str, int] + ... dictionary ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#dict|dictionary] DataType Standard Should Be 4 - ... List[Any] - ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#list|list] - DataType Standard Should Be 5 - ... List[str] + ... list ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#list|list] Accepted types @@ -76,19 +73,19 @@ Accepted types ... string Accepted Types Should Be 0 Enum AssertionOperator ... string - Accepted Types Should Be 11 Enum Small + Accepted Types Should Be 10 Enum Small ... string integer Usages Usages Should Be 1 Standard boolean ... Funny Unions - Usages Should Be 4 Standard Dict[str, int] + Usages Should Be 4 Standard dictionary ... Typing Types Usages Should Be 2 Custom CustomType ... Custom Usages Should be 6 TypedDict GeoLocation ... Funny Unions Set Location - Usages Should Be 11 Enum Small + Usages Should Be 10 Enum Small ... __init__ Funny Unions Typedoc links in arguments diff --git a/src/robot/libdocpkg/datatypes.py b/src/robot/libdocpkg/datatypes.py index 4f3bb3c744e..6e4b66c9770 100644 --- a/src/robot/libdocpkg/datatypes.py +++ b/src/robot/libdocpkg/datatypes.py @@ -31,13 +31,12 @@ class TypeDoc(Sortable): CUSTOM = 'Custom' STANDARD = 'Standard' - def __init__(self, type, name, doc, accepts=(), usages=None, origin=None, + def __init__(self, type, name, doc, accepts=(), usages=None, members=None, items=None): self.type = type self.name = name self.doc = doc or '' # doc parsed from XML can be None. self.accepts = [type_name(t) if not isinstance(t, str) else t for t in accepts] - self.origin = origin or name self.usages = usages or [] # Enum members and TypedDict items are used only with appropriate types. self.members = members @@ -60,9 +59,10 @@ def for_type(cls, type_hint, converters): return cls(cls.CUSTOM, converter.type_name, converter.doc, converter.value_types) else: - return cls(cls.STANDARD, converter.type_name, - STANDARD_TYPE_DOCS[converter.type], converter.value_types, - origin=type(converter).type_name) + # Get `type_name` from class, not from instance, to get the original + # name with generics like `list[int]` that override it in instance. + return cls(cls.STANDARD, type(converter).type_name, + STANDARD_TYPE_DOCS[converter.type], converter.value_types) @classmethod def for_enum(cls, enum): diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index ba1616d234f..c8de00a3836 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -73,7 +73,7 @@ def _get_type_docs(self, keywords, custom_converters): for typ in arg.types: type_doc = TypeDoc.for_type(typ, custom_converters) if type_doc: - kw.type_docs[arg.name][type_repr(typ)] = type_doc.origin + kw.type_docs[arg.name][type_repr(typ)] = type_doc.name type_docs.setdefault(type_doc, set()).add(kw.name) for type_doc, usages in type_docs.items(): type_doc.usages = sorted(usages, key=str.lower) From 209967d5c2e47027a9524c57ed772ace47055283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 29 Sep 2022 21:36:42 +0300 Subject: [PATCH 0231/1592] We decided to change RF 5.1 to RF 6.0. --- .../src/CreatingTestData/CreatingTestCases.rst | 6 +++--- .../src/CreatingTestData/CreatingUserKeywords.rst | 12 ++++++------ .../src/CreatingTestData/UsingTestLibraries.rst | 2 +- .../src/ExecutingTestCases/PostProcessing.rst | 2 +- .../src/ExecutingTestCases/TestExecution.rst | 4 ++-- .../CreatingTestLibraries.rst | 8 ++++---- .../ExtendingRobotFramework/ListenerInterface.rst | 2 +- setup.py | 2 +- src/robot/api/parsing.py | 2 +- src/robot/libdocpkg/standardtypes.py | 10 +++++----- src/robot/libraries/BuiltIn.py | 2 +- src/robot/libraries/Collections.py | 2 +- src/robot/libraries/OperatingSystem.py | 2 +- src/robot/libraries/String.py | 8 ++++---- src/robot/result/flattenkeywordmatcher.py | 2 +- src/robot/running/arguments/embedded.py | 2 +- src/robot/utils/argumentparser.py | 2 +- src/robot/utils/importer.py | 2 +- src/robot/version.py | 2 +- 19 files changed, 37 insertions(+), 37 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index da5fc869a62..6b7c3af1b3f 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -657,14 +657,14 @@ preserve the exact name used in the data. When tags are compared, for example, to collect statistics, to select test to be executed, or to remove duplicates, comparisons are case, space and underscore insensitive. -.. note:: The :setting:`Test Tags` setting is new in Robot Framework 5.1. +.. note:: The :setting:`Test Tags` setting is new in Robot Framework 6.0. Earlier versions support :setting:`Force Tags` and :setting:`Default Tags` settings discussed below. Deprecation of :setting:`Force Tags` and :setting:`Default Tags` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Prior to Robot Framework 5.1, tags could be specified to tests in the Setting section +Prior to Robot Framework 6.0, tags could be specified to tests in the Setting section using two different settings: :setting:`Force Tags` @@ -684,7 +684,7 @@ Robot Framework 5.2 will introduce a new way for tests to indicate they `should not get certain globally specified tags`__. Instead of using a separate setting that tests can override, tests can use syntax `-tag` with their :setting:`[Tags]` setting to tell they should not get a tag named `tag`. -This syntax *does not* yet work in Robot Framework 5.1, but using +This syntax *does not* yet work in Robot Framework 6.0, but using :setting:`[Tags]` with a literal value like `-tag` `is now deprecated`__. If such tags are needed, they can be set using :setting:`Test Tags` or escaped__ syntax `\-tag` can be used with :setting:`[Tags]`. diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index c4410f11dc3..6d5ca1db449 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -191,13 +191,13 @@ prefix are reserved__ for special features by Robot Framework itself. Users should thus not use any tag with these prefixes unless actually activating the special functionality. -.. note:: :setting:`Keyword Tags` is new in Robot Framework 5.1. With earlier +.. note:: :setting:`Keyword Tags` is new in Robot Framework 6.0. With earlier versions all keyword tags need to be specified using the :setting:`[Tags]` setting. .. note:: Robot Framework 5.2 will support `removing globally set tags`__ using the `-tag` syntax with the :setting:`[Tags]` setting. Creating tags - with literal value like `-tag` `is deprecated`__ in Robot Framework 5.1 + with literal value like `-tag` `is deprecated`__ in Robot Framework 6.0 and escaped__ syntax `\-tag` must be used if such tags are actually needed. @@ -689,7 +689,7 @@ after looking for best matches, Robot Framework checks can they be resolved based on the `library search order`_. .. note:: Automatically resolving conflicts if multiple keywords with embedded - arguments match is a new feature in Robot Framework 5.1. With older + arguments match is a new feature in Robot Framework 6.0. With older versions custom regular expressions explained below can be used instead. Using custom regular expressions @@ -792,7 +792,7 @@ to parse the variable syntax correctly. If there are matching braces like in This syntax is unfortunately not supported by Robot Framework 3.2 or newer and keywords using it must be updated when upgrading. -.. note:: Prior to Robot Framework 5.1, using literal backslashes in the pattern +.. note:: Prior to Robot Framework 6.0, using literal backslashes in the pattern required double escaping them like `${path:c:\\\\temp\\\\.*}`. Patterns using literal backslashes need to be updated when upgrading. @@ -818,7 +818,7 @@ using the keywords from the earlier example. A limitation of using variables is that their actual values are not matched against custom regular expressions. As the result keywords may be called with values that their custom regexps would not allow. This behavior is deprecated -starting from Robot Framework 5.1 and values will be validated in the future. +starting from Robot Framework 6.0 and values will be validated in the future. For more information see issue `#4462`__. __ https://github.com/robotframework/robotframework/issues/4462 @@ -1080,6 +1080,6 @@ the public one will be used but also this situation causes a warning. Private keywords are included in spec files created by Libdoc_ but not in its HTML output files. -.. note:: Private user keywords are new in Robot Framework 5.1. +.. note:: Private user keywords are new in Robot Framework 6.0. __ `User keyword tags`_ diff --git a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst index 8fad3937875..d9abcaae4a4 100644 --- a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst +++ b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst @@ -177,7 +177,7 @@ different arguments: Setting a custom name to a test library works both when importing a library in the Setting section and when using the :name:`Import Library` keyword. -.. note:: Prior to Robot Framework 5.1 the marker to use when giving a custom name +.. note:: Prior to Robot Framework 6.0 the marker to use when giving a custom name to a library was `WITH NAME` instead of `AS`. The old syntax continues to work, but it is considered deprecated and will eventually be removed. diff --git a/doc/userguide/src/ExecutingTestCases/PostProcessing.rst b/doc/userguide/src/ExecutingTestCases/PostProcessing.rst index 64e1167a5a9..7716dfa59d3 100644 --- a/doc/userguide/src/ExecutingTestCases/PostProcessing.rst +++ b/doc/userguide/src/ExecutingTestCases/PostProcessing.rst @@ -129,7 +129,7 @@ How merging tests works is explained in the following sections discussing the two main merge use cases. .. note:: Getting suite documentation and metadata from merged suites is new in - Robot Framework 5.1. + Robot Framework 6.0. Merging re-executed tests ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/userguide/src/ExecutingTestCases/TestExecution.rst b/doc/userguide/src/ExecutingTestCases/TestExecution.rst index 6fae7e87d9b..b6d18e86f04 100644 --- a/doc/userguide/src/ExecutingTestCases/TestExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/TestExecution.rst @@ -481,7 +481,7 @@ test case keywords are executed. .. note:: The `robot:continue-on-failure` and `robot:recursive-continue-on-failure` tags are new in Robot Framework 4.1. They do not work properly with - `WHILE` loops prior to Robot Framework 5.1. + `WHILE` loops prior to Robot Framework 6.0. Disabling continue-on-failure using tags ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -544,7 +544,7 @@ __ `Special failures from keywords`_ __ `Run Keyword And Continue On Failure keyword`_ .. note:: The `robot:stop-on-failure` and `robot:recursive-stop-on-failure` - tags are new in Robot Framework 5.1. + tags are new in Robot Framework 6.0. TRY/EXCEPT ~~~~~~~~~~ diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index d1f39334fa0..aef8d3795e4 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1244,7 +1244,7 @@ Other types cause conversion failures. | | | | | and floats are considered to be seconds. | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | `Path | PathLike_ | | str_ | Strings are converted `Path <pathli_>`__ objects. On Windows | | `/tmp/absolute/path` | - | <pathli_>`__| | | | `/` is converted to :codesc:`\\` automatically. New in RF 5.1. | | `relative/path/to/file.ext` | + | <pathli_>`__| | | | `/` is converted to :codesc:`\\` automatically. New in RF 6.0. | | `relative/path/to/file.ext` | | | | | | | | `name.txt` | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | Enum_ | | | str_ | The specified type must be an enumeration (a subclass of Enum_ | .. sourcecode:: python | @@ -1294,7 +1294,7 @@ Other types cause conversion failures. | | | | Mapping_ | to the specified types and items not included in the type | | | | | | | spec are not allowed. | class Config(TypedDict): | | | | | | | width: int | - | | | | | New in RF 5.1. Normal `dict` conversion was used earlier. | enabled: bool | + | | | | | New in RF 6.0. Normal `dict` conversion was used earlier. | enabled: bool | | | | | | | | | | | | | | | `{'width': 1600, 'enabled': True}` | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ @@ -1460,7 +1460,7 @@ with different generic types works according to these rules: is the same as with lists. .. note:: Support for converting nested types with generics is new in - Robot Framework 5.1. Same syntax works also with earlier versions, + Robot Framework 6.0. Same syntax works also with earlier versions, but arguments are only converted to the base type and nested types are not used for anything. @@ -1753,7 +1753,7 @@ the code above: def example(argument: StrictType): assert isinstance(argument, StrictType) -.. note:: Using `None` as a strict converter is new in Robot Framework 5.1. +.. note:: Using `None` as a strict converter is new in Robot Framework 6.0. An explicit converter function needs to be used with earlier versions. Converter documentation diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 3c771fe3949..bc86a8b56f2 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -278,7 +278,7 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | | | | | * `values`: Return values from a keyword. | | | | | - | | | Additional attributes for control structures are new in RF 5.1.| + | | | Additional attributes for control structures are new in RF 6.0.| | | | | +------------------+------------------+----------------------------------------------------------------+ | end_keyword | name, attributes | Called when a keyword ends. | diff --git a/setup.py b/setup.py index 896b3087387..c47ea784076 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1b3.dev1' +VERSION = '6.0rc1.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/parsing.py b/src/robot/api/parsing.py index ce8af1cd8c0..c4ca3b387d0 100644 --- a/src/robot/api/parsing.py +++ b/src/robot/api/parsing.py @@ -239,7 +239,7 @@ class were exposed directly via the :mod:`robot.api` package, but other - :class:`~robot.parsing.model.statements.Break` - :class:`~robot.parsing.model.statements.Continue` - :class:`~robot.parsing.model.statements.Comment` -- :class:`~robot.parsing.model.statements.Config` (new in 5.1) +- :class:`~robot.parsing.model.statements.Config` (new in 6.0) - :class:`~robot.parsing.model.statements.Error` - :class:`~robot.parsing.model.statements.EmptyLine` diff --git a/src/robot/libdocpkg/standardtypes.py b/src/robot/libdocpkg/standardtypes.py index 87558329d3e..e90cdef932e 100644 --- a/src/robot/libdocpkg/standardtypes.py +++ b/src/robot/libdocpkg/standardtypes.py @@ -126,7 +126,7 @@ lists and other containers. If the type has nested types like ``list[int]``, items are converted -to those types automatically. This in new in Robot Framework 5.1. +to those types automatically. This in new in Robot Framework 6.0. Examples: ``['one', 'two']``, ``[('one', 1), ('two', 2)]`` ''', @@ -138,7 +138,7 @@ tuples and other containers. If the type has nested types like ``tuple[str, int, int]``, items are converted -to those types automatically. This in new in Robot Framework 5.1. +to those types automatically. This in new in Robot Framework 6.0. Examples: ``('one', 'two')``, ``(('one', 1), ('two', 2))`` ''', @@ -150,7 +150,7 @@ dictionaries and other containers. If the type has nested types like ``dict[str, int]``, items are converted -to those types automatically. This in new in Robot Framework 5.1. +to those types automatically. This in new in Robot Framework 6.0. Examples: ``{'a': 1, 'b': 2}``, ``{'key': 1, 'nested': {'key': 2}}`` ''', @@ -161,7 +161,7 @@ function. They can contain any values ``ast.literal_eval`` supports. If the type has nested types like ``set[int]``, items are converted -to those types automatically. This in new in Robot Framework 5.1. +to those types automatically. This in new in Robot Framework 6.0. Examples: ``{1, 2, 3, 42}``, ``set()`` (an empty set) ''', @@ -173,7 +173,7 @@ any values ``ast.literal_eval`` supports. If the type has nested types like ``frozenset[int]``, items are converted -to those types automatically. This in new in Robot Framework 5.1. +to those types automatically. This in new in Robot Framework 6.0. Examples: ``{1, 2, 3, 42}``, ``set()`` (an empty set) ''' diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 049c0a62c7b..3996ca9bdf6 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1342,7 +1342,7 @@ def should_match_regexp(self, string, pattern, msg=None, values=True, flags=None | ${group1} = 'Bar' | ${group2} = '43' - The ``flags`` argument is new in Robot Framework 5.1. + The ``flags`` argument is new in Robot Framework 6.0. """ res = re.search(pattern, string, flags=parse_re_flags(flags)) if res is None: diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index f3124911c25..5489b532ad6 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -688,7 +688,7 @@ def get_from_dictionary(self, dictionary, key, default=NOT_SET): => | ${value} = 2 - Support for ``default`` is new in Robot Framework 5.1. + Support for ``default`` is new in Robot Framework 6.0. """ self._validate_dictionary(dictionary) try: diff --git a/src/robot/libraries/OperatingSystem.py b/src/robot/libraries/OperatingSystem.py index 943dc930daf..2079eda27bb 100644 --- a/src/robot/libraries/OperatingSystem.py +++ b/src/robot/libraries/OperatingSystem.py @@ -110,7 +110,7 @@ class OperatingSystem: = ``pathlib.Path`` support = - Starting from Robot Framework 5.1, arguments representing paths can be given + Starting from Robot Framework 6.0, arguments representing paths can be given as [https://docs.python.org/3/library/pathlib.html pathlib.Path] instances in addition to strings. diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index 148f41fee33..8c150943a16 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -370,7 +370,7 @@ def get_lines_matching_regexp(self, string, pattern, partial_match=False, flags= See `Get Lines Matching Pattern` and `Get Lines Containing String` if you do not need the full regular expression powers (and complexity). - The ``flags`` argument is new in Robot Framework 5.1. + The ``flags`` argument is new in Robot Framework 6.0. """ if is_truthy(partial_match): match = re.compile(pattern, flags=parse_re_flags(flags)).search @@ -417,7 +417,7 @@ def get_regexp_matches(self, string, pattern, *groups, flags=None): | ${named group} = ['he', 'ri'] | ${two groups} = [('h', 'e'), ('r', 'i')] - The ``flags`` argument is new in Robot Framework 5.1. + The ``flags`` argument is new in Robot Framework 6.0. """ regexp = re.compile(pattern, flags=parse_re_flags(flags)) groups = [self._parse_group(g) for g in groups] @@ -473,7 +473,7 @@ def replace_string_using_regexp(self, string, pattern, replace_with, count=-1, f | ${str} = | Replace String Using Regexp | ${str} | 20\\\\d\\\\d-\\\\d\\\\d-\\\\d\\\\d | <DATE> | | ${str} = | Replace String Using Regexp | ${str} | (Hello|Hi) | ${EMPTY} | count=1 | - The ``flags`` argument is new in Robot Framework 5.1. + The ``flags`` argument is new in Robot Framework 6.0. """ count = self._convert_to_integer(count, 'count') # re.sub handles 0 and negative counts differently than string.replace @@ -520,7 +520,7 @@ def remove_string_using_regexp(self, string, *patterns, flags=None): ``flags=IGNORECASE | MULTILINE``) or embedded to the pattern (e.g. ``(?im)pattern``). - The ``flags`` argument is new in Robot Framework 5.1. + The ``flags`` argument is new in Robot Framework 6.0. """ for pattern in patterns: string = self.replace_string_using_regexp(string, pattern, '', flags=flags) diff --git a/src/robot/result/flattenkeywordmatcher.py b/src/robot/result/flattenkeywordmatcher.py index 54a590f871f..a5a7678fec7 100644 --- a/src/robot/result/flattenkeywordmatcher.py +++ b/src/robot/result/flattenkeywordmatcher.py @@ -21,7 +21,7 @@ def validate_flatten_keyword(options): for opt in options: low = opt.lower() - # TODO: deprecate 'foritem' in RF 5.1 + # TODO: Deprecate 'foritem' in RF 6.1! if low == 'foritem': low = 'iteration' if not (low in ('for', 'while', 'iteration') or diff --git a/src/robot/running/arguments/embedded.py b/src/robot/running/arguments/embedded.py index d873dc63763..be56a925cbc 100644 --- a/src/robot/running/arguments/embedded.py +++ b/src/robot/running/arguments/embedded.py @@ -43,7 +43,7 @@ def map(self, values): def validate(self, values): # Validating that embedded args match custom regexps also if args are - # given as variables was initially implemented in RF 5.1. It needed + # given as variables was initially implemented in RF 6.0. It needed # to be reverted due to backwards incompatibility reasons: # https://github.com/robotframework/robotframework/issues/4069 # diff --git a/src/robot/utils/argumentparser.py b/src/robot/utils/argumentparser.py index fa26a896e62..b73a67f40e7 100644 --- a/src/robot/utils/argumentparser.py +++ b/src/robot/utils/argumentparser.py @@ -71,7 +71,7 @@ def __init__(self, usage, name=None, version=None, arg_limits=None, self._validator = validator self._auto_help = auto_help self._auto_version = auto_version - # TODO: Change DeprecationWarning to more loud UserWarning in RF 5.1. + # TODO: Change DeprecationWarning to more loud UserWarning in RF 6.1. if auto_pythonpath == 'DEPRECATED': auto_pythonpath = False else: diff --git a/src/robot/utils/importer.py b/src/robot/utils/importer.py index 3461a3cdfe3..77ef321ae9c 100644 --- a/src/robot/utils/importer.py +++ b/src/robot/utils/importer.py @@ -106,7 +106,7 @@ def import_module(self, name_or_path): Use :meth:`import_class_or_module` if it is desired to get a class from the imported module automatically. - New in Robot Framework 5.1. + New in Robot Framework 6.0. """ try: imported, source = self._import(name_or_path, get_class=False) diff --git a/src/robot/version.py b/src/robot/version.py index ba325116017..ce2905698cd 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '5.1b3.dev1' +VERSION = '6.0rc1.dev1' def get_version(naked=False): From e34d883ab46578f8b11794f64ec12dcf017faeca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 29 Sep 2022 23:29:28 +0300 Subject: [PATCH 0232/1592] Fix tests that expected Robot's version to be 3-5. --- atest/robot/libdoc/python_library.robot | 2 +- atest/testdata/standard_libraries/builtin/evaluate.robot | 2 +- atest/testdata/standard_libraries/builtin/should_be_true.robot | 2 +- atest/testdata/variables/python_evaluation.robot | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/atest/robot/libdoc/python_library.robot b/atest/robot/libdoc/python_library.robot index c6266e6cbff..1d6d64d34ea 100644 --- a/atest/robot/libdoc/python_library.robot +++ b/atest/robot/libdoc/python_library.robot @@ -13,7 +13,7 @@ Documentation ... ``Telnet`` is Robot Framework's standard library that makes it possible to Version - Version Should Match [345].* + Version Should Match [6789].* Type Type Should Be LIBRARY diff --git a/atest/testdata/standard_libraries/builtin/evaluate.robot b/atest/testdata/standard_libraries/builtin/evaluate.robot index 85611fadd4b..2c6b519acb1 100644 --- a/atest/testdata/standard_libraries/builtin/evaluate.robot +++ b/atest/testdata/standard_libraries/builtin/evaluate.robot @@ -36,7 +36,7 @@ Modules are imported automatically Should Be Equal ${sep} ${/} Should Be Equal ${+} \\+ ${version} = Evaluate robot.__version__.split('.')[0] - Should Be True ${version} in (3, 4, 5) + Should Be True ${version} in (6, 7, 8, 9) Importing non-existing module fails with NameError [Documentation] FAIL diff --git a/atest/testdata/standard_libraries/builtin/should_be_true.robot b/atest/testdata/standard_libraries/builtin/should_be_true.robot index 13de2aaa531..5d9c2701031 100644 --- a/atest/testdata/standard_libraries/builtin/should_be_true.robot +++ b/atest/testdata/standard_libraries/builtin/should_be_true.robot @@ -37,7 +37,7 @@ Should Not Be True with invalid expression Should (Not) Be True automatically imports modules Should Be True os.pathsep == '${:}' Should Be True math.pi > 3.14 - Should Be True robot.__version__[0] in ('3', '4', '5') + Should Be True robot.__version__[0] in ('6', '7', '8', '9') Should Not Be True os.sep == 'os.sep' Should Not Be True sys.platform == 'hurd' # let's see when this starts failing diff --git a/atest/testdata/variables/python_evaluation.robot b/atest/testdata/variables/python_evaluation.robot index 46fcf3ee97b..c2257fcf917 100644 --- a/atest/testdata/variables/python_evaluation.robot +++ b/atest/testdata/variables/python_evaluation.robot @@ -56,7 +56,7 @@ Automatic module import ${{os.sep}} ${/} ${{round(math.pi, 2)}} ${3.14} ${{json.dumps([1, None, 'kolme'])}} [1, null, "kolme"] - ${{robot.__version__.split('.')[0] in ('3', '4', '5')}} + ${{robot.__version__.split('.')[0] in ('6', '7', '8', '9')}} ... ${True} Module imports are case-sensitive From 34fd58e29e18460864cff89a25ac512a188435a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 30 Sep 2022 01:35:31 +0300 Subject: [PATCH 0233/1592] Release notes for 6.0rc1 --- doc/releasenotes/rf-6.0rc1.rst | 821 +++++++++++++++++++++++++++++++++ 1 file changed, 821 insertions(+) create mode 100644 doc/releasenotes/rf-6.0rc1.rst diff --git a/doc/releasenotes/rf-6.0rc1.rst b/doc/releasenotes/rf-6.0rc1.rst new file mode 100644 index 00000000000..8601a2566c1 --- /dev/null +++ b/doc/releasenotes/rf-6.0rc1.rst @@ -0,0 +1,821 @@ +======================================= +Robot Framework 6.0 release candidate 1 +======================================= + +.. default-role:: code + +`Robot Framework`_ 6.0 is a new major release that starts Robot Framework's +localization efforts. In addition to that, it contains several nice enhancements +related to, for example, automatic argument conversion and using embedded arguments. +Robot Framework 6.0 rc 1 is the first and hopefully also the last release candidate +containing all features and fixes planned to be included in the final release. + +Robot Framework 6.0 was initially labeled Robot Framework 5.1 and considered +a feature release. In the end it grow so big that we decided to make it a major +release instead. The previous preview release was `RF 5.1 beta 2 <rf-5.1b2.rst>`_. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.0rc1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.0 rc 1 was released on Friday September 30, 2022. +The final release is planned to be released on Wednesday October 5, 2022 +just in time for the `RoboCon Germany <https://robocon.io/germany>`_ conference. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Robot Framework Slack: http://slack.robotframework.org/ +.. _Slack: http://slack.robotframework.org/ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Localization +------------ + +Robot Framework 6.0 starts our localization efforts by making it possible to translate +various markers used in the data. It is possible to translate headers (e.g. `Test Cases`) +and settings (e.g. `Documentation`) (`#4096`_), `Given/When/Then` prefixes used in BDD +(`#519`_), as well as true and false strings used in Boolean argument conversion (`#4400`_). +Future versions may allow translating syntax like `IF` and `FOR`, contents of logs and +reports, error messages, and so on. + +Languages to use are specified when starting execution using the `--language` command +line option. With languages supported by Robot Framework out-of-the-box, it is possible +to use just a language code or name like `--language fi` or `--language Finnish`. +It is also possible to create a custom language file and use it like `--language MyLang.py`. +If there is a need to support multiple languages, the `--language` option can be +used multiple times like `--language de --language uk`. + +In addition to specifying the language from the command line, it is possible to +specify it in the data file itself using `language: <lang>` syntax, where `<lang>` is +a language code or name, before the first section:: + + language: fi + + *** Asetukset *** + Dokumentaatio Example using Finnish. + +Due to technical reasons this per-file language configuration affects also parsing +subsequent files, but that behavior is likely to change and *should not* be dependent +on. Either use `language: <lang>` in each parsed file or specify the language to +use from the command line. + +Robot Framework 6.0 contains built-in support for these languages in addition +to English that is automatically supported: + +- Bosnian (bs) +- Chinese Simplified (zh-CN) and Chinese Traditional (zh-TW) +- Czech (cs) +- Dutch (nl) +- Finnish (fi) +- French (fr) +- German (de) +- Polish (pl) +- Portuguese (pt) and Brazilian Portuguese (pt-BR) +- Russian (ru) +- Spanish (es) +- Thai (th) +- Turkish (tr) +- Ukrainian (uk) + +All these translations have been provided by our awesome community and we hope to get +more community contributed translations in future releases. If you are interested to +help, head to Crowdin__ that we use for collaboration. For more instructions see +issue `#4390`_ and for general discussion and questions join the `#localization` +channel on our Slack_. + +__ https://robotframework.crowdin.com/robot-framework + +Enhancements to using keywords with embedded arguments +------------------------------------------------------ + +When using keywords with embedded arguments, it is pretty common that a keyword +that is used matches multiple keyword implementations. For example, +`Execute "ls" with "-lh"` in this example matches both of the keywords: + +.. sourcecode:: robotframework + + *** Test Cases *** + Automatic conflict resolution + Execute "ls" + Execute "ls" with "-lh" + + *** Keywords *** + Execute "${cmd}" + Log Running command '${cmd}'. + + Execute "${cmd}" with "${opts}" + Log Running command '${cmd}' with options '${opts}'. + +Earlier when such conflicts occurred, execution failed due to there being +multiple matching keywords. Nowadays, if there is a match that is better than +others, it will be used and the conflict is resolved. In the above example, +`Execute "${cmd}" with "${opts}"` is considered to be a better match than +the more generic `Execute "${cmd}"` and the example thus succeeds. (`#4454`_) + +There can, however, be cases where it is not possible to find a single best +match. In such cases conflicts cannot be resolved automatically and +execution fails as earlier. + +Another nice enhancement related to keywords using embedded arguments is that +if they are used with `Run Keyword` or its variants, arguments are not anymore +always converted to strings. That allows passing arguments containing other +values than strings as variables also in this context. (`#1595`_) + +Enhancements to automatic argument conversion +--------------------------------------------- + +Automatic argument conversion makes it possible for library authors to specify +what types certain arguments have and then Robot Framework automatically converts +used arguments accordingly. This support has been enhanced in various ways. + +Nowadays, if a container type like `list` is used with parameters like `list[int]`, +arguments are not only converted to the container type, but items they contain are +also converted to specified nested types (`#4433`_). This works with all containers +Robot Framework's argument conversion works in general. Most important examples +are the already mentioned lists, dictionaries like `dict[str, int]`, tuples like +`tuple[str, int, bool]` and heterogeneous tuples like `tuple[int, ...]`. Notice +that using parameters with Python's standard types `requires Python 3.9`__. With +earlier versions it is possible to use `List`, `Dict` and other such types +available in the typing__ module. + +Another container type that is nowadays handled better is TypedDict__. Earlier, +when TypedDicts were used as type hints, arguments were only converted to +dictionaries, but nowadays items are converted according to the specified +types. In addition to that, Robot Framework validates that all the specified +items are present. (`#4477`_) + +A bit smaller but still nice enhancement is that automatic conversion nowadays +works also with `pathlib.Path`__. (`#4461`_) + +__ https://peps.python.org/pep-0585/ +__ https://docs.python.org/3/library/typing.html +__ https://docs.python.org/3/library/typing.html#typing.TypedDict +__ https://docs.python.org/3/library/pathlib.html + +Enhancements for setting keyword and test tags +---------------------------------------------- + +It is now possible to set tags for all keywords in a certain file by using +the new `Keyword Tags` setting (`#4373`_). It works in resource files and also +in test case and suite initialization files. When used in initialization files, +it only affects keywords in that file and does not propagate to lower level suites. + +The `Force Tags` setting has been renamed to `Test Tags` (`#4368`_). The motivation +is to make settings related to tests more consistent (`Test Setup`, `Test Timeout`, +`Test Tags`, ...) and to better separate settings for specifying test and keyword tags. +Consistent naming also easies translations. The old `Force Tags` setting still works but it +will be `deprecated in the future`__. When creating tasks, it is possible to use +`Task Tags` alias instead of `Test Tags`. + +To simplify setting tags, the `Default Tags` setting will `also be deprecated`__. +The functionality it provides, setting tags that some but no all tests get, +will be enabled in the future by using `-tag` syntax with the `[Tags]` setting +to indicate that a test should not get tag `tag`. This syntax will then work +also in combination with the new `Keyword Tags`. For more details see `#4374`__. + +__ `Force Tags and Default Tags settings`_ +__ `Force Tags and Default Tags settings`_ +__ https://github.com/robotframework/robotframework/issues/4374 + +Enhancements to keyword namespaces +---------------------------------- + +It is possible to mark keywords in resource files as private by adding +`robot:private` tag to them (`#430`_). If such a keyword is used by keywords +outside that resource file, there will be a warning. These keywords are also +excluded from HTML library documentation generated by Libdoc. + +If a keyword exists in the same resource file as a keyword using it, it will +be used even if there would be keyword with the same name in another resource +file (`#4366`_). Earlier this situation caused a conflict. + +If a keyword exists in the same resource file as a keyword using it and there +is a keyword with the same name in the test case file, the keyword in the test +case file will be used as it has been used earlier. This behavior is nowadays +deprecated__, though, and in the future local keywords will have precedence also +in these cases. + +__ `Keywords in test case files having precedence over local keywords in resource files`_ + +Possibility to disable continue-on-failure mode +----------------------------------------------- + +Robot Framework generally stops executing a keyword or a test case if there +is a failure. Exceptions to this rule include teardowns, templates and +cases where the continue-on-failure mode has been explicitly enabled with +`robot:continue-on-failure` or `robot:recursive-continue-on-failure` +tags. Robot Framework 6.0 makes it possible to disable the implicit or explicit +continue-on-failure mode when needed by using `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags (`#4303`_). + +`start/end_keyword` listener methods get more information about control structures +---------------------------------------------------------------------------------- + +When using the listener API v2, `start_keyword` and `end_keyword` methods are not +only used with keywords but also with all control structures. Earlier these methods +always got exactly the same information, but nowadays there is additional context +specific details with control structures (`#4335`_). + +Performance enhancements for executing user keywords +---------------------------------------------------- + +The overhead in executing user keywords has been reduced. The difference +can be seen especially if user keywords fail often, for example, when using +`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) + +Python 3.11 support +-------------------- + +Robot Framework 6.0 officially supports the forthcoming Python 3.11 +release (`#4401`_). Incompatibilities were not too big, so also the earlier +versions work fairly well. + +At the other end of the spectrum, Python 3.6 is deprecated and will not +anymore be supported by Robot Framework 7.0 (`#4295`_). + + +Backwards incompatible changes +============================== + +- Space is required after `Given/When/Then` prefixes used with BDD scenarios. (`#4379`_) + +- Dictionary related keywords in `Collections` require dictionaries to inherit `Mapping`. (`#4413`_) + +- `Dictionary Should Contain Item` from the Collections library does not anymore convert + values to strings before comparison. (`#4408`_) + +- Automatic `TypedDict` conversion can cause problems if a keyword expects to get any + dictionary. Nowadays dictionaries that do not match the type spec cause failures + and the keyword is not called at all. (`#4477`_) + +- Generation time in XML and JSON spec files generated by Libdoc has been changed to + `2022-05-27T19:07:15+00:00`. With XML specs the format used to be `2022-05-27T19:07:15Z` + that is equivalent with the new format. JSON spec files did not include the timezone + information at all and the format was `2022-05-27 19:07:15`. (`#4262`_) + +- `BuiltIn.run_keyword()` nowadays resolves variables in the name of the keyword to + execute when earlier they were resolved by Robot Framework before calling the keyword. + This affects programmatic usage if the used name contains variables or backslashes. + The change was done when enhancing how keywords with embedded arguments work with + `BuiltIn.run_keyword()`. (`#1595`_) + + +Deprecated features +=================== + +`Force Tags` and `Default Tags` settings +---------------------------------------- + +As `discussed above`__, new `Test Tags` setting has been added to replace `Force Tags` +and there is a plan to remove `Default Tags` altogether. Both of these settings still +work but they are considered deprecated. There is no visible deprecation warning yet, +but such a warning will be emitted starting from Robot Framework 7.0 and eventually these +settings will be removed. (`#4368`_) + +The plan is to add new `-tag` syntax that can be used with the `[Tags]` setting +to enable similar functionality that the `Default Tags` setting provides. Because +of that, using tags starting with a hyphen with the `[Tags]` setting is now deprecated. +If such literal values are needed, it is possible to use escaped format like `\-tag`. +(`#4380`_) + +__ `Enhancements for setting keyword and test tags`_ + +Keywords in test case files having precedence over local keywords in resource files +----------------------------------------------------------------------------------- + +Keywords in test cases files currently always have the highest precedence. They +are used even when a keyword in a resource file uses a keyword that would exist also +in the same resource file. This will change so that local keywords always have +highest precedence and the current behavior is deprecated. (`#4366`_) + +`WITH NAME` in favor of `AS` when giving alias to imported library +------------------------------------------------------------------ + +`WITH NAME` marker that is used when giving an alias to an imported library +will be renamed to `AS` (`#4371`_). The motivation is to be consistent with +Python that uses `as` for similar purpose. We also already use `AS` with +`TRY/EXCEPT` and reusing the same marker and internally used token simplifies +the syntax. Having less markers will also ease translations (but these markers +cannot yet be translated). + +In Robot Framework 6.0 both `AS` and `WITH NAME` work when setting an alias +for a library. `WITH NAME` is considered deprecated, but there will not be +visible deprecation warnings until Robot Framework 7.0. + +Singular section headers like `Test Case` +----------------------------------------- + +Robot Framework has earlier accepted both plural (e.g. `Test Cases`) and singular +(e.g. `Test Case`) section headers. The singular variants are now deprecated +and their support will eventually be removed (`#4431`_). The is no visible +deprecation warning yet, but they will most likely be emitted starting from +Robot Framework 7.0. + +Using variables with embedded arguments so that value does not match custom pattern +----------------------------------------------------------------------------------- + +When keywords accepting embedded arguments are used so that arguments are +passed as variables, variable values are not checked against possible custom +regular expressions. Keywords being called with arguments they explicitly do not +accept is problematic and this behavior will be changed. Due to the backwards +compatibility it is now only deprecated, but validation will be more strict +in the future. (`#4462`_) + +Custom patterns have often been used to avoid conflicts when using embedded arguments. +That need is nowadays smaller because Robot Framework 6.0 can typically resolve +conflicts automatically. (`#4454`_) + +Python 3.6 support +------------------ + +Python 3.6 `reached end-of-life`__ in December 2021. It will be still supported +by all future Robot Framework 6.x releases, but not anymore by Robot Framework +7.0 (`#4295`_). Users are recommended to upgrade to newer versions already now. + +__ https://endoflife.date/python + + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its ~50 member organizations. Robot Framework 6.0 team funded by the foundation +consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the wider open source community has provided several +great contributions: + +- `Elout van Leeuwen <https://github.com/leeuwe>`_ has lead the localization efforts + (`#4390`_). Individual translations have been provided by the following people: + + - Bosnian by `Namik <https://github.com/Delilovic>`_ + - Czech by `Václav Fuksa <https://github.com/MoreFamed>`_ + - Dutch by `Pim Jansen <https://github.com/pimjansen>`_ + and `Elout van Leeuwen <https://github.com/leeuwe>`_ + - French by `@lesnake <https://github.com/lesnake>`_ + and `Martin Malorni <https://github.com/mmalorni>`_ + - German by `René <https://github.com/Snooz82>`_ + and `Markus <https://github.com/Noordsestern>`_ + - Polish by `Bartłomiej Hirsz <https://github.com/bhirsz>`_ + - Portuguese and Brazilian Portuguese + by `Hélio Guilherme <https://github.com/HelioGuilherme66>`_ + - Russian by `Anatoly Kolpakov <https://github.com/axxyhtrx>`_ + - Simplified and Traditional Chinese + by `@nixuewei <https://github.com/nixuewei>`_ + and `charis <https://github.com/mawentao119>`_ + - Spanish by Miguel Angel Apolayo Mendoza + - Thai by `Somkiat Puisungnoen <https://github.com/up1>`_ + - Turkish by `Yusuf Can Bayrak <https://github.com/yusufcanb>`_ + - Ukrainian by `@Sunshine0000000 <https://github.com/Sunshine0000000>`_ + +- `Oliver Boehmer <https://github.com/oboehmer>`_ provided several contributions: + + - Support to disable the continue-on-failure mode using `robot:stop-on-failure` and + `robot:recursive-stop-on-failure` tags. (`#4303`_) + - Document that failing test setup stops execution even if the continue-on-failure + mode is active. (`#4404`_) + - Default value to `Get From Dictionary` keyword. (`#4398`_) + - Allow passing explicit flags to regexp related keywords. (`#4429`_) + +- `J. Foederer <https://github.com/JFoederer>`_ enhanced performance of + `Keyword Should Exist` when a keyword is not found (`#4470`_) and provided + the initial pull request to support parameterized generics like `list[int]` (`#4433`_) + +- `Ossi R. <https://github.com/osrjv>`_ added more information to `start/end_keyword` + listener methods when they are used with control structures (`#4335`_). + +- `René <https://github.com/Snooz82>`_ fixed Libdoc's HTML outputs if type hints + matched Javascript variables in browser namespace (`#4464`_) or keyword names (`#4471`_). + +- `Fabio Zadrozny <https://github.com/fabioz>`_ provided a pull request speeding up + user keyword execution (`#4353`_). + +- `@Apteryks <https://github.com/Apteryks>`_ added support to generate deterministic + library documentation by using `SOURCE_DATE_EPOCH`__ environment variable (`#4262`_). + +- `@F3licity <https://github.com/F3licity>`_ enhanced `Sleep` keyword documentation. (`#4485`_) + +__ https://reproducible-builds.org/specs/source-date-epoch/ + +Thanks also to all community members who have submitted bug reports, helped debugging +problems, or otherwise helped to make Robot Framework 6.0 our best release so far! + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#4096`_ + - enhancement + - critical + - Multilanguage support for markers used in data + - alpha 1 + * - `#519`_ + - enhancement + - critical + - Given/When/Then should support other languages than English + - alpha 1 + * - `#1595`_ + - bug + - high + - Embedded arguments are not passed as objects when executed with `Run Keyword` or its variants + - beta 2 + * - `#4348`_ + - bug + - high + - Invalid IF or WHILE condition should not cause error that does not allow continuation + - rc 1 + * - `#4483`_ + - bug + - high + - BREAK and CONTINUE hide continuable errors with WHILE loops + - rc 1 + * - `#4295`_ + - enhancement + - high + - Deprecate Python 3.6 + - alpha 1 + * - `#430`_ + - enhancement + - high + - Keyword visibility modifiers for resource files + - alpha 1 + * - `#4303`_ + - enhancement + - high + - Support disabling continue-on-failure mode using `robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags + - alpha 1 + * - `#4335`_ + - enhancement + - high + - Pass more information about control structures to `start/end_keyword` listener methods + - beta 1 + * - `#4366`_ + - enhancement + - high + - Give local keywords precedence over imported keywords in resource files + - alpha 1 + * - `#4368`_ + - enhancement + - high + - New `Test Tags` setting as an alias for `Force Tags` + - alpha 1 + * - `#4373`_ + - enhancement + - high + - Support adding tags for all keywords using `Keyword Tags` setting + - alpha 1 + * - `#4380`_ + - enhancement + - high + - Deprecate setting tags starting with a hyphen like `-tag` using the `[Tags]` setting + - alpha 1 + * - `#4388`_ + - enhancement + - high + - Enhance performance of executing user keywords especially when they fail + - alpha 1 + * - `#4400`_ + - enhancement + - high + - Allow translating True and False words used in Boolean argument conversion + - beta 1 + * - `#4401`_ + - enhancement + - high + - Python 3.11 compatibility + - alpha 1 + * - `#4433`_ + - enhancement + - high + - Convert and validate collection contents when using generics in type hints + - rc 1 + * - `#4454`_ + - enhancement + - high + - Automatically select "best" match if there is conflict with keywords using embedded arguments + - beta 2 + * - `#4477`_ + - enhancement + - high + - Convert and validate `TypedDict` items + - rc 1 + * - `#4351`_ + - bug + - medium + - Libdoc can give bad error message if library argument has extension matching resource files + - alpha 1 + * - `#4355`_ + - bug + - medium + - Continuable failures terminate WHILE loops + - alpha 1 + * - `#4357`_ + - bug + - medium + - Parsing model: Creating `TRY` and `WHILE` statements using `from_params` is not possible + - alpha 1 + * - `#4359`_ + - bug + - medium + - Parsing model: `Variable.from_params` doesn't handle list values properly + - alpha 1 + * - `#4364`_ + - bug + - medium + - `@{list}` used as embedded argument not anymore expanded if keyword accepts varargs + - beta 1 + * - `#4381`_ + - bug + - medium + - Parsing errors are recognized as EmptyLines + - alpha 1 + * - `#4384`_ + - bug + - medium + - RPA aliases for settings do not work in suite initialization files + - alpha 1 + * - `#4387`_ + - bug + - medium + - Libdoc: Fix storing information about deprecated keywords to spec files + - alpha 1 + * - `#4408`_ + - bug + - medium + - Collection: `Dictionary Should Contain Item` incorrectly casts values to strings before comparison + - alpha 1 + * - `#4418`_ + - bug + - medium + - Dictionaries insider lists in YAML variable files not converted to DotDict objects + - beta 1 + * - `#4438`_ + - bug + - medium + - `Get Time` returns current time if it is given input time that matches epoch + - beta 2 + * - `#4441`_ + - bug + - medium + - Regression: Empty `--include/--exclude/--test/--suite` are not ignored + - beta 2 + * - `#4447`_ + - bug + - medium + - Evaluating expressions that modify evaluation namespace (locals) fail + - beta 1 + * - `#4455`_ + - bug + - medium + - Standard libraries don't support `pathlib.Path` objects + - beta 2 + * - `#4464`_ + - bug + - medium + - Libdoc: Type hints aren't shown for types with same name as Javascript variables available in browser namespace + - beta 2 + * - `#4476`_ + - bug + - medium + - BuiltIn: `Call Method` loses traceback if calling the method fails + - rc 1 + * - `#4480`_ + - bug + - medium + - Creating log and report fails if WHILE loop has no condition + - rc 1 + * - `#4482`_ + - bug + - medium + - WHILE and FOR loop contents not shown in log if running them fails due to errors + - rc 1 + * - `#4484`_ + - bug + - medium + - Invalid TRY/EXCEPT structure causes normal error, not syntax error + - rc 1 + * - `#4262`_ + - enhancement + - medium + - Honor `SOURCE_DATE_EPOCH` environment variable when generating library documentation + - alpha 1 + * - `#4312`_ + - enhancement + - medium + - Add project URLs to PyPI + - alpha 1 + * - `#4353`_ + - enhancement + - medium + - Performance enhancements to parsing + - alpha 1 + * - `#4354`_ + - enhancement + - medium + - When merging suites with Rebot, copy documentation and metadata from merged suites + - beta 1 + * - `#4371`_ + - enhancement + - medium + - Add `AS` alias for `WITH NAME` in library imports + - alpha 1 + * - `#4379`_ + - enhancement + - medium + - Require space after Given/When/Then prefixes + - alpha 1 + * - `#4398`_ + - enhancement + - medium + - Collections: `Get From Dictionary` should accept a default value + - alpha 1 + * - `#4404`_ + - enhancement + - medium + - Document that failing test setup stops execution even if continue-on-failure mode is active + - alpha 1 + * - `#4413`_ + - enhancement + - medium + - Dictionary related keywords in `Collections` are more script about accepted values + - alpha 1 + * - `#4429`_ + - enhancement + - medium + - Allow passing flags to regexp related keywords using explicit `flags` argument + - beta 1 + * - `#4431`_ + - enhancement + - medium + - Deprecate using singular section headers + - beta 1 + * - `#4440`_ + - enhancement + - medium + - Allow using `None` as custom argument converter to enable strict type validation + - beta 1 + * - `#4461`_ + - enhancement + - medium + - Automatic argument conversion for `pathlib.Path` + - beta 2 + * - `#4462`_ + - enhancement + - medium + - Deprecate using embedded arguments using variables that do not match custom regexp + - beta 2 + * - `#4470`_ + - enhancement + - medium + - Enhance `Keyword Should Exist` performance by not looking for possible recommendations + - beta 2 + * - `#4349`_ + - bug + - low + - User Guide: Example related to YAML variable files is buggy + - alpha 1 + * - `#4358`_ + - bug + - low + - User Guide: Errors in examples related to TRY/EXCEPT + - alpha 1 + * - `#4453`_ + - bug + - low + - `Run Keywords`: Execution is not continued in teardown if keyword name contains non-existing variable + - beta 2 + * - `#4471`_ + - bug + - low + - Libdoc: If keyword and type have same case-insensitive name, opening type info opens keyword documentation + - beta 2 + * - `#4481`_ + - bug + - low + - Invalid BREAK and CONTINUE cause errros even when not actually executed + - rc 1 + * - `#4346`_ + - enhancement + - low + - Enhance documentation of the `--timestampoutputs` option + - alpha 1 + * - `#4372`_ + - enhancement + - low + - Document how to import resource files bundled into Python packages + - alpha 1 + * - `#4485`_ + - enhancement + - low + - Update docstring for kw Sleep to specify the default value + - rc 1 + * - `#4394`_ + - bug + - --- + - Error when `--doc` or `--metadata` value matches an existing directory + - alpha 1 + +Altogether 62 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0>`__. + +.. _#4096: https://github.com/robotframework/robotframework/issues/4096 +.. _#519: https://github.com/robotframework/robotframework/issues/519 +.. _#1595: https://github.com/robotframework/robotframework/issues/1595 +.. _#4348: https://github.com/robotframework/robotframework/issues/4348 +.. _#4483: https://github.com/robotframework/robotframework/issues/4483 +.. _#4295: https://github.com/robotframework/robotframework/issues/4295 +.. _#430: https://github.com/robotframework/robotframework/issues/430 +.. _#4303: https://github.com/robotframework/robotframework/issues/4303 +.. _#4335: https://github.com/robotframework/robotframework/issues/4335 +.. _#4366: https://github.com/robotframework/robotframework/issues/4366 +.. _#4368: https://github.com/robotframework/robotframework/issues/4368 +.. _#4373: https://github.com/robotframework/robotframework/issues/4373 +.. _#4380: https://github.com/robotframework/robotframework/issues/4380 +.. _#4388: https://github.com/robotframework/robotframework/issues/4388 +.. _#4400: https://github.com/robotframework/robotframework/issues/4400 +.. _#4401: https://github.com/robotframework/robotframework/issues/4401 +.. _#4433: https://github.com/robotframework/robotframework/issues/4433 +.. _#4454: https://github.com/robotframework/robotframework/issues/4454 +.. _#4477: https://github.com/robotframework/robotframework/issues/4477 +.. _#4351: https://github.com/robotframework/robotframework/issues/4351 +.. _#4355: https://github.com/robotframework/robotframework/issues/4355 +.. _#4357: https://github.com/robotframework/robotframework/issues/4357 +.. _#4359: https://github.com/robotframework/robotframework/issues/4359 +.. _#4364: https://github.com/robotframework/robotframework/issues/4364 +.. _#4381: https://github.com/robotframework/robotframework/issues/4381 +.. _#4384: https://github.com/robotframework/robotframework/issues/4384 +.. _#4387: https://github.com/robotframework/robotframework/issues/4387 +.. _#4408: https://github.com/robotframework/robotframework/issues/4408 +.. _#4418: https://github.com/robotframework/robotframework/issues/4418 +.. _#4438: https://github.com/robotframework/robotframework/issues/4438 +.. _#4441: https://github.com/robotframework/robotframework/issues/4441 +.. _#4447: https://github.com/robotframework/robotframework/issues/4447 +.. _#4455: https://github.com/robotframework/robotframework/issues/4455 +.. _#4464: https://github.com/robotframework/robotframework/issues/4464 +.. _#4476: https://github.com/robotframework/robotframework/issues/4476 +.. _#4480: https://github.com/robotframework/robotframework/issues/4480 +.. _#4482: https://github.com/robotframework/robotframework/issues/4482 +.. _#4484: https://github.com/robotframework/robotframework/issues/4484 +.. _#4262: https://github.com/robotframework/robotframework/issues/4262 +.. _#4312: https://github.com/robotframework/robotframework/issues/4312 +.. _#4353: https://github.com/robotframework/robotframework/issues/4353 +.. _#4354: https://github.com/robotframework/robotframework/issues/4354 +.. _#4371: https://github.com/robotframework/robotframework/issues/4371 +.. _#4379: https://github.com/robotframework/robotframework/issues/4379 +.. _#4398: https://github.com/robotframework/robotframework/issues/4398 +.. _#4404: https://github.com/robotframework/robotframework/issues/4404 +.. _#4413: https://github.com/robotframework/robotframework/issues/4413 +.. _#4429: https://github.com/robotframework/robotframework/issues/4429 +.. _#4431: https://github.com/robotframework/robotframework/issues/4431 +.. _#4440: https://github.com/robotframework/robotframework/issues/4440 +.. _#4461: https://github.com/robotframework/robotframework/issues/4461 +.. _#4462: https://github.com/robotframework/robotframework/issues/4462 +.. _#4470: https://github.com/robotframework/robotframework/issues/4470 +.. _#4349: https://github.com/robotframework/robotframework/issues/4349 +.. _#4358: https://github.com/robotframework/robotframework/issues/4358 +.. _#4453: https://github.com/robotframework/robotframework/issues/4453 +.. _#4471: https://github.com/robotframework/robotframework/issues/4471 +.. _#4481: https://github.com/robotframework/robotframework/issues/4481 +.. _#4346: https://github.com/robotframework/robotframework/issues/4346 +.. _#4372: https://github.com/robotframework/robotframework/issues/4372 +.. _#4485: https://github.com/robotframework/robotframework/issues/4485 +.. _#4394: https://github.com/robotframework/robotframework/issues/4394 +.. _#4390: https://github.com/robotframework/robotframework/issues/4390 From 5e19447b601a5c61799e70ffb85ea805d7ba381b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 30 Sep 2022 01:35:48 +0300 Subject: [PATCH 0234/1592] Updated version to 6.0rc1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index c47ea784076..004c2bccc3f 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc1.dev1' +VERSION = '6.0rc1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index ce2905698cd..3afbb355568 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc1.dev1' +VERSION = '6.0rc1' def get_version(naked=False): From cb21172b6231ec3f58e56462876fa44ca15c845e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 30 Sep 2022 11:40:22 +0300 Subject: [PATCH 0235/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 004c2bccc3f..d5ea9925075 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc1' +VERSION = '6.0rc2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 3afbb355568..b3cddcb519a 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc1' +VERSION = '6.0rc2.dev1' def get_version(naked=False): From d87659118794bd5c86224afa2ab5ce4672f87f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 30 Sep 2022 11:40:58 +0300 Subject: [PATCH 0236/1592] Mention that RF 5.1 -> RF 6.0 in RF 5.1b2 release notes. --- doc/releasenotes/rf-5.1b2.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/releasenotes/rf-5.1b2.rst b/doc/releasenotes/rf-5.1b2.rst index 09feab609d2..3bd30cb7ce2 100644 --- a/doc/releasenotes/rf-5.1b2.rst +++ b/doc/releasenotes/rf-5.1b2.rst @@ -4,6 +4,10 @@ Robot Framework 5.1 beta 2 .. default-role:: code +.. note:: Robot Framework 5.1 grew so big that we considered it is better to call + it a major release and changed the version number accordingly. + The next release after RF 5.1 beta 2 was thus `RF 6.0 rc 1 <rf-6.0rc1.rst>`__. + `Robot Framework`_ 5.1 is a new feature release that starts Robot Framework's localization efforts and also brings in other nice enhancements. Robot Framework 5.1 preview releases are targeted especially From 8388562daaab4221ab2dd52a02e9cf12d82a479d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 30 Sep 2022 11:51:25 +0300 Subject: [PATCH 0237/1592] GitHub doesn't show reST notes well. Use a handcrafted one instead. --- doc/releasenotes/rf-5.1b2.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/doc/releasenotes/rf-5.1b2.rst b/doc/releasenotes/rf-5.1b2.rst index 3bd30cb7ce2..77944c5c88c 100644 --- a/doc/releasenotes/rf-5.1b2.rst +++ b/doc/releasenotes/rf-5.1b2.rst @@ -4,15 +4,17 @@ Robot Framework 5.1 beta 2 .. default-role:: code -.. note:: Robot Framework 5.1 grew so big that we considered it is better to call - it a major release and changed the version number accordingly. - The next release after RF 5.1 beta 2 was thus `RF 6.0 rc 1 <rf-6.0rc1.rst>`__. - `Robot Framework`_ 5.1 is a new feature release that starts Robot Framework's localization efforts and also brings in other nice enhancements. Robot Framework 5.1 preview releases are targeted especially for people interested in translations. +**NOTE:** + + Robot Framework 5.1 grew so big that we considered it is better to call + it a major release and changed the version number accordingly. + The next release after RF 5.1 beta 2 was thus `RF 6.0 rc 1 <rf-6.0rc1.rst>`__. + All issues targeted for Robot Framework 5.1 can be found from the `issue tracker milestone`_. Questions and comments related to the release can be sent to the From 60bc710b9b275085fb36ab16435fcf9cf446a267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 4 Oct 2022 12:30:52 +0300 Subject: [PATCH 0238/1592] f-strings --- src/robot/running/builder/builders.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index f5de33fa6b3..cdd696432bf 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -97,8 +97,7 @@ def build(self, *paths): def _validate_test_counts(self, suite, multisource=False): def validate(suite): if not suite.has_tests: - raise DataError("Suite '%s' contains no tests or tasks." - % suite.name) + raise DataError(f"Suite '{suite.name}' contains no tests or tasks.") if not multisource: validate(suite) else: @@ -141,7 +140,7 @@ def parse(self, structure): return self.suite def visit_file(self, structure): - LOGGER.info("Parsing file '%s'." % structure.source) + LOGGER.info(f"Parsing file '{structure.source}'.") suite, _ = self._build_suite(structure) if self._stack: self._stack[-1][0].suites.append(suite) @@ -150,7 +149,7 @@ def visit_file(self, structure): def start_directory(self, structure): if structure.source: - LOGGER.info("Parsing directory '%s'." % structure.source) + LOGGER.info(f"Parsing directory '{structure.source}'.") suite, defaults = self._build_suite(structure) if self.suite is None: self.suite = suite @@ -174,10 +173,10 @@ def _build_suite(self, structure): else: suite = parser.parse_suite_file(source, defaults) if not suite.tests: - LOGGER.info("Data source '%s' has no tests or tasks." % source) + LOGGER.info(f"Data source '{source}' has no tests or tasks.") self._validate_execution_mode(suite) except DataError as err: - raise DataError("Parsing '%s' failed: %s" % (source, err.message)) + raise DataError(f"Parsing '{source}' failed: {err.message}") return suite, defaults def _validate_execution_mode(self, suite): @@ -189,10 +188,10 @@ def _validate_execution_mode(self, suite): self.rpa = suite.rpa elif self.rpa is not suite.rpa: this, that = ('tasks', 'tests') if suite.rpa else ('tests', 'tasks') - raise DataError("Conflicting execution modes. File has %s " - "but files parsed earlier have %s. Fix headers " - "or use '--rpa' or '--norpa' options to set the " - "execution mode explicitly." % (this, that)) + raise DataError(f"Conflicting execution modes. File has {this} " + f"but files parsed earlier have {that}. Fix headers " + f"or use '--rpa' or '--norpa' options to set the " + f"execution mode explicitly.") class ResourceFileBuilder: @@ -202,13 +201,13 @@ def __init__(self, lang=None, process_curdir=True): self.process_curdir = process_curdir def build(self, source): - LOGGER.info("Parsing resource file '%s'." % source) + LOGGER.info(f"Parsing resource file '{source}'.") resource = self._parse(source) if resource.imports or resource.variables or resource.keywords: - LOGGER.info("Imported resource file '%s' (%d keywords)." - % (source, len(resource.keywords))) + LOGGER.info(f"Imported resource file '{source}' ({len(resource.keywords)} " + f"keywords).") else: - LOGGER.warn("Imported resource file '%s' is empty." % source) + LOGGER.warn(f"Imported resource file '{source}' is empty.") return resource def _parse(self, source): From 68a6a282e31c987c0b18c172bd2e52a389582a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 4 Oct 2022 12:34:06 +0300 Subject: [PATCH 0239/1592] Libdoc: Support generating kw docs for suite files. #4493 --- atest/robot/libdoc/invalid_usage.robot | 27 ++++--- atest/robot/libdoc/suite_file.robot | 70 +++++++++++++++++++ .../testdata/libdoc/invalid_resource.resource | 9 +++ atest/testdata/libdoc/invalid_resource.robot | 6 ++ atest/testdata/libdoc/suite.robot | 27 +++++++ doc/schema/libdoc.json | 3 +- doc/schema/libdoc.xsd | 1 + doc/schema/libdoc_json_schema.py | 1 + src/robot/libdocpkg/builder.py | 14 ++-- src/robot/libdocpkg/robotbuilder.py | 39 +++++++---- 10 files changed, 170 insertions(+), 27 deletions(-) create mode 100644 atest/robot/libdoc/suite_file.robot create mode 100644 atest/testdata/libdoc/invalid_resource.resource create mode 100644 atest/testdata/libdoc/invalid_resource.robot create mode 100644 atest/testdata/libdoc/suite.robot diff --git a/atest/robot/libdoc/invalid_usage.robot b/atest/robot/libdoc/invalid_usage.robot index 24f1c0b4ec7..7071e61919b 100644 --- a/atest/robot/libdoc/invalid_usage.robot +++ b/atest/robot/libdoc/invalid_usage.robot @@ -2,7 +2,6 @@ Resource libdoc_resource.robot Test Setup Remove File ${OUT HTML} Test Template Run libdoc and verify error -Test Teardown Should Not Exist ${OUT HTML} *** Test Cases *** No arguments @@ -53,11 +52,17 @@ Non-XML spec [Teardown] Remove File ${OUT XML} Invalid resource - ${CURDIR}/invalid_usage.robot ${OUT HTML} - ... ? ERROR ? Error in file '*' on line 3: Setting 'Test Setup' is not allowed in resource file. - ... ? ERROR ? Error in file '*' on line 4: Setting 'Test Template' is not allowed in resource file. - ... ? ERROR ? Error in file '*' on line 5: Setting 'Test Teardown' is not allowed in resource file. - ... Error in file '*[/\\]invalid_usage.robot' on line 7: Resource file with 'Test Cases' section is invalid. + ${TESTDATADIR}/invalid_resource.resource ${OUT HTML} + ... ? ERROR ? Error in file '*[/\\]invalid_resource.resource' on line 2: Setting 'Metadata' is not allowed in resource file. + ... ? ERROR ? Error in file '*[/\\]invalid_resource.resource' on line 3: Setting 'Test Setup' is not allowed in resource file. + ... Error in file '*[/\\]invalid_resource.resource' on line 5: Resource file with 'Test Cases' section is invalid. + +Invalid resource with '.robot' extension + ${TESTDATADIR}/invalid_resource.robot ${OUT HTML} + ... ? ERROR ? Error in file '*[/\\]invalid_resource.robot' on line 2: Setting 'Metadata' is not allowed in resource file. + ... ? ERROR ? Error in file '*[/\\]invalid_resource.robot' on line 3: Setting 'Test Setup' is not allowed in resource file. + ... ${OUT HTML} + ... fatal=False Invalid output file [Setup] Run Keywords @@ -75,5 +80,11 @@ invalid Spec File version *** Keywords *** Run libdoc and verify error - [Arguments] ${args} @{error} - Run libdoc and verify output ${args} @{error} ${USAGE TIP[1:]} + [Arguments] ${args} @{error} ${fatal}=True + IF ${fatal} + Run Libdoc And Verify Output ${args} @{error} ${USAGE TIP[1:]} + File Should Not Exist ${OUT HTML} + ELSE + Run Libdoc And Verify Output ${args} @{error} + File Should Exist ${OUT HTML} + END diff --git a/atest/robot/libdoc/suite_file.robot b/atest/robot/libdoc/suite_file.robot new file mode 100644 index 00000000000..094e510d542 --- /dev/null +++ b/atest/robot/libdoc/suite_file.robot @@ -0,0 +1,70 @@ +*** Settings *** +Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/suite.robot +Resource libdoc_resource.robot + +*** Test Cases *** +Name + Name Should Be Suite + +Documentation + Doc Should Be Documentation for keywords in suite ``Suite``. + +Version + Version Should Be ${EMPTY} + +Type + Type Should Be SUITE + +Generated + Generated Should Be Defined + +Scope + Scope Should Be GLOBAL old=${EMPTY} + +Source Info + Source Should Be ${TESTDATADIR}/suite.robot + Lineno Should Be 1 + +Spec version + Spec version should be correct + +Tags + Specfile Tags Should Be $\{CURDIR} keyword tags tags + +Suite Has No Inits + Should Have No Init + +Keyword Names + Keyword Name Should Be 0 1. Example + Keyword Name Should Be 1 2. Keyword with some "stuff" to <escape> + +Keyword Arguments + Keyword Arguments Should Be 0 + Keyword Arguments Should Be 1 a1 a2=c:\\temp\\ + +Different Argument Types + Keyword Arguments Should Be 2 mandatory optional=default *varargs + ... kwo=default another **kwargs + +Embedded Arguments + Keyword Name Should Be 3 4. Embedded \${arguments} + Keyword Arguments Should Be 3 + +Keyword Documentation + Keyword Doc Should Be 0 Keyword doc with $\{CURDIR}. + Keyword Doc Should Be 1 foo bar `kw` & some "stuff" to <escape> .\n\nbaa `\${a1}` + Keyword Doc Should Be 2 Multiple\n\nlines. + +Keyword tags + Keyword Tags Should Be 0 keyword tags tags + Keyword Tags Should Be 1 $\{CURDIR} keyword tags + +Non ASCII + Keyword Doc Should Be 3 Hyvää yötä. дякую! + +Keyword Source Info + Keyword Should Not Have Source 0 + Keyword Lineno Should Be 0 10 + +Test related settings should not cause errors + Should Not Contain ${OUTPUT} ERROR diff --git a/atest/testdata/libdoc/invalid_resource.resource b/atest/testdata/libdoc/invalid_resource.resource new file mode 100644 index 00000000000..04aa896f706 --- /dev/null +++ b/atest/testdata/libdoc/invalid_resource.resource @@ -0,0 +1,9 @@ +*** Settings *** +Metadata Not allowed +Test Setup Not allowed either + +*** Test Cases *** +Definitely not allowed + +*** Keywords *** +Example diff --git a/atest/testdata/libdoc/invalid_resource.robot b/atest/testdata/libdoc/invalid_resource.robot new file mode 100644 index 00000000000..88acdcf297e --- /dev/null +++ b/atest/testdata/libdoc/invalid_resource.robot @@ -0,0 +1,6 @@ +*** Settings *** +Metadata Not allowed +Test Setup Not allowed either + +*** Keywords *** +Example diff --git a/atest/testdata/libdoc/suite.robot b/atest/testdata/libdoc/suite.robot new file mode 100644 index 00000000000..dc851d88858 --- /dev/null +++ b/atest/testdata/libdoc/suite.robot @@ -0,0 +1,27 @@ +*** Settings *** +Documentation Doc for suite. Not used by Libdoc. +Test Tags Should not cause errors with Libdoc. +Keyword Tags keyword tags + +*** Test Cases *** +This is a suite file, not a resource file. + +*** Keywords *** +1. Example + [Documentation] Keyword doc with ${CURDIR}. + [Tags] tags + +2. Keyword with some "stuff" to <escape> + [Arguments] ${a1} ${a2}=c:\temp\ + [Documentation] foo bar `kw` & some "stuff" to <escape> .\n\nbaa `${a1}` + [Tags] ${CURDIR} + +3. Different argument types + [Arguments] ${mandatory} ${optional}=default @{varargs} + ... ${kwo}=default ${another} &{kwargs} + [Documentation] Multiple + ... + ... lines. + +4. Embedded ${arguments} + [Documentation] Hyvää yötä. дякую! diff --git a/doc/schema/libdoc.json b/doc/schema/libdoc.json index 4399a37c7a3..cf16a13e23d 100644 --- a/doc/schema/libdoc.json +++ b/doc/schema/libdoc.json @@ -109,7 +109,8 @@ "description": "Type of the doc: LIBRARY or RESOURCE.", "enum": [ "LIBRARY", - "RESOURCE" + "RESOURCE", + "SUITE" ], "type": "string" }, diff --git a/doc/schema/libdoc.xsd b/doc/schema/libdoc.xsd index 00ce4e6750d..a7e2fd07462 100644 --- a/doc/schema/libdoc.xsd +++ b/doc/schema/libdoc.xsd @@ -169,6 +169,7 @@ <xs:restriction base="xs:string"> <xs:enumeration value="LIBRARY" /> <xs:enumeration value="RESOURCE" /> + <xs:enumeration value="SUITE" /> </xs:restriction> </xs:simpleType> <xs:simpleType name="SpecVersion"> diff --git a/doc/schema/libdoc_json_schema.py b/doc/schema/libdoc_json_schema.py index 4f91b29061b..5b4bffb5e50 100755 --- a/doc/schema/libdoc_json_schema.py +++ b/doc/schema/libdoc_json_schema.py @@ -25,6 +25,7 @@ class DocumentationType(str, Enum): """Type of the doc: LIBRARY or RESOURCE.""" LIBRARY = 'LIBRARY' RESOURCE = 'RESOURCE' + SUITE = 'SUITE' class LibraryScope(str, Enum): diff --git a/src/robot/libdocpkg/builder.py b/src/robot/libdocpkg/builder.py index fbec1a78b63..4b38fcdbb39 100644 --- a/src/robot/libdocpkg/builder.py +++ b/src/robot/libdocpkg/builder.py @@ -19,7 +19,7 @@ from robot.utils import get_error_message from .jsonbuilder import JsonDocBuilder -from .robotbuilder import LibraryDocBuilder, ResourceDocBuilder +from .robotbuilder import LibraryDocBuilder, ResourceDocBuilder, SuiteDocBuilder from .xmlbuilder import XmlDocBuilder @@ -51,10 +51,12 @@ def _build(builder, source): and not os.path.exists(source) and _get_extension(source) in RESOURCE_EXTENSIONS): return _build(ResourceDocBuilder(), source) + # Resource file with other extension than '.resource' parsed as a suite file. + if isinstance(builder, SuiteDocBuilder): + return _build(ResourceDocBuilder(), source) raise - except: - raise DataError("Building library '%s' failed: %s" - % (source, get_error_message())) + except Exception: + raise DataError(f"Building library '{source}' failed: {get_error_message()}") def _get_extension(source): @@ -73,8 +75,10 @@ def DocumentationBuilder(library_or_resource): """ if os.path.exists(library_or_resource): extension = _get_extension(library_or_resource) - if extension in RESOURCE_EXTENSIONS: + if extension == 'resource': return ResourceDocBuilder() + if extension in RESOURCE_EXTENSIONS: + return SuiteDocBuilder() if extension in XML_EXTENSIONS: return XmlDocBuilder() if extension == 'json': diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index c8de00a3836..7ba402aad12 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -18,8 +18,8 @@ import re from robot.errors import DataError -from robot.running import (TestLibrary, UserLibrary, UserErrorHandler, - ResourceFileBuilder) +from robot.running import (ResourceFileBuilder, TestLibrary, TestSuiteBuilder, + UserLibrary, UserErrorHandler) from robot.utils import is_string, split_tags_from_doc, type_repr, unescape from robot.variables import search_variable @@ -81,12 +81,14 @@ def _get_type_docs(self, keywords, custom_converters): class ResourceDocBuilder: + type = 'RESOURCE' def build(self, path): - res = self._import_resource(path) - libdoc = LibraryDoc(name=res.name, - doc=self._get_doc(res), - type='RESOURCE', + path = self._find_resource_file(path) + res, name = self._import_resource(path) + libdoc = LibraryDoc(name=name, + doc=self._get_doc(res, name), + type=self.type, scope='GLOBAL', source=res.source, lineno=1) @@ -94,9 +96,9 @@ def build(self, path): return libdoc def _import_resource(self, path): - ast = ResourceFileBuilder(process_curdir=False).build( - self._find_resource_file(path)) - return UserLibrary(ast) + model = ResourceFileBuilder(process_curdir=False).build(path) + resource = UserLibrary(model) + return resource, resource.name def _find_resource_file(self, path): if os.path.isfile(path): @@ -107,10 +109,21 @@ def _find_resource_file(self, path): return candidate raise DataError(f"Resource file '{path}' does not exist.") - def _get_doc(self, res): - if res.doc: - return unescape(res.doc) - return f"Documentation for resource file ``{res.name}``." + def _get_doc(self, resource, name): + if resource.doc: + return unescape(resource.doc) + return f"Documentation for resource file ``{name}``." + + +class SuiteDocBuilder(ResourceDocBuilder): + type = 'SUITE' + + def _import_resource(self, path): + suite = TestSuiteBuilder(process_curdir=False).build(path) + return UserLibrary(suite.resource), suite.name + + def _get_doc(self, resource, name): + return f"Documentation for keywords in suite ``{name}``." class KeywordDocBuilder: From e87e080408b77c501ee9ed9acbe7fe2b4dfdc6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 4 Oct 2022 17:08:46 +0300 Subject: [PATCH 0240/1592] Libdoc: Support generating docs for __init__.robot files. Also document that with RF 6.0 Libdoc supports generating docs for suite files and suite initialization files. Fixes #4493. --- atest/robot/libdoc/suite_init_file.robot | 70 +++++++++++++++++++ atest/testdata/libdoc/__init__.robot | 24 +++++++ .../CreatingTestData/CreatingTestCases.rst | 2 +- .../CreatingTestData/CreatingTestSuites.rst | 7 +- .../CreatingTestData/CreatingUserKeywords.rst | 2 +- .../ResourceAndVariableFiles.rst | 2 +- .../CreatingTestData/UsingTestLibraries.rst | 2 +- doc/userguide/src/RobotFrameworkUserGuide.rst | 5 +- doc/userguide/src/SupportingTools/Libdoc.rst | 9 ++- src/robot/libdocpkg/robotbuilder.py | 11 ++- 10 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 atest/robot/libdoc/suite_init_file.robot create mode 100644 atest/testdata/libdoc/__init__.robot diff --git a/atest/robot/libdoc/suite_init_file.robot b/atest/robot/libdoc/suite_init_file.robot new file mode 100644 index 00000000000..be36811fb68 --- /dev/null +++ b/atest/robot/libdoc/suite_init_file.robot @@ -0,0 +1,70 @@ +*** Settings *** +Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/__init__.robot +Resource libdoc_resource.robot + +*** Test Cases *** +Name + Name Should Be Libdoc + +Documentation + Doc Should Be Documentation for keywords in suite ``Libdoc``. + +Version + Version Should Be ${EMPTY} + +Type + Type Should Be SUITE + +Generated + Generated Should Be Defined + +Scope + Scope Should Be GLOBAL old=${EMPTY} + +Source Info + Source Should Be ${TESTDATADIR} + Lineno Should Be 1 + +Spec version + Spec version should be correct + +Tags + Specfile Tags Should Be $\{CURDIR} keyword tags tags + +Suite Has No Inits + Should Have No Init + +Keyword Names + Keyword Name Should Be 0 1. Example + Keyword Name Should Be 1 2. Keyword with some "stuff" to <escape> + +Keyword Arguments + Keyword Arguments Should Be 0 + Keyword Arguments Should Be 1 a1 a2=c:\\temp\\ + +Different Argument Types + Keyword Arguments Should Be 2 mandatory optional=default *varargs + ... kwo=default another **kwargs + +Embedded Arguments + Keyword Name Should Be 3 4. Embedded \${arguments} + Keyword Arguments Should Be 3 + +Keyword Documentation + Keyword Doc Should Be 0 Keyword doc with $\{CURDIR}. + Keyword Doc Should Be 1 foo bar `kw` & some "stuff" to <escape> .\n\nbaa `\${a1}` + Keyword Doc Should Be 2 Multiple\n\nlines. + +Keyword tags + Keyword Tags Should Be 0 keyword tags tags + Keyword Tags Should Be 1 $\{CURDIR} keyword tags + +Non ASCII + Keyword Doc Should Be 3 Hyvää yötä. дякую! + +Keyword Source Info + Keyword Should Not Have Source 0 + Keyword Lineno Should Be 0 7 + +Test related settings should not cause errors + Should Not Contain ${OUTPUT} ERROR diff --git a/atest/testdata/libdoc/__init__.robot b/atest/testdata/libdoc/__init__.robot new file mode 100644 index 00000000000..e454e9f5b01 --- /dev/null +++ b/atest/testdata/libdoc/__init__.robot @@ -0,0 +1,24 @@ +*** Settings *** +Documentation Doc for suite. Not used by Libdoc. +Suite Setup Log Should not cause errors with Libdoc. +Keyword Tags keyword tags + +*** Keywords *** +1. Example + [Documentation] Keyword doc with ${CURDIR}. + [Tags] tags + +2. Keyword with some "stuff" to <escape> + [Arguments] ${a1} ${a2}=c:\temp\ + [Documentation] foo bar `kw` & some "stuff" to <escape> .\n\nbaa `${a1}` + [Tags] ${CURDIR} + +3. Different argument types + [Arguments] ${mandatory} ${optional}=default @{varargs} + ... ${kwo}=default ${another} &{kwargs} + [Documentation] Multiple + ... + ... lines. + +4. Embedded ${arguments} + [Documentation] Hyvää yötä. дякую! diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index 6b7c3af1b3f..8968690e0dd 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -608,7 +608,7 @@ There are multiple ways how to specify tags for test cases explained below: `Test Tags`:setting: in the Setting section All tests in a test case file with this setting always get specified tags. - If this setting is used in a `test suite initialization file`_, all tests + If this setting is used in a `suite initialization file`_, all tests in child suites get these tags. `[Tags]`:setting: with each test case diff --git a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst index 4ed381ae5e8..be456179979 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst @@ -60,8 +60,8 @@ If a file or directory that is processed does not contain any test cases, it is silently ignored (a message is written to the syslog_) and the processing continues. -Initialization files -~~~~~~~~~~~~~~~~~~~~ +Suite initialization files +~~~~~~~~~~~~~~~~~~~~~~~~~~ A test suite created from a directory can have similar settings as a suite created from a test case file. Because a directory alone cannot have that @@ -190,8 +190,7 @@ Not only `test cases`__ but also test suites can have a setup and a teardown. A suite setup is executed before running any of the suite's test cases or child test suites, and a test teardown is executed after them. All test suites can have a setup and a teardown; with suites created -from a directory they must be specified in a `test suite -initialization file`_. +from a directory they must be specified in a `suite initialization file`_. __ `Test setup and teardown`_ diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 6d5ca1db449..9b9d691614e 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -47,7 +47,7 @@ values`_. __ `User keyword arguments`_ User keywords can be created in `test case files`_, `resource files`_, -and `test suite initialization files`_. Keywords created in resource +and `suite initialization files`_. Keywords created in resource files are available for files using them, whereas other keywords are only available in the files where they are created. diff --git a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst index 345c25fecb9..b0171a902c2 100644 --- a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst +++ b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst @@ -1,7 +1,7 @@ Resource and variable files =========================== -User keywords and variables in `test case files`_ and `test suite +User keywords and variables in `test case files`_ and `suite initialization files`_ can only be used in files where they are created, but *resource files* provide a mechanism for sharing them. The high level syntax for creating resource files is exactly the same diff --git a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst index d9abcaae4a4..52fb42dce11 100644 --- a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst +++ b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst @@ -45,7 +45,7 @@ __ `Using arguments`_ Library ${LIBRARY} It is possible to import test libraries in `test case files`_, -`resource files`_ and `test suite initialization files`_. In all these +`resource files`_ and `suite initialization files`_. In all these cases, all the keywords in the imported library are available in that file. With resource files, those keywords are also available in other files using them. diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index 647684fa3c2..7831ddd2792 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -136,9 +136,8 @@ .. _data-driven approach: `Data-driven style`_ .. _test case file: `Test case files`_ .. _test suite directory: `Test suite directories`_ -.. _initialization file: `Initialization files`_ -.. _test suite initialization file: `Initialization files`_ -.. _test suite initialization files: `Initialization files`_ +.. _initialization file: `Suite initialization files`_ +.. _suite initialization file: `Suite initialization files`_ .. _test case name: `Test case name and documentation`_ .. _test case documentation: `Test case name and documentation`_ .. _test suite name: `Test suite name and documentation`_ diff --git a/doc/userguide/src/SupportingTools/Libdoc.rst b/doc/userguide/src/SupportingTools/Libdoc.rst index 2fbfab63346..7a6548f5351 100644 --- a/doc/userguide/src/SupportingTools/Libdoc.rst +++ b/doc/userguide/src/SupportingTools/Libdoc.rst @@ -16,12 +16,17 @@ on the console. Documentation can be created for: - libraries implemented using the normal static library API__, -- libraries using the `dynamic API`__, including remote libraries, and -- `resource files`_. +- libraries using the `dynamic API`__, including remote libraries, +- `resource files`_, +- `test case files`_, and +- `suite initialization files`_. Additionally it is possible to use XML and JSON spec files created by Libdoc earlier as an input. +.. note:: Support for generating documentation for test case files and suite + initialization files is new in Robot Framework 6.0. + .. note:: The support for the JSON spec files is new in Robot Framework 4.0. __ `Python libraries`_ diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index 7ba402aad12..63522e20672 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -102,11 +102,11 @@ def _import_resource(self, path): def _find_resource_file(self, path): if os.path.isfile(path): - return os.path.normpath(path) + return os.path.normpath(os.path.abspath(path)) for dire in [item for item in sys.path if os.path.isdir(item)]: candidate = os.path.normpath(os.path.join(dire, path)) if os.path.isfile(candidate): - return candidate + return os.path.abspath(candidate) raise DataError(f"Resource file '{path}' does not exist.") def _get_doc(self, resource, name): @@ -119,7 +119,12 @@ class SuiteDocBuilder(ResourceDocBuilder): type = 'SUITE' def _import_resource(self, path): - suite = TestSuiteBuilder(process_curdir=False).build(path) + builder = TestSuiteBuilder(process_curdir=False) + if os.path.basename(path).lower() == '__init__.robot': + path = os.path.dirname(path) + builder.included_suites = () + builder.allow_empty_suite = True + suite = builder.build(path) return UserLibrary(suite.resource), suite.name def _get_doc(self, resource, name): From 3226adb7732114ed3815ee3b6e47524dd3df7300 Mon Sep 17 00:00:00 2001 From: ALX99 <46844683+ALX99@users.noreply.github.com> Date: Thu, 6 Oct 2022 23:48:59 +0200 Subject: [PATCH 0241/1592] UG: Fix missing asterisk (#4498) --- doc/userguide/src/CreatingTestData/CreatingTestCases.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index 8968690e0dd..2f58072d85c 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -843,7 +843,7 @@ functionally fully identical. .. sourcecode:: robotframework - *** Test Cases ** + *** Test Cases *** Normal test case Example keyword first argument second argument From 0d9b18c12e64a3f2c3e0e080bcf7bf5d32175836 Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Sat, 8 Oct 2022 00:01:24 +0200 Subject: [PATCH 0242/1592] Add Bulgarian, Romanian and Swedish localisation (#4499) Swedish by @JockeJarre Bulgarian by @naschenez Romanian by @zastress Thank you all for your contribution! #4390 --- src/robot/conf/languages.py | 124 ++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 3d178eb9c17..33fb9fec0d2 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -925,3 +925,127 @@ class Tr(Language): but_prefix = {'Ancak'} true_strings = {'DOĞRU', 'EVET', 'AÇIK'} false_strings = {'YANLIŞ', 'HAYIR', 'KAPALI'} + + +class Sv(Language): + """Swedish""" + settings_header = 'Inställningar' + variables_header = 'Variabler' + test_cases_header = 'Testfall' + tasks_header = 'Taskar' + keywords_header = 'Nyckelord' + comments_header = 'Kommentarer' + library_setting = 'Bibliotek' + resource_setting = 'Resurs' + variables_setting = 'Variabel' + documentation_setting = 'Dokumentation' + metadata_setting = 'Metadata' + suite_setup_setting = 'Svit konfigurering' + suite_teardown_setting = 'Svit nedrivning' + test_setup_setting = 'Test konfigurering' + test_teardown_setting = 'Test nedrivning' + test_template_setting = 'Test mall' + test_timeout_setting = 'Test timeout' + test_tags_setting = 'Test taggar' + task_setup_setting = 'Task konfigurering' + task_teardown_setting = 'Task nedrivning' + task_template_setting = 'Task mall' + task_timeout_setting = 'Task timeout' + task_tags_setting = 'Arbetsuppgift taggar' + keyword_tags_setting = 'Nyckelord taggar' + tags_setting = 'Taggar' + setup_setting = 'Konfigurering' + teardown_setting = 'Nedrivning' + template_setting = 'Mall' + timeout_setting = 'Timeout' + arguments_setting = 'Argument' + given_prefix = {'Givet'} + when_prefix = {'När'} + then_prefix = {'Då'} + and_prefix = {'Och'} + but_prefix = {'Men'} + true_strings = {'SANT', 'JA', 'PÅ'} + false_strings = {'FALSKT', 'NEJ', 'AV', 'INGEN'} + + +class Bg(Language): + """Bulgarian""" + settings_header = 'Настройки' + variables_header = 'Променливи' + test_cases_header = 'Тестови случаи' + tasks_header = 'Задачи' + keywords_header = 'Ключови думи' + comments_header = 'Коментари' + library_setting = 'Библиотека' + resource_setting = 'Ресурс' + variables_setting = 'Променлива' + documentation_setting = 'Документация' + metadata_setting = 'Метаданни' + suite_setup_setting = 'Първоначални настройки на комплекта' + suite_teardown_setting = 'Приключване на комплекта' + test_setup_setting = 'Първоначални настройки на тестове' + test_teardown_setting = 'Приключване на тестове' + test_template_setting = 'Шаблон за тестове' + test_timeout_setting = 'Таймаут за тестове' + test_tags_setting = 'Етикети за тестове' + task_setup_setting = 'Първоначални настройки на задачи' + task_teardown_setting = 'Приключване на задачи' + task_template_setting = 'Шаблон за задачи' + task_timeout_setting = 'Таймаут за задачи' + task_tags_setting = 'Етикети за задачи' + keyword_tags_setting = 'Етикети за ключови думи' + tags_setting = 'Етикети' + setup_setting = 'Първоначални настройки' + teardown_setting = 'Приключване' + template_setting = 'Шаблон' + timeout_setting = 'Таймаут' + arguments_setting = 'Аргументи' + given_prefix = {'В случай че'} + when_prefix = {'Когато'} + then_prefix = {'Тогава'} + and_prefix = {'И'} + but_prefix = {'Но'} + true_strings = {'Вярно', 'Да', 'Включен'} + false_strings = {'Невярно', 'Не', 'Изключен', 'Нищо'} + + +class Ro(Language): + """Romanian""" + settings_header = 'Setari' + variables_header = 'Variabile' + test_cases_header = 'Cazuri De Test' + tasks_header = 'Sarcini' + keywords_header = 'Cuvinte Cheie' + comments_header = 'Comentarii' + library_setting = 'Librarie' + resource_setting = 'Resursa' + variables_setting = 'Variabila' + documentation_setting = 'Documentatie' + metadata_setting = 'Metadate' + suite_setup_setting = 'Configurare De Suita' + suite_teardown_setting = 'Configurare De Intrerupere' + test_setup_setting = 'Setare De Test' + test_teardown_setting = 'Inrerupere De Test' + test_template_setting = 'Sablon De Test' + test_timeout_setting = 'Timp Expirare Test' + test_tags_setting = 'Taguri De Test' + task_setup_setting = 'Configuarare activitate' + task_teardown_setting = 'Intrerupere activitate' + task_template_setting = 'Sablon de activitate' + task_timeout_setting = 'Timp de expirare activitate' + task_tags_setting = 'Etichete activitate' + keyword_tags_setting = 'Etichete metode' + tags_setting = 'Etichete' + setup_setting = 'Setare' + teardown_setting = 'Intrerupere' + template_setting = 'Sablon' + timeout_setting = 'Expirare' + arguments_setting = 'Argumente' + given_prefix = {'Fie ca'} + when_prefix = {'Cand'} + then_prefix = {'Atunci'} + and_prefix = {'Si'} + but_prefix = {'Dar'} + true_strings = {'ADEVARAT', 'DA', 'CAND'} + false_strings = {'FALS', 'NU', 'OPRIT', 'NICIUN'} + From 8b5da31d06ddfb77804cb1fa1fd835ab7ab64658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 8 Oct 2022 01:42:11 +0300 Subject: [PATCH 0243/1592] Change normalized Boolean words to title case. Main motivation is being consistent with other language markers that are in title case. TRUE/FALSE_STRINGS in robot.utils.robottypes weren't touched to avoid backwards compatibility concerns. Those constants shouldn't probably be publicly exposed at all. --- src/robot/conf/languages.py | 5 ++--- src/robot/running/arguments/typeconverters.py | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 33fb9fec0d2..a5b69274326 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -52,8 +52,8 @@ def _add_language(self, lang): self.headers.update({n.title(): lang.headers[n] for n in lang.headers if n}) self.settings.update({n.title(): lang.settings[n] for n in lang.settings if n}) self.bdd_prefixes |= {p.title() for p in lang.bdd_prefixes} - self.true_strings |= {s.upper() for s in lang.true_strings} - self.false_strings |= {s.upper() for s in lang.false_strings} + self.true_strings |= {s.title() for s in lang.true_strings} + self.false_strings |= {s.title() for s in lang.false_strings} def _get_languages(self, languages): languages = self._resolve_languages(languages) @@ -1048,4 +1048,3 @@ class Ro(Language): but_prefix = {'Dar'} true_strings = {'ADEVARAT', 'DA', 'CAND'} false_strings = {'FALS', 'NU', 'OPRIT', 'NICIUN'} - diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 18f5adc2966..35d8ea9d0ce 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -246,12 +246,12 @@ def _non_string_convert(self, value, explicit_type=True): return value def _convert(self, value, explicit_type=True): - upper = value.upper() - if upper == 'NONE': + normalized = value.title() + if normalized == 'None': return None - if upper in self.languages.true_strings: + if normalized in self.languages.true_strings: return True - if upper in self.languages.false_strings: + if normalized in self.languages.false_strings: return False return value From 4c1ed8fae2eeea600fa73a07407a72caa9242215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 8 Oct 2022 14:40:57 +0300 Subject: [PATCH 0244/1592] Quitly deprecate `robot.utils.TRUE/FALSE_STRINGS`. Fixes #4500. --- src/robot/utils/__init__.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 60471147b04..c73c034a712 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -62,9 +62,9 @@ get_time, get_timestamp, secs_to_timestamp, secs_to_timestr, timestamp_to_secs, timestr_to_secs, parse_time) -from .robottypes import (FALSE_STRINGS, TRUE_STRINGS, is_bytes, is_dict_like, is_falsy, - is_integer, is_list_like, is_number, is_pathlike, is_string, - is_truthy, is_union, type_name, type_repr, typeddict_types) +from .robottypes import (is_bytes, is_dict_like, is_falsy, is_integer, is_list_like, + is_number, is_pathlike, is_string, is_truthy, is_union, + type_name, type_repr, typeddict_types) from .setter import setter, SetterAwareType from .sortable import Sortable from .text import (cut_assign_value, cut_long_message, format_assign_message, @@ -78,11 +78,16 @@ def read_rest_data(rstfile): return read_rest_data(rstfile) +# Quietly deprecated utils. Should be deprecated loudly in RF 7.0. +# https://github.com/robotframework/robotframework/issues/4501 + +from .robottypes import FALSE_STRINGS, TRUE_STRINGS + + # Deprecated Python 2/3 compatibility layer. Not needed by Robot Framework itself -# anymore because Python 2 support was dropped in RF 5. Preserved at least until -# RF 5.2 to avoid breaking external libraries and tools that use it. There's also -# `PY2` in the `platform` submodule. -# https://github.com/robotframework/robotframework/issues/4150 +# after RF 5.0 when Python 2 support was dropped. Should be deprecated loudly in +# RF 7.0. Notice that there's also `PY2` in the `platform` submodule. +# https://github.com/robotframework/robotframework/issues/4501 from io import StringIO From 1fb2ac59c3b0ebf5812d5b109e3cfb04b355acaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 8 Oct 2022 14:47:17 +0300 Subject: [PATCH 0245/1592] Convert true/false strings to title case. Motivation is to be consistent with other language markers that are in title case. Code was already earlier changed to convert markers to title case. --- src/robot/conf/languages.py | 48 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index a5b69274326..6da46158f96 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -277,8 +277,8 @@ class En(Language): then_prefix = {'Then'} and_prefix = {'And'} but_prefix = {'But'} - true_strings = {'TRUE', 'YES', 'ON'} - false_strings = {'FALSE', 'NO', 'OFF'} + true_strings = {'True', 'Yes', 'On'} + false_strings = {'False', 'No', 'Off'} class Cs(Language): @@ -318,8 +318,8 @@ class Cs(Language): then_prefix = {'Pak'} and_prefix = {'A'} but_prefix = {'Ale'} - true_strings = {'PRAVDA', 'ANO', 'ZAPNUTO'} - false_strings = {'NEPRAVDA', 'NE', 'VYPNUTO', 'NIC'} + true_strings = {'Pravda', 'Ano', 'Zapnuto'} + false_strings = {'Nepravda', 'Ne', 'Vypnuto', 'Nic'} class Nl(Language): @@ -359,8 +359,8 @@ class Nl(Language): then_prefix = {'Dan'} and_prefix = {'En'} but_prefix = {'Maar'} - true_strings = {'WAAR', 'JA', 'AAN'} - false_strings = {'ONWAAR', 'NEE', 'UIT', 'GEEN'} + true_strings = {'Waar', 'Ja', 'Aan'} + false_strings = {'Onwaar', 'Nee', 'Uit', 'Geen'} class Bs(Language): @@ -439,8 +439,8 @@ class Fi(Language): then_prefix = {'Niin'} and_prefix = {'Ja'} but_prefix = {'Mutta'} - true_strings = {'TOSI', 'KYLLÄ', 'PÄÄLLÄ'} - false_strings = {'EPÄTOSI', 'EI', 'POIS'} + true_strings = {'Tosi', 'Kyllä', 'Päällä'} + false_strings = {'Epätosi', 'Ei', 'Pois'} class Fr(Language): @@ -480,8 +480,8 @@ class Fr(Language): then_prefix = {'Alors'} and_prefix = {'Et'} but_prefix = {'Mais'} - true_strings = {'VRAI', 'OUI', 'ACTIF'} - false_strings = {'FAUX', 'NON', 'Désactivé', 'AUCUN'} + true_strings = {'Vrai', 'Oui', 'Actif'} + false_strings = {'Faux', 'Non', 'Désactivé', 'Aucun'} class De(Language): @@ -521,8 +521,8 @@ class De(Language): then_prefix = {'Dann'} and_prefix = {'Und'} but_prefix = {'Aber'} - true_strings = {'WAHR', 'JA', 'AN', 'EIN'} - false_strings = {'FALSCH', 'NEIN', 'AUS', 'UNWAHR'} + true_strings = {'Wahr', 'Ja', 'An', 'Ein'} + false_strings = {'Falsch', 'Nein', 'Aus', 'Unwahr'} class PtBr(Language): @@ -562,8 +562,8 @@ class PtBr(Language): then_prefix = {'Então'} and_prefix = {'E'} but_prefix = {'Mas'} - true_strings = {'VERDADEIRO', 'VERDADE', 'SIM', 'LIGADO'} - false_strings = {'FALSO', 'NÃO', 'DESLIGADO', 'DESATIVADO', 'NADA'} + true_strings = {'Verdadeiro', 'Verdade', 'Sim', 'Ligado'} + false_strings = {'Falso', 'Não', 'Desligado', 'Desativado', 'Nada'} class Pt(Language): @@ -603,8 +603,8 @@ class Pt(Language): then_prefix = {'Então'} and_prefix = {'E'} but_prefix = {'Mas'} - true_strings = {'VERDADEIRO', 'VERDADE', 'SIM', 'LIGADO'} - false_strings = {'FALSO', 'NÃO', 'DESLIGADO', 'DESATIVADO', 'NADA'} + true_strings = {'Verdadeiro', 'Verdade', 'Sim', 'Ligado'} + false_strings = {'Falso', 'Não', 'Desligado', 'Desativado', 'Nada'} class Th(Language): @@ -761,8 +761,8 @@ class Es(Language): then_prefix = {'Entonces'} and_prefix = {'Y'} but_prefix = {'Pero'} - true_strings = {'Verdadero', 'Si', 'ON'} - false_strings = {'Falso', 'No', 'OFF', 'Ninguno'} + true_strings = {'Verdadero', 'Si', 'On'} + false_strings = {'Falso', 'No', 'Off', 'Ninguno'} class Ru(Language): @@ -923,8 +923,8 @@ class Tr(Language): then_prefix = {'O zaman'} and_prefix = {'Ve'} but_prefix = {'Ancak'} - true_strings = {'DOĞRU', 'EVET', 'AÇIK'} - false_strings = {'YANLIŞ', 'HAYIR', 'KAPALI'} + true_strings = {'Doğru', 'Evet', 'Açik'} + false_strings = {'Yanliş', 'Hayir', 'Kapali'} class Sv(Language): @@ -964,8 +964,8 @@ class Sv(Language): then_prefix = {'Då'} and_prefix = {'Och'} but_prefix = {'Men'} - true_strings = {'SANT', 'JA', 'PÅ'} - false_strings = {'FALSKT', 'NEJ', 'AV', 'INGEN'} + true_strings = {'Sant', 'Ja', 'På'} + false_strings = {'Falskt', 'Nej', 'Av', 'Ingen'} class Bg(Language): @@ -1046,5 +1046,5 @@ class Ro(Language): then_prefix = {'Atunci'} and_prefix = {'Si'} but_prefix = {'Dar'} - true_strings = {'ADEVARAT', 'DA', 'CAND'} - false_strings = {'FALS', 'NU', 'OPRIT', 'NICIUN'} + true_strings = {'Adevarat', 'Da', 'Cand'} + false_strings = {'Fals', 'Nu', 'Oprit', 'Niciun'} From 92f62332f581072e3e923869d5017b60c522a044 Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Mon, 10 Oct 2022 15:54:01 +0200 Subject: [PATCH 0246/1592] Add Italian (#4502) Provided by Luca Giorgi @lugi0. Part of #4390. --- src/robot/conf/languages.py | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 6da46158f96..7a7a6e82db5 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -1048,3 +1048,44 @@ class Ro(Language): but_prefix = {'Dar'} true_strings = {'Adevarat', 'Da', 'Cand'} false_strings = {'Fals', 'Nu', 'Oprit', 'Niciun'} + + +class It(Language): + """Italian""" + settings_header = 'Impostazioni' + variables_header = 'Variabili' + test_cases_header = 'Casi Di Test' + tasks_header = 'Attività' + keywords_header = 'Parole Chiave' + comments_header = 'Commenti' + library_setting = 'Libreria' + resource_setting = 'Risorsa' + variables_setting = 'Variabile' + documentation_setting = 'Documentazione' + metadata_setting = 'Metadati' + suite_setup_setting = 'Configurazione Suite' + suite_teardown_setting = 'Distruzione Suite' + test_setup_setting = 'Configurazione Test' + test_teardown_setting = 'Distruzione Test' + test_template_setting = 'Modello Test' + test_timeout_setting = 'Timeout Test' + test_tags_setting = 'Tag Del Test' + task_setup_setting = 'Configurazione Attività' + task_teardown_setting = 'Distruzione Attività' + task_template_setting = 'Modello Attività' + task_timeout_setting = 'Timeout Attività' + task_tags_setting = 'Tag Attività' + keyword_tags_setting = 'Tag Parola Chiave' + tags_setting = 'Tag' + setup_setting = 'Configurazione' + teardown_setting = 'Distruzione' + template_setting = 'Template' + timeout_setting = 'Timeout' + arguments_setting = 'Parametri' + given_prefix = {'Dato'} + when_prefix = {'Quando'} + then_prefix = {'Allora'} + and_prefix = {'E'} + but_prefix = {'Ma'} + true_strings = {'Vero', 'Sì', 'On'} + false_strings = {'Falso', 'No', 'Off', 'Nessuno'} From 32fbc1b42aa4b340fa0ef0e0cefe358bcac1f55b Mon Sep 17 00:00:00 2001 From: mikkuja <111570884+mikkuja@users.noreply.github.com> Date: Mon, 10 Oct 2022 17:43:01 +0300 Subject: [PATCH 0247/1592] Add ns and us support to robot time string (#4491) Time string now supports microsecond and nanosecond inputs like `1us`. Co-authored-by: Mikko Kujala <mikko.kujala@fi.abb.com> --- .../datetime/convert_time_input_format.robot | 4 ++++ src/robot/libraries/DateTime.py | 2 ++ src/robot/utils/robottime.py | 13 +++++++++---- utest/utils/test_robottime.py | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/atest/testdata/standard_libraries/datetime/convert_time_input_format.robot b/atest/testdata/standard_libraries/datetime/convert_time_input_format.robot index 2148ae33511..46d5d73ce7b 100644 --- a/atest/testdata/standard_libraries/datetime/convert_time_input_format.robot +++ b/atest/testdata/standard_libraries/datetime/convert_time_input_format.robot @@ -15,18 +15,22 @@ Time string 10 s 10 0 s 0 0.1 millisecond 0.0001 0.123456789 ms 0.000123456789 + 123 μs 0.000123 + 1 ns 1E-9 Number as string 10 10 0.5 0.5 -1 -1 0 0 0.123456789 0.123456789 + 1E-9 1E-9 Number ${42} 42 ${3.14} 3.14 ${-0.5} -0.5 ${0} 0 ${0.123456789} 0.123456789 + ${1E-9} 1E-9 Timer 00:00:00.000 0 00:00:00.001 0.001 diff --git a/src/robot/libraries/DateTime.py b/src/robot/libraries/DateTime.py index 5caea612ca5..d276f3bd980 100644 --- a/src/robot/libraries/DateTime.py +++ b/src/robot/libraries/DateTime.py @@ -186,6 +186,8 @@ - ``minutes``, ``minute``, ``mins``, ``min``, ``m`` - ``seconds``, ``second``, ``secs``, ``sec``, ``s`` - ``milliseconds``, ``millisecond``, ``millis``, ``ms`` +- ``microseconds``, ``microsecond``, ``us``, ``μs`` +- ``nanoseconds``, ``nanosecond``, ``ns`` When returning a time string, it is possible to select between ``verbose`` and ``compact`` representations using ``result_format`` argument. The verbose diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 44ca504189d..80eeb0cace3 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -78,7 +78,7 @@ def _time_string_to_secs(timestr): timestr = _normalize_timestr(timestr) if not timestr: return None - millis = secs = mins = hours = days = 0 + nanos = micros = millis = secs = mins = hours = days = 0 if timestr[0] == '-': sign = -1 timestr = timestr[1:] @@ -87,7 +87,9 @@ def _time_string_to_secs(timestr): temp = [] for c in timestr: try: - if c == 'x': millis = float(''.join(temp)); temp = [] + if c == 'n': nanos = float(''.join(temp)); temp = [] + elif c == 'u': micros = float(''.join(temp)); temp = [] + elif c == 'x': millis = float(''.join(temp)); temp = [] elif c == 's': secs = float(''.join(temp)); temp = [] elif c == 'm': mins = float(''.join(temp)); temp = [] elif c == 'h': hours = float(''.join(temp)); temp = [] @@ -97,12 +99,15 @@ def _time_string_to_secs(timestr): return None if temp: return None - return sign * (millis/1000 + secs + mins*60 + hours*60*60 + days*60*60*24) + return sign * (nanos/1E9 + micros/1E6 + millis/1000 + secs + + mins*60 + hours*60*60 + days*60*60*24) def _normalize_timestr(timestr): timestr = normalize(timestr) - for specifier, aliases in [('x', ['millisecond', 'millisec', 'millis', + for specifier, aliases in [('n', ['nanosecond', 'ns']), + ('u', ['microsecond', 'us', 'μs']), + ('x', ['millisecond', 'millisec', 'millis', 'msec', 'ms']), ('s', ['second', 'sec']), ('m', ['minute', 'min']), diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index 83bfaa01a4e..6cbb909a240 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -100,6 +100,20 @@ def test_timestr_to_secs_with_time_string(self): ('0day 0hour 0minute 0seconds 0millisecond', 0)]: assert_equal(timestr_to_secs(inp), exp, inp) + def test_timestr_to_secs_with_time_string_ns_accuracy(self): + for input, expected in [("1 us", 1E-6), + ("1 μs", 1E-6), + ("1 microsecond", 1E-6), + ("1 microseconds", 1E-6), + ("2 us", 2E-6), + ("1 ns", 1E-9), + ("1 nanosecond", 1E-9), + ("1 nanoseconds", 1E-9), + ("2 ns", 2E-9), + ("-100 ns", -100E-9), + ("1.2us", 1.2E-6)]: + assert_equal(timestr_to_secs(input, round_to=9), expected) + def test_timestr_to_secs_with_timer_string(self): for inp, exp in [('00:00:00', 0), ('00:00:01', 1), From 3dae53c627f45e656941e000cc9f0c163257ef54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 8 Oct 2022 16:14:40 +0300 Subject: [PATCH 0248/1592] Always support Boolean conversion with 'True/False' strings. Now they are supported even if English would be disabled (which isn't yet possible during execution). They were left to the En class as examples that other language classes can translate. Notice that although 'None' is listed here, it's not actually used for anything at least now. The reason is that Boolean conversion expicitly checks is the value 'None' (case-insenstively) and returns Python `None` in that case instead of `False`. --- src/robot/conf/languages.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 7a7a6e82db5..619cc7efaf5 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -34,8 +34,8 @@ def __init__(self, languages=None): } self.settings = {} self.bdd_prefixes = set() - self.true_strings = {'1'} - self.false_strings = {'0', 'NONE', ''} + self.true_strings = {'True', '1'} + self.false_strings = {'False', '0', 'None', ''} for lang in self._get_languages(languages): self._add_language(lang) From 253af172cd413416a2ceb048f326941180276452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 10 Oct 2022 16:19:47 +0300 Subject: [PATCH 0249/1592] Refactor. - Minor doc tuning. - More generic validation method. --- src/robot/libdoc.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/robot/libdoc.py b/src/robot/libdoc.py index 9b045dd4df2..f99ef7763a7 100755 --- a/src/robot/libdoc.py +++ b/src/robot/libdoc.py @@ -76,22 +76,22 @@ -f --format HTML|XML|JSON|LIBSPEC Specifies whether to generate an HTML output for humans or a machine readable spec file in XML or JSON - format. The `libspec` format means XML spec with + format. The LIBSPEC format means XML spec with documentations converted to HTML. The default format is got from the output file extension. -s --specdocformat RAW|HTML Specifies the documentation format used with XML and - JSON spec files. `raw` means preserving the original - documentation format and `html` means converting - documentation to HTML. The default is `raw` with XML - spec files and `html` with JSON specs and when using - the special `libspec` format. New in RF 4.0. + JSON spec files. RAW means preserving the original + documentation format and HTML means converting + documentation to HTML. The default is RAW with XML + spec files and HTML with JSON specs and when using + the special LIBSPEC format. New in RF 4.0. -F --docformat ROBOT|HTML|TEXT|REST Specifies the source documentation format. Possible values are Robot Framework's documentation format, HTML, plain text, and reStructuredText. The default value can be specified in library source code and - the initial default value is `ROBOT`. + the initial default value is ROBOT. -n --name name Sets the name of the documented library or resource. -v --version version Sets the version of the documented library or resource. @@ -195,28 +195,26 @@ def main(self, args, name='', version='', format=None, docformat=None, self.console(os.path.abspath(output)) def _get_docformat(self, docformat): - return self._validate_format('Doc format', docformat, - ['ROBOT', 'TEXT', 'HTML', 'REST']) + return self._validate('Doc format', docformat, 'ROBOT', 'TEXT', 'HTML', 'REST') def _get_format_and_specdocformat(self, format, specdocformat, output): extension = os.path.splitext(output)[1][1:] - format = self._validate_format('Format', format or extension, - ['HTML', 'XML', 'JSON', 'LIBSPEC']) - specdocformat = self._validate_format('Spec doc format', specdocformat, - ['RAW', 'HTML']) + format = self._validate('Format', format or extension, + 'HTML', 'XML', 'JSON', 'LIBSPEC') + specdocformat = self._validate('Spec doc format', specdocformat, 'RAW', 'HTML') if format == 'HTML' and specdocformat: raise DataError("The --specdocformat option is not applicable with " "HTML outputs.") return format, specdocformat - def _validate_format(self, type, format, valid): - if format is None: + def _validate(self, kind, value, *valid): + if not value: return None - format = format.upper() - if format not in valid: - raise DataError("%s must be %s, got '%s'." - % (type, seq2str(valid, lastsep=' or '), format)) - return format + value = value.upper() + if value not in valid: + raise DataError(f"{kind} must be {seq2str(valid, lastsep=' or ')}, " + f"got '{value}'.") + return value def libdoc_cli(arguments=None, exit=True): From ec2e07a8a903d6fcf536e0a5c1507102dff24a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 10 Oct 2022 17:44:52 +0300 Subject: [PATCH 0250/1592] Enhance docs of new ns/ms support with time strings. #4490 --- doc/userguide/src/Appendices/TimeFormat.rst | 8 ++++++-- src/robot/libraries/DateTime.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/userguide/src/Appendices/TimeFormat.rst b/doc/userguide/src/Appendices/TimeFormat.rst index e666ebe4e1f..03e345fd5a4 100644 --- a/doc/userguide/src/Appendices/TimeFormat.rst +++ b/doc/userguide/src/Appendices/TimeFormat.rst @@ -40,16 +40,20 @@ times. The available time specifiers are: * minutes, minute, mins, min, m * seconds, second, secs, sec, s * milliseconds, millisecond, millis, ms +* microseconds, microsecond, us, μs +* nanoseconds, nanosecond, ns Examples:: 1 min 30 secs 1.5 minutes 90 s - 1 day 2 hours 3 minutes 4 seconds 5 milliseconds - 1d 2h 3m 4s 5ms + 1 day 2 hours 3 minutes 4 seconds 5 milliseconds 6 microseconds 7 nanoseconds + 1d 2h 3m 4s 5ms 6μs 7 ns - 10 seconds +.. note:: Support for micro and nanoseconds is new in Robot Framework 6.0. + Time as "timer" string ---------------------- diff --git a/src/robot/libraries/DateTime.py b/src/robot/libraries/DateTime.py index d276f3bd980..91b2429b745 100644 --- a/src/robot/libraries/DateTime.py +++ b/src/robot/libraries/DateTime.py @@ -186,8 +186,8 @@ - ``minutes``, ``minute``, ``mins``, ``min``, ``m`` - ``seconds``, ``second``, ``secs``, ``sec``, ``s`` - ``milliseconds``, ``millisecond``, ``millis``, ``ms`` -- ``microseconds``, ``microsecond``, ``us``, ``μs`` -- ``nanoseconds``, ``nanosecond``, ``ns`` +- ``microseconds``, ``microsecond``, ``us``, ``μs`` (new in RF 6.0) +- ``nanoseconds``, ``nanosecond``, ``ns`` (new in RF 6.0) When returning a time string, it is possible to select between ``verbose`` and ``compact`` representations using ``result_format`` argument. The verbose From b2093b80c05b3d2bca50eb27b1a2103f85aa457e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 10 Oct 2022 20:31:53 +0300 Subject: [PATCH 0251/1592] Fix SetterAwareType if __slots__ is tuple. --- src/robot/utils/setter.py | 5 +++-- utest/utils/test_setter.py | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/robot/utils/setter.py b/src/robot/utils/setter.py index a8e96b8e86f..23a4a84917b 100644 --- a/src/robot/utils/setter.py +++ b/src/robot/utils/setter.py @@ -38,9 +38,10 @@ def __set__(self, instance, value): class SetterAwareType(type): def __new__(cls, name, bases, dct): - slots = dct.get('__slots__') - if slots is not None: + if '__slots__' in dct: + slots = list(dct['__slots__']) for item in dct.values(): if isinstance(item, setter): slots.append(item.attr_name) + dct['__slots__'] = slots return type.__new__(cls, name, bases, dct) diff --git a/utest/utils/test_setter.py b/utest/utils/test_setter.py index da676e521de..b45776f73fe 100644 --- a/utest/utils/test_setter.py +++ b/utest/utils/test_setter.py @@ -4,11 +4,7 @@ from robot.utils import setter, SetterAwareType -class BaseWithMeta(metaclass=SetterAwareType): - __slots__ = [] - - -class ExampleWithSlots(BaseWithMeta): +class ExampleWithSlots(metaclass=SetterAwareType): __slots__ = [] @setter @@ -54,6 +50,22 @@ def setUp(self): def test_set_other_attr(self): assert_raises(AttributeError, setattr, self.item, 'other_attr', 1) + def test_slots_as_tuple(self): + class XY(metaclass=SetterAwareType): + __slots__ = ('x',) + + def __init__(self, x, y): + self.x = x + self.y = y + + @setter + def y(self, y): + return y.upper() + + xy = XY('x', 'y') + assert_equal((xy.x, xy.y), ('x', 'Y')) + assert_raises(AttributeError, setattr, xy, 'z', 'z') + if __name__ == '__main__': unittest.main() From f6756f1fb292155f2e4aad6415a14cb53d904ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 15:11:58 +0300 Subject: [PATCH 0252/1592] Libdoc: Support setting dark/light theme explicitly. #4497 --- atest/robot/libdoc/cli.robot | 12 +++++++++- atest/robot/libdoc/invalid_usage.robot | 4 ++++ doc/userguide/src/SupportingTools/Libdoc.rst | 5 ++++ src/robot/htmldata/libdoc/libdoc.css | 24 +++++++++----------- src/robot/htmldata/libdoc/libdoc.html | 15 ++++++++++++ src/robot/libdoc.py | 16 +++++++++++-- src/robot/libdocpkg/htmlwriter.py | 16 ++++++++----- src/robot/libdocpkg/model.py | 17 ++++++++------ src/robot/libdocpkg/writer.py | 4 ++-- 9 files changed, 82 insertions(+), 31 deletions(-) diff --git a/atest/robot/libdoc/cli.robot b/atest/robot/libdoc/cli.robot index bbd9f5ed867..47d18c5aec2 100644 --- a/atest/robot/libdoc/cli.robot +++ b/atest/robot/libdoc/cli.robot @@ -56,6 +56,11 @@ Missing destination subdirectory is created Quiet --quiet String ${OUTHTML} HTML String quiet=True +Theme + --theme DARK String ${OUTHTML} HTML String theme=dark + --theme light String ${OUTHTML} HTML String theme=light + --theme NoNe String ${OUTHTML} HTML String theme= + Relative path with Python libraries [Template] NONE ${dir in libdoc exec dir}= Normalize Path ${ROBOTPATH}/../TempDirInExecDir @@ -81,10 +86,15 @@ Non-existing resource *** Keywords *** Run Libdoc And Verify Created Output File - [Arguments] ${args} ${format} ${name} ${version}= ${path}=${OUTHTML} ${quiet}=False + [Arguments] ${args} ${format} ${name} ${version}= ${path}=${OUTHTML} ${theme}= ${quiet}=False ${stdout} = Run Libdoc ${args} Run Keyword ${format} Doc Should Have Been Created ${path} ${name} ${version} File Should Have Correct Line Separators ${path} + IF "${theme}" + File Should Contain ${path} "theme": "${theme}" + ELSE + File Should Not Contain ${path} "theme": + END IF not ${quiet} Path to output should be in stdout ${path} ${stdout.rstrip()} ELSE diff --git a/atest/robot/libdoc/invalid_usage.robot b/atest/robot/libdoc/invalid_usage.robot index 7071e61919b..062099b59fa 100644 --- a/atest/robot/libdoc/invalid_usage.robot +++ b/atest/robot/libdoc/invalid_usage.robot @@ -35,6 +35,10 @@ Invalid doc format Invalid doc format in library ${TESTDATADIR}/DocFormatInvalid.py ${OUT HTML} Invalid documentation format 'INVALID'. +Invalid theme + --theme bad String ${OUT XML} Theme must be 'DARK', 'LIGHT' or 'NONE', got 'BAD'. + --theme light --format xml String ${OUT XML} The --theme option is only applicable with HTML outputs. + Non-existing library NonExistingLib ${OUT HTML} Importing library 'NonExistingLib' failed: * diff --git a/doc/userguide/src/SupportingTools/Libdoc.rst b/doc/userguide/src/SupportingTools/Libdoc.rst index 7a6548f5351..59d6c371b44 100644 --- a/doc/userguide/src/SupportingTools/Libdoc.rst +++ b/doc/userguide/src/SupportingTools/Libdoc.rst @@ -65,6 +65,11 @@ Options HTML, plain text, and reStructuredText. Default value can be specified in test library source code and the initial default value is `robot`. + --theme <dark|light|none> + Use dark or light HTML theme. If this option is not used, + or the value is `none`, the theme is selected based on + the browser color scheme. Only applicable with HTML outputs. + New in Robot Framework 6.0. -N, --name <newname> Sets the name of the documented library or resource. -V, --version <newversion> Sets the version of the documented library or resource. The default value for test libraries is diff --git a/src/robot/htmldata/libdoc/libdoc.css b/src/robot/htmldata/libdoc/libdoc.css index 1c9aa8028ad..1eb8e41b229 100644 --- a/src/robot/htmldata/libdoc/libdoc.css +++ b/src/robot/htmldata/libdoc/libdoc.css @@ -10,19 +10,17 @@ --link-color: #0000ee; } -@media (prefers-color-scheme: dark) { - :root { - --background-color: #1c2227; - --text-color: #e2e1d7; - --border-color: #4e4e4e; - --light-background-color: #002b36; - --robot-highlight: yellow; - --highlighted-color: var(--background-color); - --highlighted-background-color: yellow; - --less-important-text-color: #5b6a6f; - --link-color: #52adff; - color-scheme: dark; - } +[data-theme="dark"] { + --background-color: #1c2227; + --text-color: #e2e1d7; + --border-color: #4e4e4e; + --light-background-color: #002b36; + --robot-highlight: yellow; + --highlighted-color: var(--background-color); + --highlighted-background-color: yellow; + --less-important-text-color: #5b6a6f; + --link-color: #52adff; + color-scheme: dark; } body { diff --git a/src/robot/htmldata/libdoc/libdoc.html b/src/robot/htmldata/libdoc/libdoc.html index c805b12c410..9c9bd32bfad 100644 --- a/src/robot/htmldata/libdoc/libdoc.html +++ b/src/robot/htmldata/libdoc/libdoc.html @@ -55,6 +55,7 @@ <h1>Opening library documentation failed</h1> parseTemplates(); document.title = libdoc.name; storage.init('libdoc'); + setTheme(); renderTemplate('base', libdoc, $('body')); if (libdoc.inits.length) { libdoc.typedocs.map(function(type) { @@ -88,6 +89,20 @@ <h1>Opening library documentation failed</h1> createModal(); }); + function setTheme(theme) { + document.documentElement.setAttribute('data-theme', theme || getTheme()); + } + + function getTheme() { + if (libdoc['theme']) { + return libdoc['theme']; + } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } else { + return 'light;' + } + } + function parseTemplates() { $('script[type="text/x-jquery-tmpl"]').map(function (idx, elem) { $.template(elem.id, elem.text); diff --git a/src/robot/libdoc.py b/src/robot/libdoc.py index f99ef7763a7..4df5b93fec4 100755 --- a/src/robot/libdoc.py +++ b/src/robot/libdoc.py @@ -92,6 +92,10 @@ HTML, plain text, and reStructuredText. The default value can be specified in library source code and the initial default value is ROBOT. + --theme DARK|LIGHT|NONE + Use dark or light HTML theme. If this option is not + used, or the value is NONE, the theme is selected + based on the browser color scheme. New in RF 6.0. -n --name name Sets the name of the documented library or resource. -v --version version Sets the version of the documented library or resource. @@ -175,7 +179,7 @@ def validate(self, options, arguments): return options, arguments def main(self, args, name='', version='', format=None, docformat=None, - specdocformat=None, pythonpath=None, quiet=False): + specdocformat=None, theme=None, pythonpath=None, quiet=False): if pythonpath: sys.path = pythonpath + sys.path lib_or_res, output = args[:2] @@ -190,7 +194,7 @@ def main(self, args, name='', version='', format=None, docformat=None, or specdocformat == 'HTML' or format in ('JSON', 'LIBSPEC') and specdocformat != 'RAW'): libdoc.convert_docs_to_html() - libdoc.save(output, format) + libdoc.save(output, format, self._validate_theme(theme, format)) if not quiet: self.console(os.path.abspath(output)) @@ -216,6 +220,14 @@ def _validate(self, kind, value, *valid): f"got '{value}'.") return value + def _validate_theme(self, theme, format): + theme = self._validate('Theme', theme, 'DARK', 'LIGHT', 'NONE') + if not theme or theme == 'NONE': + return None + if format != 'HTML': + raise DataError("The --theme option is only applicable with HTML outputs.") + return theme + def libdoc_cli(arguments=None, exit=True): """Executes Libdoc similarly as from the command line. diff --git a/src/robot/libdocpkg/htmlwriter.py b/src/robot/libdocpkg/htmlwriter.py index e17951d4984..7c87388d873 100644 --- a/src/robot/libdocpkg/htmlwriter.py +++ b/src/robot/libdocpkg/htmlwriter.py @@ -18,19 +18,23 @@ class LibdocHtmlWriter: + def __init__(self, theme=None): + self.theme = theme + def write(self, libdoc, output): - model_writer = LibdocModelWriter(output, libdoc) + model_writer = LibdocModelWriter(output, libdoc, self.theme) HtmlFileWriter(output, model_writer).write(LIBDOC) class LibdocModelWriter(ModelWriter): - def __init__(self, output, libdoc): + def __init__(self, output, libdoc, theme=None): self.output = output self.libdoc = libdoc + self.theme = theme def write(self, line): - self.output.write('<script type="text/javascript">\n' - 'libdoc = %s\n' - '</script>\n' - % self.libdoc.to_json(include_private=False)) + data = self.libdoc.to_json(include_private=False, theme=self.theme) + self.output.write(f'<script type="text/javascript">\n' + f'libdoc = {data}\n' + f'</script>\n') diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 3f435b82f82..efa17b4f6bf 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -86,9 +86,9 @@ def _process_keywords(self, kws): def all_tags(self): return Tags(chain.from_iterable(kw.tags for kw in self.keywords)) - def save(self, output=None, format='HTML'): + def save(self, output=None, format='HTML', theme=None): with LibdocOutput(output, format) as outfile: - LibdocWriter(format).write(self, outfile) + LibdocWriter(format, theme).write(self, outfile) def convert_docs_to_html(self): formatter = DocFormatter(self.keywords, self.type_docs, self.doc, self.doc_format) @@ -108,8 +108,8 @@ def convert_docs_to_html(self): type_doc.doc = formatter.html(type_doc.doc) self.doc_format = 'HTML' - def to_dictionary(self, include_private=False): - return { + def to_dictionary(self, include_private=False, theme=None): + data = { 'specversion': 1, 'name': self.name, 'doc': self.doc, @@ -123,11 +123,14 @@ def to_dictionary(self, include_private=False): 'tags': list(self.all_tags), 'inits': [init.to_dictionary() for init in self.inits], 'keywords': [kw.to_dictionary() for kw in self.keywords - if include_private or not kw.private], + if include_private or not kw.private], # 'dataTypes' was deprecated in RF 5, 'typedoc' should be used instead. 'dataTypes': self._get_data_types(self.type_docs), 'typedocs': [t.to_dictionary() for t in sorted(self.type_docs)] } + if theme: + data['theme'] = theme.lower() + return data def _get_data_types(self, types): enums = sorted(t for t in types if t.type == 'Enum') @@ -137,8 +140,8 @@ def _get_data_types(self, types): 'typedDicts': [t.to_dictionary(legacy=True) for t in typed_dicts] } - def to_json(self, indent=None, include_private=True): - data = self.to_dictionary(include_private) + def to_json(self, indent=None, include_private=True, theme=None): + data = self.to_dictionary(include_private, theme) return json.dumps(data, indent=indent) diff --git a/src/robot/libdocpkg/writer.py b/src/robot/libdocpkg/writer.py index f1f4cf76e07..3ed0bba8f3f 100644 --- a/src/robot/libdocpkg/writer.py +++ b/src/robot/libdocpkg/writer.py @@ -20,10 +20,10 @@ from .jsonwriter import LibdocJsonWriter -def LibdocWriter(format=None): +def LibdocWriter(format=None, theme=None): format = (format or 'HTML') if format == 'HTML': - return LibdocHtmlWriter() + return LibdocHtmlWriter(theme) if format == 'XML': return LibdocXmlWriter() if format == 'LIBSPEC': From a33cb589d709de2d8e5e3293ea12d5cc0963ff8a Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Tue, 11 Oct 2022 14:18:09 +0200 Subject: [PATCH 0253/1592] Add Hindi (#4506) #4390 Kindly provided by @bharat.2001 --- src/robot/conf/languages.py | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 619cc7efaf5..e38446ee2e1 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -1089,3 +1089,44 @@ class It(Language): but_prefix = {'Ma'} true_strings = {'Vero', 'Sì', 'On'} false_strings = {'Falso', 'No', 'Off', 'Nessuno'} + + +class Hi(Language): + """Hindi""" + settings_header = 'स्थापना' + variables_header = 'चर' + test_cases_header = 'नियत कार्य प्रवेशिका' + tasks_header = 'कार्य प्रवेशिका' + keywords_header = 'कुंजीशब्द' + comments_header = 'टिप्पणी' + library_setting = 'कोड़ प्रतिबिंब संग्रह' + resource_setting = 'संसाधन' + variables_setting = 'चर' + documentation_setting = 'प्रलेखन' + metadata_setting = 'अधि-आंकड़ा' + suite_setup_setting = 'जांच की शुरुवात' + suite_teardown_setting = 'परीक्षण कार्य अंत' + test_setup_setting = 'परीक्षण कार्य प्रारंभ' + test_teardown_setting = 'परीक्षण कार्य अंत' + test_template_setting = 'परीक्षण ढांचा' + test_timeout_setting = 'परीक्षण कार्य समय समाप्त' + test_tags_setting = 'जाँचका उपनाम' + task_setup_setting = 'परीक्षण कार्य प्रारंभ' + task_teardown_setting = 'परीक्षण कार्य अंत' + task_template_setting = 'परीक्षण ढांचा' + task_timeout_setting = 'कार्य समयबाह्य' + task_tags_setting = 'कार्यका उपनाम' + keyword_tags_setting = 'कुंजीशब्द का उपनाम' + tags_setting = 'निशान' + setup_setting = 'व्यवस्थापना' + teardown_setting = 'विमोचन' + template_setting = 'साँचा' + timeout_setting = 'समय समाप्त' + arguments_setting = 'प्राचल' + given_prefix = {'दिया हुआ'} + when_prefix = {'जब'} + then_prefix = {'तब'} + and_prefix = {'और'} + but_prefix = {'परंतु'} + true_strings = {'यथार्थ', 'निश्चित', 'हां', 'पर'} + false_strings = {'गलत', 'नहीं', 'हालाँकि', 'यद्यपि', 'नहीं', 'हैं'} From 09ca68f431c513bded1077d0dcbd94976bc0297b Mon Sep 17 00:00:00 2001 From: Daniel Biehl <7069968+d-biehl@users.noreply.github.com> Date: Tue, 11 Oct 2022 14:50:47 +0200 Subject: [PATCH 0254/1592] =?UTF-8?q?Enhancements=20to=20public=20`robot.a?= =?UTF-8?q?pi.Languages=C2=B4=20API=20(#4496)=20#4494?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atest/robot/parsing/translations.robot | 9 ++- .../translations/custom/custom_per_file.robot | 61 ++++++++++++++++++ src/robot/conf/languages.py | 62 +++++++++++++------ utest/api/orcish_languages.py | 9 +++ utest/api/test_languages.py | 31 ++++++++++ utest/parsing/test_lexer.py | 2 +- utest/parsing/test_model.py | 2 +- 7 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 atest/testdata/parsing/translations/custom/custom_per_file.robot create mode 100644 utest/api/orcish_languages.py diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index 5c178405264..1612736a4df 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -20,9 +20,13 @@ Custom Validate Translations Custom task aliases - Run Tests --lang ${DATADIR}/parsing/translations/custom/custom.py --rpa parsing/translations/custom + Run Tests --lang ${DATADIR}/parsing/translations/custom/custom.py --rpa parsing/translations/custom/tasks.robot Validate Task Translations +Custom Per file configuration + Run Tests -P ${DATADIR}/parsing/translations/custom parsing/translations/custom/custom_per_file.robot + Validate Translations + Invalid ${result} = Run Tests Without Processing Output --lang bad parsing/finnish.robot Should Be Equal ${result.rc} ${252} @@ -44,8 +48,9 @@ Per file configuration with multiple languages Should Be Equal ${tc.doc} приклад Invalid per file configuration + Run Tests ${EMPTY} parsing/translations/per_file_config/many.robot Error in file 0 parsing/translations/per_file_config/many.robot 4 - ... Invalid language configuration: No language with name 'invalid' found. + ... Invalid language configuration: Language "invalid" not found nor importable as a module. Per file configuration bleeds to other files [Documentation] This is a technical limitation and will hopefully change! diff --git a/atest/testdata/parsing/translations/custom/custom_per_file.robot b/atest/testdata/parsing/translations/custom/custom_per_file.robot new file mode 100644 index 00000000000..3b2be0ed403 --- /dev/null +++ b/atest/testdata/parsing/translations/custom/custom_per_file.robot @@ -0,0 +1,61 @@ +language: custom +*** H S *** +D Suite documentation. +M Metadata Value +S S Suite Setup +S T Suite Teardown +T S Test Setup +T Tea Test Teardown +t tem Test Template +T ti 1 minute +t Ta test tags +k T keyword tags +L OperatingSystem +R resource.resource +V ../../variables.py + +*** h v *** +${VARIABLE} variable value + +*** H TE *** +Test without settings + Nothing to see here + +Test with settings + [D] Test documentation. + [Ta] own tag + [S] NONE + [tea] NONE + [tEm] NONE + [ti] NONE + Keyword ${VARIABLE} + +*** h K *** +Suite Setup + Directory Should Exist ${CURDIR} + +Suite Teardown + Keyword In Resource + +Test Setup + Should Be Equal ${VARIABLE} variable value + Should Be Equal ${RESOURCE FILE} variable in resource file + Should Be Equal ${VARIABLE FILE} variable in variable file + +Test Teardown + No Operation + +Test Template + [A] ${message} + Log ${message} + +Keyword + [d] Keyword documentation. + [a] ${arg} + [ta] own tag + [tI] 1h + Should Be Equal ${arg} ${VARIABLE} + [TEA] No Operation + +*** H C *** +Ignored comments. diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index e38446ee2e1..480be9df99c 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -16,34 +16,49 @@ import inspect import os.path +from robot.errors import DataError from robot.utils import is_list_like, Importer, normalize class Languages: + """Keeps a list of languages and unifies the translations in the properties. - def __init__(self, languages=None): + :param languages: a language or a list of languages. + Can be the name, the code or an instance. + :type languages: str, class: Language, list[str, class: Language], optional + :param add_default: if True the default language (En) and some aliases is (Default: True) + :type add_default: bool, optional + + Example:: + + languages = Languages(["de"]) + print(languages.settings) + """ + + def __init__(self, languages=None, add_english=True): self.languages = [] - # The English singular forms are added for backwards compatibility - self.headers = { - 'Setting': 'Settings', - 'Variable': 'Variables', - 'Test Case': 'Test Cases', - 'Task': 'Tasks', - 'Keyword': 'Keywords', - 'Comment': 'Comments' - } + self.headers = {} self.settings = {} self.bdd_prefixes = set() self.true_strings = {'True', '1'} self.false_strings = {'False', '0', 'None', ''} - for lang in self._get_languages(languages): + for lang in self._get_languages(languages, add_english): self._add_language(lang) - def reset(self, languages=None): - self.__init__(languages) + def reset(self, languages=None, add_english=True): + """Resets the instance to the given languages.""" + self.__init__(languages, add_english) def add_language(self, name): - self._add_language(Language.from_name(name)) + try: + languages = [Language.from_name(name)] + except ValueError: + try: + languages = self._import_languages(name) + except DataError: + raise ValueError(f'Language "{name}" not found nor importable as a module.') + for lang in languages: + self._add_language(lang) def _add_language(self, lang): if lang in self.languages: @@ -55,8 +70,8 @@ def _add_language(self, lang): self.true_strings |= {s.title() for s in lang.true_strings} self.false_strings |= {s.title() for s in lang.false_strings} - def _get_languages(self, languages): - languages = self._resolve_languages(languages) + def _get_languages(self, languages, add_english=True): + languages = self._resolve_languages(languages, add_english) available = self._get_available_languages() returned = [] for lang in languages: @@ -70,14 +85,24 @@ def _get_languages(self, languages): returned.extend(self._import_languages(lang)) return returned - def _resolve_languages(self, languages): + def _resolve_languages(self, languages, add_english=True): if not languages: languages = [] elif is_list_like(languages): languages = list(languages) else: languages = [languages] - languages.append(En()) + if add_english: + languages.append(En()) + # The English singular forms are added for backwards compatibility + self.headers = { + 'Setting': 'Settings', + 'Variable': 'Variables', + 'Test Case': 'Test Cases', + 'Task': 'Tasks', + 'Keyword': 'Keywords', + 'Comment': 'Comments' + } return languages def _get_available_languages(self): @@ -98,6 +123,7 @@ def is_language(member): module = Importer('language file').import_module(lang) return [value() for _, value in inspect.getmembers(module, is_language)] + def __iter__(self): return iter(self.languages) diff --git a/utest/api/orcish_languages.py b/utest/api/orcish_languages.py new file mode 100644 index 00000000000..62b30d5b15e --- /dev/null +++ b/utest/api/orcish_languages.py @@ -0,0 +1,9 @@ +from robot.api import Language + +class OrcQui(Language): + """Orcish Quiet""" + settings_header="Jiivo" + +class OrcLou(Language): + """Orcish Loud""" + settings_header="JIIVA" diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index 1cc4d59dafd..e34175b7942 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -1,5 +1,7 @@ import unittest +from os.path import abspath, dirname, join + from robot.api import Language, Languages from robot.conf.languages import En, Fi, PtBr, Th from robot.utils.asserts import assert_equal, assert_not_equal, assert_raises_with_msg @@ -85,6 +87,12 @@ def test_init(self): assert_equal(list(Languages(['fi'])), [Fi(), En()]) assert_equal(list(Languages(['fi', PtBr()])), [Fi(), PtBr(), En()]) + def test_init_without_default(self): + assert_equal(list(Languages(add_english=False)), []) + assert_equal(list(Languages('fi', add_english=False)), [Fi()]) + assert_equal(list(Languages(['fi'], add_english=False)), [Fi()]) + assert_equal(list(Languages(['fi', PtBr()], add_english=False)), [Fi(), PtBr()]) + def test_reset(self): langs = Languages(['fi']) langs.reset() @@ -94,6 +102,15 @@ def test_reset(self): langs.reset(['fi', PtBr()]) assert_equal(list(langs), [Fi(), PtBr(), En()]) + def test_reset_with_default(self): + langs = Languages(['fi']) + langs.reset(add_english=False) + assert_equal(list(langs), []) + langs.reset('fi', add_english=False) + assert_equal(list(langs), [Fi()]) + langs.reset(['fi', PtBr()], add_english=False) + assert_equal(list(langs), [Fi(), PtBr()]) + def test_duplicates_are_not_added(self): langs = Languages(['Finnish', 'en', Fi(), 'pt-br']) assert_equal(list(langs), [Fi(), En(), PtBr()]) @@ -102,6 +119,20 @@ def test_duplicates_are_not_added(self): langs.add_language('th') assert_equal(list(langs), [Fi(), En(), PtBr(), Th()]) + def test_add_language_with_custom_module(self): + data = join(abspath(dirname(__file__)), 'orcish_languages.py') + langs = Languages() + langs.add_language(data) + self.assertIn(("Orcish Loud", "or-CLOU"), [(v.name, v.code) for v in langs]) + self.assertIn(("Orcish Quiet", "or-CQUI"), [(v.name, v.code) for v in langs]) + + def test_add_language_with_invalid_custom_module(self): + langs = Languages() + with self.assertRaises(ValueError) as context: + langs.add_language("invalid") + self.assertTrue('Language "invalid" not found nor importable as a module.' in context.exception.args) + + if __name__ == '__main__': unittest.main() diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 34ced00af38..5515ee359bf 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -2290,7 +2290,7 @@ def test_invalid_per_file_config(self): ''' expected = [ (T.ERROR, 'language: in:va:lid', 1, 0, - "Invalid language configuration: No language with name 'in:va:lid' found."), + 'Invalid language configuration: Language "in:va:lid" not found nor importable as a module.'), (T.EOL, '\n', 1, 19), (T.EOS, '', 1, 20), (T.COMMENT, 'language: bad again', 2, 0), diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 2820618c790..0464937a40b 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1287,7 +1287,7 @@ def test_valid(self): ]), Error([ Token('ERROR', 'language: bad', 2, 0, - "Invalid language configuration: No language with name 'bad' found."), + 'Invalid language configuration: Language "bad" not found nor importable as a module.'), Token('EOL', '\n', 2, 13) ]), Comment([ From 7b6d8197600862e142a9cafa499bcc75e7027772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 17:26:58 +0300 Subject: [PATCH 0255/1592] Some further enhancements to public robot.api.Languages API. - More doc strings. - Change exception used by `Languages.add_language` on error to match exception used when initializing `Languages`. - Make `Language.code/name` propertys accessible also from class. This required implementing new `@classproperty` decorator. This is part of issue #4494 and the work was started in #4496. --- atest/robot/parsing/translations.robot | 3 +- src/robot/conf/languages.py | 59 +++++++++++------- src/robot/parsing/lexer/statementlexers.py | 10 ++-- src/robot/utils/__init__.py | 4 +- src/robot/utils/misc.py | 29 ++++++++- utest/api/test_languages.py | 28 ++++++--- utest/parsing/test_lexer.py | 4 +- utest/parsing/test_model.py | 5 +- utest/utils/test_misc.py | 70 +++++++++++++++++++++- 9 files changed, 167 insertions(+), 45 deletions(-) diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index 1612736a4df..d7b8cea5edb 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -50,7 +50,8 @@ Per file configuration with multiple languages Invalid per file configuration Run Tests ${EMPTY} parsing/translations/per_file_config/many.robot Error in file 0 parsing/translations/per_file_config/many.robot 4 - ... Invalid language configuration: Language "invalid" not found nor importable as a module. + ... Invalid language configuration: + ... Language 'invalid' not found nor importable as a language module. Per file configuration bleeds to other files [Documentation] This is a technical limitation and will hopefully change! diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 480be9df99c..16f723c03b8 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -17,25 +17,30 @@ import os.path from robot.errors import DataError -from robot.utils import is_list_like, Importer, normalize +from robot.utils import classproperty, is_list_like, Importer, normalize class Languages: """Keeps a list of languages and unifies the translations in the properties. - :param languages: a language or a list of languages. - Can be the name, the code or an instance. - :type languages: str, class: Language, list[str, class: Language], optional - :param add_default: if True the default language (En) and some aliases is (Default: True) - :type add_default: bool, optional - Example:: - languages = Languages(["de"]) + languages = Languages('de', add_english=False) print(languages.settings) + languages = Languages(['pt-BR', 'Finnish', 'MyLang.py']) + print(list(languages)) """ def __init__(self, languages=None, add_english=True): + """ + :param languages: Initial language or list of languages. + Languages can be given as language codes or names, paths or names of + language modules to load, or as :class:`Language` instances. + :param add_english: If True, English is added automatically. + :raises :class:`~robot.errors.DataError` if a given language is not found. + + :meth:`add.language` can be used to add languages after initialization. + """ self.languages = [] self.headers = {} self.settings = {} @@ -50,13 +55,23 @@ def reset(self, languages=None, add_english=True): self.__init__(languages, add_english) def add_language(self, name): + """Add new language. + + :param name: Name or code of a language to add, or name or path of + a language module to load. + :raises: :class:`~robot.errors.DataError` if the language is not found. + + Language codes and names are passed to by :meth:`Language.from_name`. + Language modules are imported and :class:`Language` subclasses in them + loaded. + """ try: languages = [Language.from_name(name)] - except ValueError: + except ValueError as err1: try: languages = self._import_languages(name) - except DataError: - raise ValueError(f'Language "{name}" not found nor importable as a module.') + except DataError as err2: + raise DataError(f'{err1} {err2}') for lang in languages: self._add_language(lang) @@ -108,9 +123,10 @@ def _resolve_languages(self, languages, add_english=True): def _get_available_languages(self): available = {} for lang in Language.__subclasses__(): - available[normalize(lang.__name__)] = lang - if lang.__doc__: - available[normalize(lang.__doc__.splitlines()[0])] = lang + available[normalize(lang.code, ignore='-')] = lang + available[normalize(lang.name)] = lang + if '' in available: + available.pop('') return available def _import_languages(self, lang): @@ -123,7 +139,6 @@ def is_language(member): module = Importer('language file').import_module(lang) return [value() for _, value in inspect.getmembers(module, is_language)] - def __iter__(self): return iter(self.languages) @@ -193,26 +208,26 @@ def from_name(cls, name): return lang() raise ValueError(f"No language with name '{name}' found.") - @property - def code(self): + @classproperty + def code(cls): """Language code like 'fi' or 'pt-BR'. Got based on the class name. If the class name is two characters (or less), the code is just the name in lower case. If it is longer, a hyphen is added remainder of the class name is upper-cased. """ - code = type(self).__name__.lower() + code = cls.__name__.lower() if len(code) < 3: return code return f'{code[:2]}-{code[2:].upper()}' - @property - def name(self): + @classproperty + def name(cls): """Language name like 'Finnish' or 'Brazilian Portuguese'. Got from the first line of the class docstring. """ - return self.__doc__.splitlines()[0] if self.__doc__ else '' + return cls.__doc__.splitlines()[0] if cls.__doc__ else '' @property def headers(self): @@ -926,7 +941,7 @@ class Tr(Language): documentation_setting = 'Dokümantasyon' metadata_setting = 'Üstveri' suite_setup_setting = 'Takım Kurulumu' - suite_teardown_setting = 'Takım Bitişi' + suite_teardown_setting = 'Takım Bitişi' test_setup_setting = 'Test Kurulumu' task_setup_setting = 'Görev Kurulumu' test_teardown_setting = 'Test Bitişi' diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 2c5f4334606..7f5bcdce00c 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -13,8 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import re - +from robot.errors import DataError from robot.utils import normalize_whitespace from robot.variables import is_assign @@ -122,8 +121,11 @@ def input(self, statement): lang = statement[0].value.split(':', 1)[1].strip() try: self.ctx.add_language(lang) - except ValueError as err: - statement[0].set_error(f'Invalid language configuration: {err}') + except DataError: + statement[0].set_error( + f"Invalid language configuration: " + f"Language '{lang}' not found nor importable as a language module." + ) else: statement[0].type = Token.CONFIG diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index c73c034a712..a217483b703 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -49,8 +49,8 @@ from .markupwriters import HtmlWriter, XmlWriter, NullMarkupWriter from .importer import Importer from .match import eq, Matcher, MultiMatcher -from .misc import (isatty, parse_re_flags, plural_or_not, printable_name,seq2str, - seq2str2, test_or_task) +from .misc import (classproperty, isatty, parse_re_flags, plural_or_not, + printable_name, seq2str, seq2str2, test_or_task) from .normalizing import normalize, normalize_whitespace, NormalizedDict from .platform import PY_VERSION, PYPY, UNIXY, WINDOWS, RERAISED_EXCEPTIONS from .recommendations import RecommendationFinder diff --git a/src/robot/utils/misc.py b/src/robot/utils/misc.py index 7ee9c93b06d..5dd207b227d 100644 --- a/src/robot/utils/misc.py +++ b/src/robot/utils/misc.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from operator import add, sub import re from .robottypes import is_integer @@ -125,6 +124,7 @@ def isatty(stream): except ValueError: # Occurs if file is closed. return False + def parse_re_flags(flags=None): result = 0 if not flags: @@ -140,3 +140,30 @@ def parse_re_flags(flags=None): else: raise ValueError(f'Unknown regexp flag: {flag}') return result + + +class classproperty(property): + """Property that works with classes in addition to instances. + + Only supports getters. Setters and deleters cannot work with classes due + to how the descriptor protocol works, and they are thus explicitly disabled. + Metaclasses must be used if they are needed. + """ + + def __init__(self, fget, fset=None, fdel=None, doc=None): + if fset: + self.setter(fset) + if fdel: + self.deleter(fset) + super().__init__(fget) + if doc: + self.__doc__ = doc + + def __get__(self, instance, owner): + return self.fget(owner) + + def setter(self, fset): + raise TypeError('Setters are not supported.') + + def deleter(self, fset): + raise TypeError('Deleters are not supported.') diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index e34175b7942..d30a278de9b 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -4,20 +4,26 @@ from robot.api import Language, Languages from robot.conf.languages import En, Fi, PtBr, Th -from robot.utils.asserts import assert_equal, assert_not_equal, assert_raises_with_msg +from robot.errors import DataError +from robot.utils.asserts import (assert_equal, assert_not_equal, assert_true, + assert_raises_with_msg) class TestLanguage(unittest.TestCase): def test_one_part_code(self): assert_equal(Fi().code, 'fi') + assert_equal(Fi.code, 'fi') def test_two_part_code(self): assert_equal(PtBr().code, 'pt-BR') + assert_equal(PtBr.code, 'pt-BR') def test_name(self): assert_equal(Fi().name, 'Finnish') + assert_equal(Fi.name, 'Finnish') assert_equal(PtBr().name, 'Brazilian Portuguese') + assert_equal(PtBr.name, 'Brazilian Portuguese') def test_name_with_multiline_docstring(self): class X(Language): @@ -26,18 +32,21 @@ class X(Language): Other lines are ignored. """ assert_equal(X().name, 'Language Name') + assert_equal(X.name, 'Language Name') def test_name_without_docstring(self): class X(Language): pass X.__doc__ = None assert_equal(X().name, '') + assert_equal(X.name, '') def test_all_standard_languages_have_code_and_name(self): for cls in Language.__subclasses__(): - lang = cls() - assert lang.code - assert lang.name + assert cls().code + assert cls.code + assert cls().name + assert cls.name def test_eq(self): assert_equal(Fi(), Fi()) @@ -127,11 +136,12 @@ def test_add_language_with_custom_module(self): self.assertIn(("Orcish Quiet", "or-CQUI"), [(v.name, v.code) for v in langs]) def test_add_language_with_invalid_custom_module(self): - langs = Languages() - with self.assertRaises(ValueError) as context: - langs.add_language("invalid") - self.assertTrue('Language "invalid" not found nor importable as a module.' in context.exception.args) - + with self.assertRaises(DataError) as context: + Languages().add_language('invalid') + assert_true(context.exception.args[0].startswith( + "No language with name 'invalid' found. " + "Importing language file 'invalid' failed: " + )) if __name__ == '__main__': diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 5515ee359bf..e65be27b54e 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -2290,7 +2290,8 @@ def test_invalid_per_file_config(self): ''' expected = [ (T.ERROR, 'language: in:va:lid', 1, 0, - 'Invalid language configuration: Language "in:va:lid" not found nor importable as a module.'), + "Invalid language configuration: Language 'in:va:lid' not found " + "nor importable as a language module."), (T.EOL, '\n', 1, 19), (T.EOS, '', 1, 20), (T.COMMENT, 'language: bad again', 2, 0), @@ -2316,5 +2317,6 @@ def test_invalid_per_file_config(self): assert_equal(lang.languages, [Language.from_name(lang) for lang in ('en', 'fi')]) + if __name__ == '__main__': unittest.main() diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 0464937a40b..19e8ee03e88 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1268,7 +1268,7 @@ def visit_Block(self, node): class TestLanguageConfig(unittest.TestCase): - def test_valid(self): + def test_config(self): model = get_model('''\ language: fi language: bad @@ -1287,7 +1287,8 @@ def test_valid(self): ]), Error([ Token('ERROR', 'language: bad', 2, 0, - 'Invalid language configuration: Language "bad" not found nor importable as a module.'), + "Invalid language configuration: Language 'bad' " + "not found nor importable as a language module."), Token('EOL', '\n', 2, 13) ]), Comment([ diff --git a/utest/utils/test_misc.py b/utest/utils/test_misc.py index da02d44c5dd..c6cfb2525d1 100644 --- a/utest/utils/test_misc.py +++ b/utest/utils/test_misc.py @@ -1,9 +1,9 @@ import re import unittest -from robot.utils import (parse_re_flags, plural_or_not, printable_name, +from robot.utils import (classproperty, parse_re_flags, plural_or_not, printable_name, seq2str, test_or_task) -from robot.utils.asserts import assert_equal, assert_raises_with_msg +from robot.utils.asserts import assert_equal, assert_raises, assert_raises_with_msg class TestSeg2Str(unittest.TestCase): @@ -96,7 +96,6 @@ def test_plural_or_not(self): assert_equal(plural_or_not(plural), 's') - class TestTestOrTask(unittest.TestCase): def test_no_match(self): @@ -148,5 +147,70 @@ def test_parse_negative(self): assert_raises_with_msg(ValueError, exp_msg, parse_re_flags, inp) +class TestClassProperty(unittest.TestCase): + + def setUp(self): + class Class: + @classproperty + def p(cls): + assert cls is Class + return 1 + self.cls = Class + + def test_get_from_class(self): + assert self.cls.p == 1 + + def test_get_from_instance(self): + assert self.cls().p == 1 + + def test_set_in_class_overrides(self): + # This cannot be avoided without using metaclasses. + self.cls.p = 2 + assert self.cls.p == 2 + assert self.cls().p == 2 + + def test_set_in_instance_fails(self): + assert_raises(AttributeError, setattr, self.cls(), 'p', 2) + + def test_cannot_have_setter(self): + code = ''' +class Class: + @classproperty + def p(cls): + pass + @p.setter + def p(cls): + pass +''' + assert_raises_with_msg(TypeError, 'Setters are not supported.', + exec, code, globals()) + assert_raises_with_msg(TypeError, 'Setters are not supported.', + classproperty, lambda c: None, lambda c, v: None) + + def test_cannot_have_deleter(self): + code = ''' +class Class: + @classproperty + def p(cls): + pass + @p.deleter + def p(cls): + pass +''' + assert_raises_with_msg(TypeError, 'Deleters are not supported.', + exec, code, globals()) + assert_raises_with_msg(TypeError, 'Deleters are not supported.', + classproperty, lambda c: None, None, lambda c, v: None) + + def test_doc(self): + class Class(self.cls): + @classproperty + def p(cls): + """Doc for p.""" + q = classproperty(lambda cls: None, doc='Doc for q.') + assert_equal(Class.__dict__['p'].__doc__, 'Doc for p.') + assert_equal(Class.__dict__['q'].__doc__, 'Doc for q.') + + if __name__ == "__main__": unittest.main() From 493638ca04b202e3ae976316870692e2fb56b718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 20:27:20 +0300 Subject: [PATCH 0256/1592] Languages.add_language: Support input as Language instance. One more enhancement for #4494. --- src/robot/conf/languages.py | 13 ++++++++----- utest/api/test_languages.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 16f723c03b8..5ffa630a80f 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -54,11 +54,11 @@ def reset(self, languages=None, add_english=True): """Resets the instance to the given languages.""" self.__init__(languages, add_english) - def add_language(self, name): + def add_language(self, lang): """Add new language. - :param name: Name or code of a language to add, or name or path of - a language module to load. + :param lang: Language to add. Can be a language code or name, name or + path of a language module to load, or a :class:`Language` instance. :raises: :class:`~robot.errors.DataError` if the language is not found. Language codes and names are passed to by :meth:`Language.from_name`. @@ -66,10 +66,13 @@ def add_language(self, name): loaded. """ try: - languages = [Language.from_name(name)] + if isinstance(lang, Language): + languages = [lang] + else: + languages = [Language.from_name(lang)] except ValueError as err1: try: - languages = self._import_languages(name) + languages = self._import_languages(lang) except DataError as err2: raise DataError(f'{err1} {err2}') for lang in languages: diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index d30a278de9b..fcba614df7f 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -128,14 +128,14 @@ def test_duplicates_are_not_added(self): langs.add_language('th') assert_equal(list(langs), [Fi(), En(), PtBr(), Th()]) - def test_add_language_with_custom_module(self): + def test_add_language_using_custom_module(self): data = join(abspath(dirname(__file__)), 'orcish_languages.py') langs = Languages() langs.add_language(data) self.assertIn(("Orcish Loud", "or-CLOU"), [(v.name, v.code) for v in langs]) self.assertIn(("Orcish Quiet", "or-CQUI"), [(v.name, v.code) for v in langs]) - def test_add_language_with_invalid_custom_module(self): + def test_add_language_using_invalid_custom_module(self): with self.assertRaises(DataError) as context: Languages().add_language('invalid') assert_true(context.exception.args[0].startswith( @@ -143,6 +143,13 @@ def test_add_language_with_invalid_custom_module(self): "Importing language file 'invalid' failed: " )) + def test_add_language_using_Language_instance(self): + languages = Languages(add_english=False) + to_add = [Fi(), PtBr(), Th()] + for lang in to_add: + languages.add_language(lang) + assert_equal(list(languages), to_add) + if __name__ == '__main__': unittest.main() From 36e3f4e670bb25a7143b19d494bee32ae9b2d209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 20:36:43 +0300 Subject: [PATCH 0257/1592] Try 'de-flakey' test sometimes failing on CI. --- .../screenshot/set_screenshot_directory.robot | 2 ++ 1 file changed, 2 insertions(+) diff --git a/atest/testdata/standard_libraries/screenshot/set_screenshot_directory.robot b/atest/testdata/standard_libraries/screenshot/set_screenshot_directory.robot index c9e5c1cff58..0977e068196 100644 --- a/atest/testdata/standard_libraries/screenshot/set_screenshot_directory.robot +++ b/atest/testdata/standard_libraries/screenshot/set_screenshot_directory.robot @@ -1,6 +1,7 @@ *** Settings *** Suite Setup Clean Temp Files And Create Directory Test Setup Save Start Time +Test Teardown Clean Temp Files And Create Directory Suite Teardown Clean Temp Files Resource screenshot_resource.robot @@ -20,6 +21,7 @@ Set Screenshot Directory Set Screenshot Directory as `pathlib.Path` ${old} = Set Screenshot Directory ${{pathlib.Path($SCREENSHOT_DIR)}} Paths Should Be Equal ${OUTPUT DIR} ${old} + Set Suite Variable ${OUTPUT DIR} ${SCREENSHOT DIR} Take Screenshot Screenshot Should Exist ${FIRST SCREENSHOT} From 0eb5045a72e4542b914c7718f7b5f3ac1e7b1b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 21:29:58 +0300 Subject: [PATCH 0258/1592] Update Slack link --- tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index b0562a54426..2aa818f5494 100644 --- a/tasks.py +++ b/tasks.py @@ -63,7 +63,8 @@ .. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3A{version.milestone} .. _issue tracker: https://github.com/robotframework/robotframework/issues .. _robotframework-users: http://groups.google.com/group/robotframework-users -.. _Robot Framework Slack: https://robotframework-slack-invite.herokuapp.com +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ .. _installation instructions: ../../INSTALL.rst ''' From d2216ab41a3d98347c907e6c54d72a10fce8fbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 21:30:31 +0300 Subject: [PATCH 0259/1592] Release notes for 6.0rc2 --- doc/releasenotes/rf-6.0rc1.rst | 5 +- doc/releasenotes/rf-6.0rc2.rst | 894 +++++++++++++++++++++++++++++++++ 2 files changed, 898 insertions(+), 1 deletion(-) create mode 100644 doc/releasenotes/rf-6.0rc2.rst diff --git a/doc/releasenotes/rf-6.0rc1.rst b/doc/releasenotes/rf-6.0rc1.rst index 8601a2566c1..417360711a8 100644 --- a/doc/releasenotes/rf-6.0rc1.rst +++ b/doc/releasenotes/rf-6.0rc1.rst @@ -35,8 +35,11 @@ distribution from PyPI_ and install it manually. For more details and other installation approaches, see the `installation instructions`_. Robot Framework 6.0 rc 1 was released on Friday September 30, 2022. -The final release is planned to be released on Wednesday October 5, 2022 +The final release was planned to be released on Wednesday October 5, 2022, just in time for the `RoboCon Germany <https://robocon.io/germany>`_ conference. +It was, however, delayed due to us wanting to add some features that +make IDE integration easier. `Robot Framework 6.0 rc 2 <rf-6.0rc2.rst>`_ +was released on Tuesday October 11, 2022. .. _Robot Framework: http://robotframework.org .. _Robot Framework Foundation: http://robotframework.org/foundation diff --git a/doc/releasenotes/rf-6.0rc2.rst b/doc/releasenotes/rf-6.0rc2.rst new file mode 100644 index 00000000000..48220ab3c7b --- /dev/null +++ b/doc/releasenotes/rf-6.0rc2.rst @@ -0,0 +1,894 @@ +======================================= +Robot Framework 6.0 release candidate 2 +======================================= + +.. default-role:: code + +`Robot Framework`_ 6.0 is a new major release that starts Robot Framework's +localization efforts. In addition to that, it contains several nice enhancements +related to, for example, automatic argument conversion and using embedded arguments. +Robot Framework 6.0 rc 2 is the second and hopefully the last release candidate +containing all features and fixes planned to be included in the final release. + +Robot Framework 6.0 was initially labeled Robot Framework 5.1 and considered +a feature release. In the end it grow so big that we decided to make it a major +release instead. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.0rc2 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.0 rc 2 was released on Tuesday October 11, 2022. +The first release candidate did not contain problems preventing the final +release, but we wanted to add few features that make IDE integration easier +and a new release candidate was needed. The final release is planned to be +released on Tuesday October 18, 2022. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + + +Most important enhancements +=========================== + +Localization +------------ + +Robot Framework 6.0 starts our localization efforts by making it possible to translate +various markers used in the data. It is possible to translate headers (e.g. `Test Cases`) +and settings (e.g. `Documentation`) (`#4096`_), `Given/When/Then` prefixes used in BDD +(`#519`_), as well as true and false strings used in Boolean argument conversion (`#4400`_). +Future versions may allow translating syntax like `IF` and `FOR`, contents of logs and +reports, error messages, and so on. + +Languages to use are specified when starting execution using the `--language` command +line option. With languages supported by Robot Framework out-of-the-box, it is possible +to use just a language code or name like `--language fi` or `--language Finnish`. +It is also possible to create a custom language file and use it like `--language MyLang.py`. +If there is a need to support multiple languages, the `--language` option can be +used multiple times like `--language de --language uk`. + +In addition to specifying the language from the command line, it is possible to +specify it in the data file itself using `language: <lang>` syntax, where `<lang>` is +a language code or name, before the first section:: + + language: fi + + *** Asetukset *** + Dokumentaatio Example using Finnish. + +Due to technical reasons this per-file language configuration affects also parsing +subsequent files, but that behavior is likely to change and *should not* be dependent +on. Either use `language: <lang>` in each parsed file or specify the language to +use from the command line. + +Robot Framework 6.0 contains built-in support for these languages in addition +to English that is automatically supported: + +- Bosnian (bs) +- Bulgarian (bg) +- Chinese Simplified (zh-CN) and Chinese Traditional (zh-TW) +- Czech (cs) +- Dutch (nl) +- Finnish (fi) +- French (fr) +- German (de) +- Hindi (hi) +- Italian (it) +- Polish (pl) +- Portuguese (pt) and Brazilian Portuguese (pt-BR) +- Romanian (ro) +- Russian (ru) +- Spanish (es) +- Swedish (sv) +- Thai (th) +- Turkish (tr) +- Ukrainian (uk) + +All these translations have been provided by our awesome community and we hope to get +more community contributed translations in future releases. If you are interested to +help, head to Crowdin__ that we use for collaboration. For more instructions see +issue `#4390`_ and for general discussion and questions join the `#localization` +channel on our Slack_. + +__ https://robotframework.crowdin.com/robot-framework + +Enhancements to using keywords with embedded arguments +------------------------------------------------------ + +When using keywords with embedded arguments, it is pretty common that a keyword +that is used matches multiple keyword implementations. For example, +`Execute "ls" with "-lh"` in this example matches both of the keywords: + +.. sourcecode:: robotframework + + *** Test Cases *** + Automatic conflict resolution + Execute "ls" + Execute "ls" with "-lh" + + *** Keywords *** + Execute "${cmd}" + Log Running command '${cmd}'. + + Execute "${cmd}" with "${opts}" + Log Running command '${cmd}' with options '${opts}'. + +Earlier when such conflicts occurred, execution failed due to there being +multiple matching keywords. Nowadays, if there is a match that is better than +others, it will be used and the conflict is resolved. In the above example, +`Execute "${cmd}" with "${opts}"` is considered to be a better match than +the more generic `Execute "${cmd}"` and the example thus succeeds. (`#4454`_) + +There can, however, be cases where it is not possible to find a single best +match. In such cases conflicts cannot be resolved automatically and +execution fails as earlier. + +Another nice enhancement related to keywords using embedded arguments is that +if they are used with `Run Keyword` or its variants, arguments are not anymore +always converted to strings. That allows passing arguments containing other +values than strings as variables also in this context. (`#1595`_) + +Enhancements to automatic argument conversion +--------------------------------------------- + +Automatic argument conversion makes it possible for library authors to specify +what types certain arguments have and then Robot Framework automatically converts +used arguments accordingly. This support has been enhanced in various ways. + +Nowadays, if a container type like `list` is used with parameters like `list[int]`, +arguments are not only converted to the container type, but items they contain are +also converted to specified nested types (`#4433`_). This works with all containers +Robot Framework's argument conversion works in general. Most important examples +are the already mentioned lists, dictionaries like `dict[str, int]`, tuples like +`tuple[str, int, bool]` and heterogeneous tuples like `tuple[int, ...]`. Notice +that using parameters with Python's standard types `requires Python 3.9`__. With +earlier versions it is possible to use `List`, `Dict` and other such types +available in the typing__ module. + +Another container type that is nowadays handled better is TypedDict__. Earlier, +when TypedDicts were used as type hints, arguments were only converted to +dictionaries, but nowadays items are converted according to the specified +types. In addition to that, Robot Framework validates that all the specified +items are present. (`#4477`_) + +A bit smaller but still nice enhancement is that automatic conversion nowadays +works also with `pathlib.Path`__. (`#4461`_) + +__ https://peps.python.org/pep-0585/ +__ https://docs.python.org/3/library/typing.html +__ https://docs.python.org/3/library/typing.html#typing.TypedDict +__ https://docs.python.org/3/library/pathlib.html + +Enhancements for setting keyword and test tags +---------------------------------------------- + +It is now possible to set tags for all keywords in a certain file by using +the new `Keyword Tags` setting (`#4373`_). It works in resource files and also +in test case and suite initialization files. When used in initialization files, +it only affects keywords in that file and does not propagate to lower level suites. + +The `Force Tags` setting has been renamed to `Test Tags` (`#4368`_). The motivation +is to make settings related to tests more consistent (`Test Setup`, `Test Timeout`, +`Test Tags`, ...) and to better separate settings for specifying test and keyword tags. +Consistent naming also easies translations. The old `Force Tags` setting still works but it +will be `deprecated in the future`__. When creating tasks, it is possible to use +`Task Tags` alias instead of `Test Tags`. + +To simplify setting tags, the `Default Tags` setting will `also be deprecated`__. +The functionality it provides, setting tags that some but no all tests get, +will be enabled in the future by using `-tag` syntax with the `[Tags]` setting +to indicate that a test should not get tag `tag`. This syntax will then work +also in combination with the new `Keyword Tags`. For more details see `#4374`__. + +__ `Force Tags and Default Tags settings`_ +__ `Force Tags and Default Tags settings`_ +__ https://github.com/robotframework/robotframework/issues/4374 + +Enhancements to keyword namespaces +---------------------------------- + +It is possible to mark keywords in resource files as private by adding +`robot:private` tag to them (`#430`_). If such a keyword is used by keywords +outside that resource file, there will be a warning. These keywords are also +excluded from HTML library documentation generated by Libdoc. + +If a keyword exists in the same resource file as a keyword using it, it will +be used even if there would be keyword with the same name in another resource +file (`#4366`_). Earlier this situation caused a conflict. + +If a keyword exists in the same resource file as a keyword using it and there +is a keyword with the same name in the test case file, the keyword in the test +case file will be used as it has been used earlier. This behavior is nowadays +deprecated__, though, and in the future local keywords will have precedence also +in these cases. + +__ `Keywords in test case files having precedence over local keywords in resource files`_ + +Possibility to disable continue-on-failure mode +----------------------------------------------- + +Robot Framework generally stops executing a keyword or a test case if there +is a failure. Exceptions to this rule include teardowns, templates and +cases where the continue-on-failure mode has been explicitly enabled with +`robot:continue-on-failure` or `robot:recursive-continue-on-failure` +tags. Robot Framework 6.0 makes it possible to disable the implicit or explicit +continue-on-failure mode when needed by using `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags (`#4303`_). + +`start/end_keyword` listener methods get more information about control structures +---------------------------------------------------------------------------------- + +When using the listener API v2, `start_keyword` and `end_keyword` methods are not +only used with keywords but also with all control structures. Earlier these methods +always got exactly the same information, but nowadays there is additional context +specific details with control structures. (`#4335`_) + +Libdoc enhancements +------------------- + +Libdoc can now generate keyword documentation not only for libraries and +resource files, but also for suite files (e.g. `tests.robot`) and for suite +initialization files (`__init__.robot`). The primary use case was making it +possible for editors to show HTML documentation for keywords regardless +the file user is editing, but naturally such HTML documentation can be useful +also otherwise. (`#4493`_) + +Libdoc has also got new `--theme` option that can be used to enforce dark +or light theme. The theme used by the browser is used by default as earlier. +External tools can control the theme also programmatically when generating +documentation and by calling the `setTheme()` Javascript function. (`#4497`_) + +Performance enhancements for executing user keywords +---------------------------------------------------- + +The overhead in executing user keywords has been reduced. The difference +can be seen especially if user keywords fail often, for example, when using +`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) + +Python 3.11 support +-------------------- + +Robot Framework 6.0 officially supports the forthcoming Python 3.11 +release (`#4401`_). Incompatibilities were not too big, so also the earlier +versions work fairly well. + +At the other end of the spectrum, Python 3.6 is deprecated and will not +anymore be supported by Robot Framework 7.0 (`#4295`_). + + +Backwards incompatible changes +============================== + +- Space is required after `Given/When/Then` prefixes used with BDD scenarios. (`#4379`_) + +- Dictionary related keywords in `Collections` require dictionaries to inherit `Mapping`. (`#4413`_) + +- `Dictionary Should Contain Item` from the Collections library does not anymore convert + values to strings before comparison. (`#4408`_) + +- Automatic `TypedDict` conversion can cause problems if a keyword expects to get any + dictionary. Nowadays dictionaries that do not match the type spec cause failures + and the keyword is not called at all. (`#4477`_) + +- Generation time in XML and JSON spec files generated by Libdoc has been changed to + `2022-05-27T19:07:15+00:00`. With XML specs the format used to be `2022-05-27T19:07:15Z` + that is equivalent with the new format. JSON spec files did not include the timezone + information at all and the format was `2022-05-27 19:07:15`. (`#4262`_) + +- `BuiltIn.run_keyword()` nowadays resolves variables in the name of the keyword to + execute when earlier they were resolved by Robot Framework before calling the keyword. + This affects programmatic usage if the used name contains variables or backslashes. + The change was done when enhancing how keywords with embedded arguments work with + `BuiltIn.run_keyword()`. (`#1595`_) + + +Deprecated features +=================== + +`Force Tags` and `Default Tags` settings +---------------------------------------- + +As `discussed above`__, new `Test Tags` setting has been added to replace `Force Tags` +and there is a plan to remove `Default Tags` altogether. Both of these settings still +work but they are considered deprecated. There is no visible deprecation warning yet, +but such a warning will be emitted starting from Robot Framework 7.0 and eventually these +settings will be removed. (`#4368`_) + +The plan is to add new `-tag` syntax that can be used with the `[Tags]` setting +to enable similar functionality that the `Default Tags` setting provides. Because +of that, using tags starting with a hyphen with the `[Tags]` setting is now deprecated. +If such literal values are needed, it is possible to use escaped format like `\-tag`. +(`#4380`_) + +__ `Enhancements for setting keyword and test tags`_ + +Keywords in test case files having precedence over local keywords in resource files +----------------------------------------------------------------------------------- + +Keywords in test cases files currently always have the highest precedence. They +are used even when a keyword in a resource file uses a keyword that would exist also +in the same resource file. This will change so that local keywords always have +highest precedence and the current behavior is deprecated. (`#4366`_) + +`WITH NAME` in favor of `AS` when giving alias to imported library +------------------------------------------------------------------ + +`WITH NAME` marker that is used when giving an alias to an imported library +will be renamed to `AS` (`#4371`_). The motivation is to be consistent with +Python that uses `as` for similar purpose. We also already use `AS` with +`TRY/EXCEPT` and reusing the same marker and internally used token simplifies +the syntax. Having less markers will also ease translations (but these markers +cannot yet be translated). + +In Robot Framework 6.0 both `AS` and `WITH NAME` work when setting an alias +for a library. `WITH NAME` is considered deprecated, but there will not be +visible deprecation warnings until Robot Framework 7.0. + +Singular section headers like `Test Case` +----------------------------------------- + +Robot Framework has earlier accepted both plural (e.g. `Test Cases`) and singular +(e.g. `Test Case`) section headers. The singular variants are now deprecated +and their support will eventually be removed (`#4431`_). The is no visible +deprecation warning yet, but they will most likely be emitted starting from +Robot Framework 7.0. + +Using variables with embedded arguments so that value does not match custom pattern +----------------------------------------------------------------------------------- + +When keywords accepting embedded arguments are used so that arguments are +passed as variables, variable values are not checked against possible custom +regular expressions. Keywords being called with arguments they explicitly do not +accept is problematic and this behavior will be changed. Due to the backwards +compatibility it is now only deprecated, but validation will be more strict +in the future. (`#4462`_) + +Custom patterns have often been used to avoid conflicts when using embedded arguments. +That need is nowadays smaller because Robot Framework 6.0 can typically resolve +conflicts automatically. (`#4454`_) + +`robot.utils.TRUE_STRINGS` and `robot.utils.FALSE_STRINGS` +---------------------------------------------------------- + +These constants were earlier sometimes needed by libraries when converting +arguments passed to keywords to Boolean values. Nowadays automatic argument +conversion takes care of that and these constants do not have any real usage. +They can still be used and there is not even a deprecation warning yet, +but they will be loudly deprecated and eventually removed later. (`#4500`_) + +These constants are internally used by `is_truthy` and `is_falsy` utility +functions that some of Robot Framework standard libraries still use. +Also these utils are likely to be deprecated in the future, and users are +advised to use the automatic argument conversion instead of them. + +Python 3.6 support +------------------ + +Python 3.6 `reached end-of-life`__ in December 2021. It will be still supported +by all future Robot Framework 6.x releases, but not anymore by Robot Framework +7.0 (`#4295`_). Users are recommended to upgrade to newer versions already now. + +__ https://endoflife.date/python + + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its ~50 member organizations. Robot Framework 6.0 team funded by the foundation +consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the wider open source community has provided several +great contributions: + +- `Elout van Leeuwen <https://github.com/leeuwe>`_ has lead the localization efforts + (`#4390`_). Individual translations have been provided by the following people: + + - Bosnian by `Namik <https://github.com/Delilovic>`_ + - Bulgarian by `Ivo <https://github.com/naschenez>`_ + - Chinese Simplified and Chinese Traditional + by `@nixuewei <https://github.com/nixuewei>`_ + and `charis <https://github.com/mawentao119>`_ + - Czech by `Václav Fuksa <https://github.com/MoreFamed>`_ + - Dutch by `Pim Jansen <https://github.com/pimjansen>`_ + and `Elout van Leeuwen <https://github.com/leeuwe>`_ + - French by `@lesnake <https://github.com/lesnake>`_ + and `Martin Malorni <https://github.com/mmalorni>`_ + - German by `René <https://github.com/Snooz82>`_ + and `Markus <https://github.com/Noordsestern>`_ + - Hindi by `Bharat Patel <https://github.com/bbpatel2001>`_ + - Italian by `Luca Giorgi <https://github.com/lugi0>`_ + - Polish by `Bartłomiej Hirsz <https://github.com/bhirsz>`_ + - Portuguese and Brazilian Portuguese + by `Hélio Guilherme <https://github.com/HelioGuilherme66>`_ + - Romanian by `Liviu Avram <https://github.com/zastress>`_ + - Russian by `Anatoly Kolpakov <https://github.com/axxyhtrx>`_ + - Spanish by Miguel Angel Apolayo Mendoza + - Swedish by `Richard Ludwig <https://github.com/JockeJarre>`_ + - Thai by `Somkiat Puisungnoen <https://github.com/up1>`_ + - Turkish by `Yusuf Can Bayrak <https://github.com/yusufcanb>`_ + - Ukrainian by `@Sunshine0000000 <https://github.com/Sunshine0000000>`_ + +- `Oliver Boehmer <https://github.com/oboehmer>`_ provided several contributions: + + - Support to disable the continue-on-failure mode using `robot:stop-on-failure` and + `robot:recursive-stop-on-failure` tags. (`#4303`_) + - Document that failing test setup stops execution even if the continue-on-failure + mode is active. (`#4404`_) + - Default value to `Get From Dictionary` keyword. (`#4398`_) + - Allow passing explicit flags to regexp related keywords. (`#4429`_) + +- `J. Foederer <https://github.com/JFoederer>`_ enhanced performance of + `Keyword Should Exist` when a keyword is not found (`#4470`_) and provided + the initial pull request to support parameterized generics like `list[int]` (`#4433`_) + +- `Ossi R. <https://github.com/osrjv>`_ added more information to `start/end_keyword` + listener methods when they are used with control structures (`#4335`_). + +- `René <https://github.com/Snooz82>`_ fixed Libdoc's HTML outputs if type hints + matched Javascript variables in browser namespace (`#4464`_) or keyword names (`#4471`_). + +- `Fabio Zadrozny <https://github.com/fabioz>`_ provided a pull request speeding up + user keyword execution (`#4353`_). + +- `Daniel Biehl <https://github.com/d-biehl>`_ helped making the public + `robot.api.Languages` API easier to use for external tools (`#4096`_). + +- `@mikkuja <https://github.com/mikkuja>`_ added support to parse time strings + containing micro and nanoseconds like (`#4490`_). + +- `@Apteryks <https://github.com/Apteryks>`_ added support to generate deterministic + library documentation by using `SOURCE_DATE_EPOCH`__ environment variable (`#4262`_). + +- `@F3licity <https://github.com/F3licity>`_ enhanced `Sleep` keyword documentation. (`#4485`_) + +__ https://reproducible-builds.org/specs/source-date-epoch/ + +Thanks also to all community members who have submitted bug reports, helped debugging +problems, or otherwise helped to make Robot Framework 6.0 our best release so far! + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#4096`_ + - enhancement + - critical + - Multilanguage support for markers used in data + - alpha 1 + * - `#519`_ + - enhancement + - critical + - Given/When/Then should support other languages than English + - alpha 1 + * - `#1595`_ + - bug + - high + - Embedded arguments are not passed as objects when executed with `Run Keyword` or its variants + - beta 2 + * - `#4348`_ + - bug + - high + - Invalid IF or WHILE conditions should not cause errors that don't allow continuation + - rc 1 + * - `#4483`_ + - bug + - high + - BREAK and CONTINUE hide continuable errors with WHILE loops + - rc 1 + * - `#4295`_ + - enhancement + - high + - Deprecate Python 3.6 + - alpha 1 + * - `#430`_ + - enhancement + - high + - Keyword visibility modifiers for resource files + - alpha 1 + * - `#4303`_ + - enhancement + - high + - Support disabling continue-on-failure mode using `robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags + - alpha 1 + * - `#4335`_ + - enhancement + - high + - Pass more information about control structures to `start/end_keyword` listener methods + - beta 1 + * - `#4366`_ + - enhancement + - high + - Give local keywords precedence over imported keywords in resource files + - alpha 1 + * - `#4368`_ + - enhancement + - high + - New `Test Tags` setting as an alias for `Force Tags` + - alpha 1 + * - `#4373`_ + - enhancement + - high + - Support adding tags for all keywords using `Keyword Tags` setting + - alpha 1 + * - `#4380`_ + - enhancement + - high + - Deprecate setting tags starting with a hyphen like `-tag` using the `[Tags]` setting + - alpha 1 + * - `#4388`_ + - enhancement + - high + - Enhance performance of executing user keywords especially when they fail + - alpha 1 + * - `#4400`_ + - enhancement + - high + - Allow translating True and False words used in Boolean argument conversion + - beta 1 + * - `#4401`_ + - enhancement + - high + - Python 3.11 compatibility + - alpha 1 + * - `#4433`_ + - enhancement + - high + - Convert and validate collection contents when using generics in type hints + - rc 1 + * - `#4454`_ + - enhancement + - high + - Automatically select "best" match if there is conflict with keywords using embedded arguments + - beta 2 + * - `#4477`_ + - enhancement + - high + - Convert and validate `TypedDict` items + - rc 1 + * - `#4493`_ + - enhancement + - high + - Libdoc: Support generating keyword documentation for suite files + - rc 2 + * - `#4351`_ + - bug + - medium + - Libdoc can give bad error message if library argument has extension matching resource files + - alpha 1 + * - `#4355`_ + - bug + - medium + - Continuable failures terminate WHILE loops + - alpha 1 + * - `#4357`_ + - bug + - medium + - Parsing model: Creating `TRY` and `WHILE` statements using `from_params` is not possible + - alpha 1 + * - `#4359`_ + - bug + - medium + - Parsing model: `Variable.from_params` doesn't handle list values properly + - alpha 1 + * - `#4364`_ + - bug + - medium + - `@{list}` used as embedded argument not anymore expanded if keyword accepts varargs + - beta 1 + * - `#4381`_ + - bug + - medium + - Parsing errors are recognized as EmptyLines + - alpha 1 + * - `#4384`_ + - bug + - medium + - RPA aliases for settings do not work in suite initialization files + - alpha 1 + * - `#4387`_ + - bug + - medium + - Libdoc: Fix storing information about deprecated keywords to spec files + - alpha 1 + * - `#4408`_ + - bug + - medium + - Collection: `Dictionary Should Contain Item` incorrectly casts values to strings before comparison + - alpha 1 + * - `#4418`_ + - bug + - medium + - Dictionaries insider lists in YAML variable files not converted to DotDict objects + - beta 1 + * - `#4438`_ + - bug + - medium + - `Get Time` returns current time if it is given input time that matches epoch + - beta 2 + * - `#4441`_ + - bug + - medium + - Regression: Empty `--include/--exclude/--test/--suite` are not ignored + - beta 2 + * - `#4447`_ + - bug + - medium + - Evaluating expressions that modify evaluation namespace (locals) fail + - beta 1 + * - `#4455`_ + - bug + - medium + - Standard libraries don't support `pathlib.Path` objects + - beta 2 + * - `#4464`_ + - bug + - medium + - Libdoc: Type hints aren't shown for types with same name as Javascript variables available in browser namespace + - beta 2 + * - `#4476`_ + - bug + - medium + - BuiltIn: `Call Method` loses traceback if calling the method fails + - rc 1 + * - `#4480`_ + - bug + - medium + - Creating log and report fails if WHILE loop has no condition + - rc 1 + * - `#4482`_ + - bug + - medium + - WHILE and FOR loop contents not shown in log if running them fails due to errors + - rc 1 + * - `#4484`_ + - bug + - medium + - Invalid TRY/EXCEPT structure causes normal error, not syntax error + - rc 1 + * - `#4262`_ + - enhancement + - medium + - Honor `SOURCE_DATE_EPOCH` environment variable when generating library documentation + - alpha 1 + * - `#4312`_ + - enhancement + - medium + - Add project URLs to PyPI + - alpha 1 + * - `#4353`_ + - enhancement + - medium + - Performance enhancements to parsing + - alpha 1 + * - `#4354`_ + - enhancement + - medium + - When merging suites with Rebot, copy documentation and metadata from merged suites + - beta 1 + * - `#4371`_ + - enhancement + - medium + - Add `AS` alias for `WITH NAME` in library imports + - alpha 1 + * - `#4379`_ + - enhancement + - medium + - Require space after Given/When/Then prefixes + - alpha 1 + * - `#4398`_ + - enhancement + - medium + - Collections: `Get From Dictionary` should accept a default value + - alpha 1 + * - `#4404`_ + - enhancement + - medium + - Document that failing test setup stops execution even if continue-on-failure mode is active + - alpha 1 + * - `#4413`_ + - enhancement + - medium + - Dictionary related keywords in `Collections` are more script about accepted values + - alpha 1 + * - `#4429`_ + - enhancement + - medium + - Allow passing flags to regexp related keywords using explicit `flags` argument + - beta 1 + * - `#4431`_ + - enhancement + - medium + - Deprecate using singular section headers + - beta 1 + * - `#4440`_ + - enhancement + - medium + - Allow using `None` as custom argument converter to enable strict type validation + - beta 1 + * - `#4461`_ + - enhancement + - medium + - Automatic argument conversion for `pathlib.Path` + - beta 2 + * - `#4462`_ + - enhancement + - medium + - Deprecate using embedded arguments using variables that do not match custom regexp + - beta 2 + * - `#4470`_ + - enhancement + - medium + - Enhance `Keyword Should Exist` performance by not looking for possible recommendations + - beta 2 + * - `#4490`_ + - enhancement + - medium + - Time string parsing for micro and nanoseconds + - rc 2 + * - `#4497`_ + - enhancement + - medium + - Libdoc: Support setting dark or light mode explicitly + - rc 2 + * - `#4349`_ + - bug + - low + - User Guide: Example related to YAML variable files is buggy + - alpha 1 + * - `#4358`_ + - bug + - low + - User Guide: Errors in examples related to TRY/EXCEPT + - alpha 1 + * - `#4453`_ + - bug + - low + - `Run Keywords`: Execution is not continued in teardown if keyword name contains non-existing variable + - beta 2 + * - `#4471`_ + - bug + - low + - Libdoc: If keyword and type have same case-insensitive name, opening type info opens keyword documentation + - beta 2 + * - `#4481`_ + - bug + - low + - Invalid BREAK and CONTINUE cause errros even when not actually executed + - rc 1 + * - `#4346`_ + - enhancement + - low + - Enhance documentation of the `--timestampoutputs` option + - alpha 1 + * - `#4372`_ + - enhancement + - low + - Document how to import resource files bundled into Python packages + - alpha 1 + * - `#4485`_ + - enhancement + - low + - Explain the default value of `Sleep` keyword better in its documentation + - rc 1 + * - `#4500`_ + - enhancement + - low + - Deprecate `robot.utils.TRUE/FALSE_STRINGS` + - rc 2 + * - `#4394`_ + - bug + - --- + - Error when `--doc` or `--metadata` value matches an existing directory + - alpha 1 + +Altogether 66 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0>`__. + +.. _#4096: https://github.com/robotframework/robotframework/issues/4096 +.. _#519: https://github.com/robotframework/robotframework/issues/519 +.. _#1595: https://github.com/robotframework/robotframework/issues/1595 +.. _#4348: https://github.com/robotframework/robotframework/issues/4348 +.. _#4483: https://github.com/robotframework/robotframework/issues/4483 +.. _#4295: https://github.com/robotframework/robotframework/issues/4295 +.. _#430: https://github.com/robotframework/robotframework/issues/430 +.. _#4303: https://github.com/robotframework/robotframework/issues/4303 +.. _#4335: https://github.com/robotframework/robotframework/issues/4335 +.. _#4366: https://github.com/robotframework/robotframework/issues/4366 +.. _#4368: https://github.com/robotframework/robotframework/issues/4368 +.. _#4373: https://github.com/robotframework/robotframework/issues/4373 +.. _#4380: https://github.com/robotframework/robotframework/issues/4380 +.. _#4388: https://github.com/robotframework/robotframework/issues/4388 +.. _#4400: https://github.com/robotframework/robotframework/issues/4400 +.. _#4401: https://github.com/robotframework/robotframework/issues/4401 +.. _#4433: https://github.com/robotframework/robotframework/issues/4433 +.. _#4454: https://github.com/robotframework/robotframework/issues/4454 +.. _#4477: https://github.com/robotframework/robotframework/issues/4477 +.. _#4493: https://github.com/robotframework/robotframework/issues/4493 +.. _#4351: https://github.com/robotframework/robotframework/issues/4351 +.. _#4355: https://github.com/robotframework/robotframework/issues/4355 +.. _#4357: https://github.com/robotframework/robotframework/issues/4357 +.. _#4359: https://github.com/robotframework/robotframework/issues/4359 +.. _#4364: https://github.com/robotframework/robotframework/issues/4364 +.. _#4381: https://github.com/robotframework/robotframework/issues/4381 +.. _#4384: https://github.com/robotframework/robotframework/issues/4384 +.. _#4387: https://github.com/robotframework/robotframework/issues/4387 +.. _#4408: https://github.com/robotframework/robotframework/issues/4408 +.. _#4418: https://github.com/robotframework/robotframework/issues/4418 +.. _#4438: https://github.com/robotframework/robotframework/issues/4438 +.. _#4441: https://github.com/robotframework/robotframework/issues/4441 +.. _#4447: https://github.com/robotframework/robotframework/issues/4447 +.. _#4455: https://github.com/robotframework/robotframework/issues/4455 +.. _#4464: https://github.com/robotframework/robotframework/issues/4464 +.. _#4476: https://github.com/robotframework/robotframework/issues/4476 +.. _#4480: https://github.com/robotframework/robotframework/issues/4480 +.. _#4482: https://github.com/robotframework/robotframework/issues/4482 +.. _#4484: https://github.com/robotframework/robotframework/issues/4484 +.. _#4262: https://github.com/robotframework/robotframework/issues/4262 +.. _#4312: https://github.com/robotframework/robotframework/issues/4312 +.. _#4353: https://github.com/robotframework/robotframework/issues/4353 +.. _#4354: https://github.com/robotframework/robotframework/issues/4354 +.. _#4371: https://github.com/robotframework/robotframework/issues/4371 +.. _#4379: https://github.com/robotframework/robotframework/issues/4379 +.. _#4398: https://github.com/robotframework/robotframework/issues/4398 +.. _#4404: https://github.com/robotframework/robotframework/issues/4404 +.. _#4413: https://github.com/robotframework/robotframework/issues/4413 +.. _#4429: https://github.com/robotframework/robotframework/issues/4429 +.. _#4431: https://github.com/robotframework/robotframework/issues/4431 +.. _#4440: https://github.com/robotframework/robotframework/issues/4440 +.. _#4461: https://github.com/robotframework/robotframework/issues/4461 +.. _#4462: https://github.com/robotframework/robotframework/issues/4462 +.. _#4470: https://github.com/robotframework/robotframework/issues/4470 +.. _#4490: https://github.com/robotframework/robotframework/issues/4490 +.. _#4497: https://github.com/robotframework/robotframework/issues/4497 +.. _#4349: https://github.com/robotframework/robotframework/issues/4349 +.. _#4358: https://github.com/robotframework/robotframework/issues/4358 +.. _#4453: https://github.com/robotframework/robotframework/issues/4453 +.. _#4471: https://github.com/robotframework/robotframework/issues/4471 +.. _#4481: https://github.com/robotframework/robotframework/issues/4481 +.. _#4346: https://github.com/robotframework/robotframework/issues/4346 +.. _#4372: https://github.com/robotframework/robotframework/issues/4372 +.. _#4485: https://github.com/robotframework/robotframework/issues/4485 +.. _#4500: https://github.com/robotframework/robotframework/issues/4500 +.. _#4394: https://github.com/robotframework/robotframework/issues/4394 +.. _#4390: https://github.com/robotframework/robotframework/issues/4390 From 6e80b67515dfc29ae737a2c71242d73e51bda6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 22:28:36 +0300 Subject: [PATCH 0260/1592] Updated version to 6.0rc2 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index d5ea9925075..ebd4b1b1801 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc2.dev1' +VERSION = '6.0rc2' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index b3cddcb519a..9d7da708f4f 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc2.dev1' +VERSION = '6.0rc2' def get_version(naked=False): From 547cc7711712ee24b8d5082a8e9d2a0f19135ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 22:30:28 +0300 Subject: [PATCH 0261/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ebd4b1b1801..c3078dbcb37 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc2' +VERSION = '6.0rc3.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 9d7da708f4f..4799ad2014d 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc2' +VERSION = '6.0rc3.dev1' def get_version(naked=False): From 1a1e399a304f00cbb52ae65ea14b495677b28c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 22:49:37 +0300 Subject: [PATCH 0262/1592] API doc tuning/fixes. --- src/robot/api/__init__.py | 4 +++- src/robot/conf/languages.py | 15 ++++++++++++--- utest/api/test_languages.py | 4 ++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/robot/api/__init__.py b/src/robot/api/__init__.py index f4654e3852c..f846165d3c9 100644 --- a/src/robot/api/__init__.py +++ b/src/robot/api/__init__.py @@ -58,7 +58,9 @@ returned by the :func:`~robot.result.resultbuilder.ExecutionResult` or an executed :class:`~robot.running.model.TestSuite`. -* :class:`~robot.conf.languages.Language` base class for custom translations. +* :class:`~robot.conf.languages.Languages` and :class:`~robot.conf.languages.Language` + classes for external tools that need to work with different translations. + The latter is also the base class to use with custom translations. All of the above names can be imported like:: diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 5ffa630a80f..0a9113d9262 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -28,7 +28,8 @@ class Languages: languages = Languages('de', add_english=False) print(languages.settings) languages = Languages(['pt-BR', 'Finnish', 'MyLang.py']) - print(list(languages)) + for lang in languages: + print(lang.name, lang.code) """ def __init__(self, languages=None, add_english=True): @@ -37,7 +38,7 @@ def __init__(self, languages=None, add_english=True): Languages can be given as language codes or names, paths or names of language modules to load, or as :class:`Language` instances. :param add_english: If True, English is added automatically. - :raises :class:`~robot.errors.DataError` if a given language is not found. + :raises: :class:`~robot.errors.DataError` if a given language is not found. :meth:`add.language` can be used to add languages after initialization. """ @@ -217,8 +218,12 @@ def code(cls): Got based on the class name. If the class name is two characters (or less), the code is just the name in lower case. If it is longer, a hyphen is added - remainder of the class name is upper-cased. + and the remainder of the class name is upper-cased. + + This special property can be accessed also directly from the class. """ + if cls is Language: + return cls.__dict__['code'] code = cls.__name__.lower() if len(code) < 3: return code @@ -229,7 +234,11 @@ def name(cls): """Language name like 'Finnish' or 'Brazilian Portuguese'. Got from the first line of the class docstring. + + This special property can be accessed also directly from the class. """ + if cls is Language: + return cls.__dict__['name'] return cls.__doc__.splitlines()[0] if cls.__doc__ else '' @property diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index fcba614df7f..2c61d18a6f8 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -48,6 +48,10 @@ def test_all_standard_languages_have_code_and_name(self): assert cls().name assert cls.name + def test_code_and_name_of_Language_base_class_are_propertys(self): + assert isinstance(Language.code, property) + assert isinstance(Language.name, property) + def test_eq(self): assert_equal(Fi(), Fi()) assert_equal(Language.from_name('fi'), Fi()) From 6df86e10bb0825b16978a3120988c7c18605ad35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 11 Oct 2022 22:49:57 +0300 Subject: [PATCH 0263/1592] regen --- doc/api/autodoc/robot.conf.rst | 8 ++++++++ doc/api/autodoc/robot.parsing.lexer.rst | 8 -------- doc/api/autodoc/robot.running.builder.rst | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/doc/api/autodoc/robot.conf.rst b/doc/api/autodoc/robot.conf.rst index 9cb2d24031d..1f05353a35e 100644 --- a/doc/api/autodoc/robot.conf.rst +++ b/doc/api/autodoc/robot.conf.rst @@ -17,6 +17,14 @@ robot.conf.gatherfailed module :undoc-members: :show-inheritance: +robot.conf.languages module +--------------------------- + +.. automodule:: robot.conf.languages + :members: + :undoc-members: + :show-inheritance: + robot.conf.settings module -------------------------- diff --git a/doc/api/autodoc/robot.parsing.lexer.rst b/doc/api/autodoc/robot.parsing.lexer.rst index c641d251b85..64d4cafe37f 100644 --- a/doc/api/autodoc/robot.parsing.lexer.rst +++ b/doc/api/autodoc/robot.parsing.lexer.rst @@ -33,14 +33,6 @@ robot.parsing.lexer.lexer module :undoc-members: :show-inheritance: -robot.parsing.lexer.sections module ------------------------------------ - -.. automodule:: robot.parsing.lexer.sections - :members: - :undoc-members: - :show-inheritance: - robot.parsing.lexer.settings module ----------------------------------- diff --git a/doc/api/autodoc/robot.running.builder.rst b/doc/api/autodoc/robot.running.builder.rst index 7f7283d7a04..34615f93a0c 100644 --- a/doc/api/autodoc/robot.running.builder.rst +++ b/doc/api/autodoc/robot.running.builder.rst @@ -25,10 +25,10 @@ robot.running.builder.parsers module :undoc-members: :show-inheritance: -robot.running.builder.testsettings module ------------------------------------------ +robot.running.builder.settings module +------------------------------------- -.. automodule:: robot.running.builder.testsettings +.. automodule:: robot.running.builder.settings :members: :undoc-members: :show-inheritance: From 44654df0681137902bbff334c8eb3802377548c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Oct 2022 23:32:56 +0300 Subject: [PATCH 0264/1592] Bump octokit/request-action from 2.1.6 to 2.1.7 (#4505) Bumps [octokit/request-action](https://github.com/octokit/request-action) from 2.1.6 to 2.1.7. - [Release notes](https://github.com/octokit/request-action/releases) - [Commits](https://github.com/octokit/request-action/compare/8509fdb30e17659bffb27878bb307fceb3ee2a64...89a1754fe82ca777b044ca8e79e9881a42f15a93) --- updated-dependencies: - dependency-name: octokit/request-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/acceptance_tests_cpython_pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 587e8b1d627..fda00ae2751 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -125,7 +125,7 @@ jobs: echo "JOB_STATUS=$(python -c "print('${{ job.status }}'.lower())")" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: always() && job.status == 'failure' && runner.os == 'Windows' - - uses: octokit/request-action@8509fdb30e17659bffb27878bb307fceb3ee2a64 + - uses: octokit/request-action@89a1754fe82ca777b044ca8e79e9881a42f15a93 name: Update status with Github Status API id: update_status with: diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 3f835728c47..a485d21d698 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -111,7 +111,7 @@ jobs: echo "JOB_STATUS=$(python -c "print('${{ job.status }}'.lower())")" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: always() && job.status == 'failure' && runner.os == 'Windows' - - uses: octokit/request-action@8509fdb30e17659bffb27878bb307fceb3ee2a64 + - uses: octokit/request-action@89a1754fe82ca777b044ca8e79e9881a42f15a93 name: Update status with Github Status API id: update_status with: From af2c0648bf1eb9702db64e5fa6dc30f5be669173 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Oct 2022 23:33:25 +0300 Subject: [PATCH 0265/1592] Bump actions/setup-python from 4.2.0 to 4.3.0 (#4504) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.2.0 to 4.3.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.2.0...v4.3.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index fda00ae2751..2734cea3bcd 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.2.0 + uses: actions/setup-python@v4.3.0 with: python-version: '3.10' architecture: 'x64' @@ -51,7 +51,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.2.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index a485d21d698..b63ca41d25c 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.2.0 + uses: actions/setup-python@v4.3.0 with: python-version: '3.10' 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@v4.2.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index b05f416e148..9e644c43f4d 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.2.0 + uses: actions/setup-python@v4.3.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 bdb287797ca..5e3383716e2 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.2.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From 4742a0dcaa8c62e4c55575ebce40ff67766bc092 Mon Sep 17 00:00:00 2001 From: Daniel Biehl <7069968+d-biehl@users.noreply.github.com> Date: Fri, 14 Oct 2022 00:01:22 +0200 Subject: [PATCH 0266/1592] Correct the compound words for German. (#4508) Some German words are not correctly written together, I have corrected that. @Snooz82, is that ok for you? --- src/robot/conf/languages.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 0a9113d9262..4532ae0a289 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -556,13 +556,13 @@ class De(Language): test_teardown_setting = 'Testnachbereitung' test_template_setting = 'Testvorlage' test_timeout_setting = 'Testzeitlimit' - test_tags_setting = 'Test Marker' + test_tags_setting = 'Testmarker' task_setup_setting = 'Aufgabenvorbereitung' task_teardown_setting = 'Aufgabennachbereitung' task_template_setting = 'Aufgabenvorlage' task_timeout_setting = 'Aufgabenzeitlimit' - task_tags_setting = 'Aufgaben Marker' - keyword_tags_setting = 'Schlüsselwort Marker' + task_tags_setting = 'Aufgabenmarker' + keyword_tags_setting = 'Schlüsselwortmarker' tags_setting = 'Marker' setup_setting = 'Vorbereitung' teardown_setting = 'Nachbereitung' From 84cc0cfc8912a3047adcac26fb56ad2df06d221b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 17 Oct 2022 17:17:51 +0300 Subject: [PATCH 0267/1592] Less strict validation for custom converter arguments. Fixes #4511. --- .../type_conversion/custom_converters.robot | 7 +++-- .../type_conversion/CustomConverters.py | 10 +++++-- .../running/arguments/customconverters.py | 28 ++++++++++++------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/atest/robot/keywords/type_conversion/custom_converters.robot b/atest/robot/keywords/type_conversion/custom_converters.robot index a4d70fb7213..2c038ca6113 100644 --- a/atest/robot/keywords/type_conversion/custom_converters.robot +++ b/atest/robot/keywords/type_conversion/custom_converters.robot @@ -37,9 +37,10 @@ Invalid converters Check Test Case ${TESTNAME} Validate Errors ... Custom converters must be callable, converter for Invalid is integer. - ... Custom converters must accept exactly one positional argument, converter 'TooFewArgs' accepts 0. - ... Custom converters must accept exactly one positional argument, converter 'TooManyArgs' accepts 2. - ... Custom converter 'KwOnlyNotOk' accepts keyword-only arguments which is not supported. + ... Custom converters must accept one positional argument, 'TooFewArgs' accepts none. + ... Custom converters cannot have more than one mandatory argument, 'TooManyArgs' has 'one' and 'two'. + ... Custom converters must accept one positional argument, 'NoPositionalArg' accepts none. + ... Custom converters cannot have mandatory keyword-only arguments, 'KwOnlyNotOk' has 'another' and 'kwo'. ... Custom converters must be specified using types, got string 'Bad'. Non-type annotation diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index 681ecdcdd30..3102d98cf29 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -41,7 +41,7 @@ def from_string(cls, value) -> date: class FiDate(date): @classmethod - def from_string(cls, value: str): + def from_string(cls, value: str, ign1=None, *ign2, ign3=None, **ign4): try: return cls.fromordinal(datetime.strptime(value, '%d.%m.%Y').toordinal()) except ValueError: @@ -82,8 +82,13 @@ def __init__(self, one, two): pass +class NoPositionalArg: + def __init__(self, *varargs): + pass + + class KwOnlyNotOk: - def __init__(self, arg, *, kwo): + def __init__(self, arg, *, kwo, another): pass @@ -98,6 +103,7 @@ def __init__(self, arg, *, kwo): Invalid: 666, TooFewArgs: TooFewArgs, TooManyArgs: TooManyArgs, + NoPositionalArg: NoPositionalArg, KwOnlyNotOk: KwOnlyNotOk, 'Bad': int} diff --git a/src/robot/running/arguments/customconverters.py b/src/robot/running/arguments/customconverters.py index 20f9b76f423..6d36a729e2f 100644 --- a/src/robot/running/arguments/customconverters.py +++ b/src/robot/running/arguments/customconverters.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import getdoc, is_union, type_name +from robot.utils import getdoc, is_union, seq2str, type_name from .argumentparser import PythonArgumentParser @@ -76,15 +76,7 @@ def converter(arg): if not callable(converter): raise TypeError(f'Custom converters must be callable, converter for ' f'{type_name(type_)} is {type_name(converter)}.') - spec = PythonArgumentParser(type='Converter').parse(converter) - if len(spec.positional) != 1: - raise TypeError(f'Custom converters must accept exactly one positional ' - f'argument, converter {converter.__name__!r} accepts ' - f'{len(spec.positional)}.') - if len(spec.named_only): - raise TypeError(f'Custom converter {converter.__name__!r} accepts ' - f'keyword-only arguments which is not supported.') - arg_type = spec.types.get(spec.positional[0]) + arg_type = cls._get_arg_type(converter) if arg_type is None: accepts = () elif is_union(arg_type): @@ -94,3 +86,19 @@ def converter(arg): else: accepts = (arg_type,) return cls(type_, converter, accepts) + + @classmethod + def _get_arg_type(cls, converter): + spec = PythonArgumentParser(type='Converter').parse(converter) + if spec.minargs > 1: + required = seq2str([a for a in spec.positional if a not in spec.defaults]) + raise TypeError(f"Custom converters cannot have more than one mandatory " + f"argument, '{converter.__name__}' has {required}.") + if not spec.positional: + raise TypeError(f"Custom converters must accept one positional argument, " + f"'{converter.__name__}' accepts none.") + if spec.named_only and set(spec.named_only) - set(spec.defaults): + required = seq2str(sorted(set(spec.named_only) - set(spec.defaults))) + raise TypeError(f"Custom converters cannot have mandatory keyword-only " + f"arguments, '{converter.__name__}' has {required}.") + return spec.types.get(spec.positional[0]) From 03f1d6a08ca6f66e2dc1c1a9326d3daffd44b1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 18 Oct 2022 01:12:37 +0300 Subject: [PATCH 0268/1592] Initial script to generate documentation for translations. Need to commit and push unfinished work because my laptop crashed and I'm not certain will it recover from that properly... --- doc/userguide/document_translations.py | 188 ++ doc/userguide/src/Appendices/Translations.rst | 2592 +++++++++++++++++ 2 files changed, 2780 insertions(+) create mode 100644 doc/userguide/document_translations.py create mode 100644 doc/userguide/src/Appendices/Translations.rst diff --git a/doc/userguide/document_translations.py b/doc/userguide/document_translations.py new file mode 100644 index 00000000000..9252bfcaa9f --- /dev/null +++ b/doc/userguide/document_translations.py @@ -0,0 +1,188 @@ +from pathlib import Path + +from robot.api import Language + + +class LanguageWrapper: + + def __init__(self, lang): + self.lang = lang + + def __getattr__(self, name): + return getattr(self.lang, name) or '' + + @property + def underline(self): + width = len(self.lang.name + self.lang.code) + 3 + return '-' * width + + @property + def given_prefix(self): + return ', '.join(self.lang.given_prefix) + + @property + def when_prefix(self): + return ', '.join(self.lang.when_prefix) + + @property + def then_prefix(self): + return ', '.join(self.lang.then_prefix) + + @property + def and_prefix(self): + return ', '.join(self.lang.and_prefix) + + @property + def but_prefix(self): + return ', '.join(self.lang.but_prefix) + + @property + def true_strings(self): + return ', '.join(self.lang.true_strings) + + @property + def false_strings(self): + return ', '.join(self.lang.false_strings) + + +TEMPLATE = ''' +{lang.name} ({lang.code}) +{lang.underline} + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - {lang.settings_header} + * - Variables + - {lang.variables_header} + * - Test Cases + - {lang.test_cases_header} + * - Tasks + - {lang.tasks_header} + * - Keywords + - {lang.keywords_header} + * - Comments + - {lang.comments_header} + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - {lang.library_setting} + * - Resource + - {lang.resource_setting} + * - Variables + - {lang.variables_setting} + * - Documentation + - {lang.documentation_setting} + * - Metadata + - {lang.metadata_setting} + * - Suite Setup + - {lang.suite_setup_setting} + * - Suite Teardown + - {lang.suite_teardown_setting} + * - Test Setup + - {lang.test_setup_setting} + * - Task Setup + - {lang.task_setup_setting} + * - Test Teardown + - {lang.test_teardown_setting} + * - Task Teardown + - {lang.task_teardown_setting} + * - Test Template + - {lang.test_template_setting} + * - Task Template + - {lang.task_template_setting} + * - Test Timeout + - {lang.test_timeout_setting} + * - Task Timeout + - {lang.task_timeout_setting} + * - Test Tags + - {lang.test_tags_setting} + * - Task Tags + - {lang.task_tags_setting} + * - Keyword Tags + - {lang.keyword_tags_setting} + * - Tags + - {lang.tags_setting} + * - Setup + - {lang.setup_setting} + * - Teardown + - {lang.teardown_setting} + * - Template + - {lang.template_setting} + * - Timeout + - {lang.timeout_setting} + * - Arguments + - {lang.arguments_setting} + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - {lang.given_prefix} + * - When + - {lang.when_prefix} + * - Then + - {lang.then_prefix} + * - And + - {lang.and_prefix} + * - But + - {lang.but_prefix} + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - {lang.true_strings} + * - False + - {lang.false_strings} +''' + + +def document_translations(file): + languages = [lang for lang in Language.__subclasses__() if lang.code != 'en'] + for index, lang in enumerate(sorted(languages, key=lambda lang: lang.code)): + file.write(TEMPLATE.format(lang=LanguageWrapper(lang))) + if index < len(languages) - 1: + file.write('\n\n') + + +if __name__ == '__main__': + target = Path(__file__).absolute().parent / 'src/Appendices/Translations.rst' + source = target.read_text(encoding='UTF-8') + with open(target, 'w', encoding='UTF-8') as file: + for line in source.splitlines(keepends=True): + file.write(line) + if line == '.. GENERATED CONTENT BEGINS\n': + break + document_translations(file) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst new file mode 100644 index 00000000000..ac9a3bfb1ab --- /dev/null +++ b/doc/userguide/src/Appendices/Translations.rst @@ -0,0 +1,2592 @@ +Translations +============ + +Robot Framework supports translating `section headers`_, settings__, Given/Whe +.. contents:: + :depth: 2 + :local: + +.. Content below has been generated using `document_translations.py`. +.. Don't edit manually, update the script instead. +.. GENERATED CONTENT BEGINS + +Bulgarian (bg) +-------------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Настройки + * - Variables + - Променливи + * - Test Cases + - Тестови случаи + * - Tasks + - Задачи + * - Keywords + - Ключови думи + * - Comments + - Коментари + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Библиотека + * - Resource + - Ресурс + * - Variables + - Променлива + * - Documentation + - Документация + * - Metadata + - Метаданни + * - Suite Setup + - Първоначални настройки на комплекта + * - Suite Teardown + - Приключване на комплекта + * - Test Setup + - Първоначални настройки на тестове + * - Task Setup + - Първоначални настройки на задачи + * - Test Teardown + - Приключване на тестове + * - Task Teardown + - Приключване на задачи + * - Test Template + - Шаблон за тестове + * - Task Template + - Шаблон за задачи + * - Test Timeout + - Таймаут за тестове + * - Task Timeout + - Таймаут за задачи + * - Test Tags + - Етикети за тестове + * - Task Tags + - Етикети за задачи + * - Keyword Tags + - Етикети за ключови думи + * - Tags + - Етикети + * - Setup + - Първоначални настройки + * - Teardown + - Приключване + * - Template + - Шаблон + * - Timeout + - Таймаут + * - Arguments + - Аргументи + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - В случай че + * - When + - Когато + * - Then + - Тогава + * - And + - И + * - But + - Но + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Включен, Вярно, Да + * - False + - Изключен, Нищо, Не, Невярно + + + +Bosnian (bs) +------------ + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Postavke + * - Variables + - Varijable + * - Test Cases + - Test Cases + * - Tasks + - Taskovi + * - Keywords + - Keywords + * - Comments + - Komentari + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Biblioteka + * - Resource + - Resursi + * - Variables + - Varijable + * - Documentation + - Dokumentacija + * - Metadata + - Metadata + * - Suite Setup + - Suite Postavke + * - Suite Teardown + - Suite Teardown + * - Test Setup + - Test Postavke + * - Task Setup + - Task Postavke + * - Test Teardown + - Test Teardown + * - Task Teardown + - Task Teardown + * - Test Template + - Test Template + * - Task Template + - Task Template + * - Test Timeout + - Test Timeout + * - Task Timeout + - Task Timeout + * - Test Tags + - Test Tagovi + * - Task Tags + - Task Tagovi + * - Keyword Tags + - Keyword Tagovi + * - Tags + - Tagovi + * - Setup + - Postavke + * - Teardown + - Teardown + * - Template + - Template + * - Timeout + - Timeout + * - Arguments + - Argumenti + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Uslovno + * - When + - Kada + * - Then + - Tada + * - And + - I + * - But + - Ali + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - + * - False + - + + + +Czech (cs) +---------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Nastavení + * - Variables + - Proměnné + * - Test Cases + - Testovací případy + * - Tasks + - Úlohy + * - Keywords + - Klíčová slova + * - Comments + - Komentáře + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Knihovna + * - Resource + - Zdroj + * - Variables + - Proměnná + * - Documentation + - Dokumentace + * - Metadata + - Metadata + * - Suite Setup + - Příprava sady + * - Suite Teardown + - Ukončení sady + * - Test Setup + - Příprava testu + * - Task Setup + - Příprava úlohy + * - Test Teardown + - Ukončení testu + * - Task Teardown + - Ukončení úlohy + * - Test Template + - Šablona testu + * - Task Template + - Šablona úlohy + * - Test Timeout + - Časový limit testu + * - Task Timeout + - Časový limit úlohy + * - Test Tags + - Štítky testů + * - Task Tags + - Štítky úloh + * - Keyword Tags + - Štítky klíčových slov + * - Tags + - Štítky + * - Setup + - Příprava + * - Teardown + - Ukončení + * - Template + - Šablona + * - Timeout + - Časový limit + * - Arguments + - Argumenty + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Pokud + * - When + - Když + * - Then + - Pak + * - And + - A + * - But + - Ale + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Zapnuto, Ano, Pravda + * - False + - Ne, Nic, Vypnuto, Nepravda + + + +German (de) +----------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Einstellungen + * - Variables + - Variablen + * - Test Cases + - Testfälle + * - Tasks + - Aufgaben + * - Keywords + - Schlüsselwörter + * - Comments + - Kommentare + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Bibliothek + * - Resource + - Ressource + * - Variables + - Variablen + * - Documentation + - Dokumentation + * - Metadata + - Metadaten + * - Suite Setup + - Suitevorbereitung + * - Suite Teardown + - Suitenachbereitung + * - Test Setup + - Testvorbereitung + * - Task Setup + - Aufgabenvorbereitung + * - Test Teardown + - Testnachbereitung + * - Task Teardown + - Aufgabennachbereitung + * - Test Template + - Testvorlage + * - Task Template + - Aufgabenvorlage + * - Test Timeout + - Testzeitlimit + * - Task Timeout + - Aufgabenzeitlimit + * - Test Tags + - Test Marker + * - Task Tags + - Aufgaben Marker + * - Keyword Tags + - Schlüsselwort Marker + * - Tags + - Marker + * - Setup + - Vorbereitung + * - Teardown + - Nachbereitung + * - Template + - Vorlage + * - Timeout + - Zeitlimit + * - Arguments + - Argumente + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Angenommen + * - When + - Wenn + * - Then + - Dann + * - And + - Und + * - But + - Aber + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Ein, Ja, Wahr, An + * - False + - Nein, Aus, Unwahr, Falsch + + + +Spanish (es) +------------ + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Configuraciones + * - Variables + - Variables + * - Test Cases + - Casos de prueba + * - Tasks + - Tareas + * - Keywords + - Palabras clave + * - Comments + - Comentarios + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Biblioteca + * - Resource + - Recursos + * - Variables + - Variable + * - Documentation + - Documentación + * - Metadata + - Metadatos + * - Suite Setup + - Configuración de la Suite + * - Suite Teardown + - Desmontaje de la Suite + * - Test Setup + - Configuración de prueba + * - Task Setup + - Configuración de tarea + * - Test Teardown + - Desmontaje de la prueba + * - Task Teardown + - Desmontaje de tareas + * - Test Template + - Plantilla de prueba + * - Task Template + - Plantilla de tareas + * - Test Timeout + - Tiempo de espera de la prueba + * - Task Timeout + - Tiempo de espera de las tareas + * - Test Tags + - Etiquetas de la prueba + * - Task Tags + - Etiquetas de las tareas + * - Keyword Tags + - Etiquetas de palabras clave + * - Tags + - Etiquetas + * - Setup + - Configuración + * - Teardown + - Desmontaje + * - Template + - Plantilla + * - Timeout + - Tiempo agotado + * - Arguments + - Argumentos + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Dado + * - When + - Cuando + * - Then + - Entonces + * - And + - Y + * - But + - Pero + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Verdadero, On, Si + * - False + - Ninguno, No, Falso, Off + + + +Finnish (fi) +------------ + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Asetukset + * - Variables + - Muuttujat + * - Test Cases + - Testit + * - Tasks + - Tehtävät + * - Keywords + - Avainsanat + * - Comments + - Kommentit + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Kirjasto + * - Resource + - Resurssi + * - Variables + - Muuttujat + * - Documentation + - Dokumentaatio + * - Metadata + - Metatiedot + * - Suite Setup + - Setin Alustus + * - Suite Teardown + - Setin Alasajo + * - Test Setup + - Testin Alustus + * - Task Setup + - Tehtävän Alustus + * - Test Teardown + - Testin Alasajo + * - Task Teardown + - Tehtävän Alasajo + * - Test Template + - Testin Malli + * - Task Template + - Tehtävän Malli + * - Test Timeout + - Testin Aikaraja + * - Task Timeout + - Tehtävän Aikaraja + * - Test Tags + - Testin Tagit + * - Task Tags + - Tehtävän Tagit + * - Keyword Tags + - Avainsanan Tagit + * - Tags + - Tagit + * - Setup + - Alustus + * - Teardown + - Alasajo + * - Template + - Malli + * - Timeout + - Aikaraja + * - Arguments + - Argumentit + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Oletetaan + * - When + - Kun + * - Then + - Niin + * - And + - Ja + * - But + - Mutta + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Tosi, Kyllä, Päällä + * - False + - Ei, Pois, Epätosi + + + +French (fr) +----------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Paramètres + * - Variables + - Variables + * - Test Cases + - Unités de test + * - Tasks + - Tâches + * - Keywords + - Mots-clés + * - Comments + - Commentaires + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Bibliothèque + * - Resource + - Ressource + * - Variables + - Variable + * - Documentation + - Documentation + * - Metadata + - Méta-donnée + * - Suite Setup + - Mise en place de suite + * - Suite Teardown + - Démontage de suite + * - Test Setup + - Mise en place de test + * - Task Setup + - Mise en place de tâche + * - Test Teardown + - Démontage de test + * - Task Teardown + - Démontage de test + * - Test Template + - Modèle de test + * - Task Template + - Modèle de tâche + * - Test Timeout + - Délai de test + * - Task Timeout + - Délai de tâche + * - Test Tags + - Étiquette de test + * - Task Tags + - Étiquette de tâche + * - Keyword Tags + - Etiquette de mot-clé + * - Tags + - Étiquette + * - Setup + - Mise en place + * - Teardown + - Démontage + * - Template + - Modèle + * - Timeout + - Délai d'attente + * - Arguments + - Arguments + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Étant donné + * - When + - Lorsque + * - Then + - Alors + * - And + - Et + * - But + - Mais + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Vrai, Actif, Oui + * - False + - Faux, Désactivé, Non, Aucun + + + +Hindi (hi) +---------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - स्थापना + * - Variables + - चर + * - Test Cases + - नियत कार्य प्रवेशिका + * - Tasks + - कार्य प्रवेशिका + * - Keywords + - कुंजीशब्द + * - Comments + - टिप्पणी + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - कोड़ प्रतिबिंब संग्रह + * - Resource + - संसाधन + * - Variables + - चर + * - Documentation + - प्रलेखन + * - Metadata + - अधि-आंकड़ा + * - Suite Setup + - जांच की शुरुवात + * - Suite Teardown + - परीक्षण कार्य अंत + * - Test Setup + - परीक्षण कार्य प्रारंभ + * - Task Setup + - परीक्षण कार्य प्रारंभ + * - Test Teardown + - परीक्षण कार्य अंत + * - Task Teardown + - परीक्षण कार्य अंत + * - Test Template + - परीक्षण ढांचा + * - Task Template + - परीक्षण ढांचा + * - Test Timeout + - परीक्षण कार्य समय समाप्त + * - Task Timeout + - कार्य समयबाह्य + * - Test Tags + - जाँचका उपनाम + * - Task Tags + - कार्यका उपनाम + * - Keyword Tags + - कुंजीशब्द का उपनाम + * - Tags + - निशान + * - Setup + - व्यवस्थापना + * - Teardown + - विमोचन + * - Template + - साँचा + * - Timeout + - समय समाप्त + * - Arguments + - प्राचल + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - दिया हुआ + * - When + - जब + * - Then + - तब + * - And + - और + * - But + - परंतु + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - निश्चित, हां, यथार्थ, पर + * - False + - गलत, हालाँकि, यद्यपि, हैं, नहीं + + + +Italian (it) +------------ + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Impostazioni + * - Variables + - Variabili + * - Test Cases + - Casi Di Test + * - Tasks + - Attività + * - Keywords + - Parole Chiave + * - Comments + - Commenti + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Libreria + * - Resource + - Risorsa + * - Variables + - Variabile + * - Documentation + - Documentazione + * - Metadata + - Metadati + * - Suite Setup + - Configurazione Suite + * - Suite Teardown + - Distruzione Suite + * - Test Setup + - Configurazione Test + * - Task Setup + - Configurazione Attività + * - Test Teardown + - Distruzione Test + * - Task Teardown + - Distruzione Attività + * - Test Template + - Modello Test + * - Task Template + - Modello Attività + * - Test Timeout + - Timeout Test + * - Task Timeout + - Timeout Attività + * - Test Tags + - Tag Del Test + * - Task Tags + - Tag Attività + * - Keyword Tags + - Tag Parola Chiave + * - Tags + - Tag + * - Setup + - Configurazione + * - Teardown + - Distruzione + * - Template + - Template + * - Timeout + - Timeout + * - Arguments + - Parametri + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Dato + * - When + - Quando + * - Then + - Allora + * - And + - E + * - But + - Ma + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Vero, On, Sì + * - False + - Nessuno, No, Falso, Off + + + +Dutch (nl) +---------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Instellingen + * - Variables + - Variabelen + * - Test Cases + - Testgevallen + * - Tasks + - Taken + * - Keywords + - Sleutelwoorden + * - Comments + - Opmerkingen + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Bibliotheek + * - Resource + - Resource + * - Variables + - Variabele + * - Documentation + - Documentatie + * - Metadata + - Metadata + * - Suite Setup + - Suite Preconditie + * - Suite Teardown + - Suite Postconditie + * - Test Setup + - Test Preconditie + * - Task Setup + - Taak Preconditie + * - Test Teardown + - Test Postconditie + * - Task Teardown + - Taak Postconditie + * - Test Template + - Test Sjabloon + * - Task Template + - Taak Sjabloon + * - Test Timeout + - Test Time-out + * - Task Timeout + - Taak Time-out + * - Test Tags + - Test Labels + * - Task Tags + - Taak Labels + * - Keyword Tags + - Sleutelwoord Labels + * - Tags + - Labels + * - Setup + - Preconditie + * - Teardown + - Postconditie + * - Template + - Sjabloon + * - Timeout + - Time-out + * - Arguments + - Parameters + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Stel, Gegeven + * - When + - Als + * - Then + - Dan + * - And + - En + * - But + - Maar + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Waar, Ja, Aan + * - False + - Nee, Onwaar, Geen, Uit + + + +Polish (pl) +----------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Ustawienia + * - Variables + - Zmienne + * - Test Cases + - Przypadki testowe + * - Tasks + - Zadania + * - Keywords + - Słowa kluczowe + * - Comments + - Komentarze + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Biblioteka + * - Resource + - Zasób + * - Variables + - Zmienne + * - Documentation + - Dokumentacja + * - Metadata + - Metadane + * - Suite Setup + - Inicjalizacja zestawu + * - Suite Teardown + - Ukończenie zestawu + * - Test Setup + - Inicjalizacja testu + * - Task Setup + - Inicjalizacja zadania + * - Test Teardown + - Ukończenie testu + * - Task Teardown + - Ukończenie zadania + * - Test Template + - Szablon testu + * - Task Template + - Szablon zadania + * - Test Timeout + - Limit czasowy testu + * - Task Timeout + - Limit czasowy zadania + * - Test Tags + - Znaczniki testu + * - Task Tags + - Znaczniki zadania + * - Keyword Tags + - Znaczniki słowa kluczowego + * - Tags + - Znaczniki + * - Setup + - Inicjalizacja + * - Teardown + - Ukończenie + * - Template + - Szablon + * - Timeout + - Limit czasowy + * - Arguments + - Argumenty + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Zakładając, że, Zakładając, Mając + * - When + - Gdy, Kiedy, Jeżeli, Jeśli + * - Then + - Wtedy + * - And + - I, Oraz + * - But + - Ale + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - + * - False + - + + + +Portuguese (pt) +--------------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Definições + * - Variables + - Variáveis + * - Test Cases + - Casos de Teste + * - Tasks + - Tarefas + * - Keywords + - Palavras-Chave + * - Comments + - Comentários + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Biblioteca + * - Resource + - Recurso + * - Variables + - Variável + * - Documentation + - Documentação + * - Metadata + - Metadados + * - Suite Setup + - Inicialização de Suíte + * - Suite Teardown + - Finalização de Suíte + * - Test Setup + - Inicialização de Teste + * - Task Setup + - Inicialização de Tarefa + * - Test Teardown + - Finalização de Teste + * - Task Teardown + - Finalização de Tarefa + * - Test Template + - Modelo de Teste + * - Task Template + - Modelo de Tarefa + * - Test Timeout + - Tempo Limite de Teste + * - Task Timeout + - Tempo Limite de Tarefa + * - Test Tags + - Etiquetas de Testes + * - Task Tags + - Etiquetas de Tarefas + * - Keyword Tags + - Etiquetas de Palavras-Chave + * - Tags + - Etiquetas + * - Setup + - Inicialização + * - Teardown + - Finalização + * - Template + - Modelo + * - Timeout + - Tempo Limite + * - Arguments + - Argumentos + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Dado + * - When + - Quando + * - Then + - Então + * - And + - E + * - But + - Mas + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Ligado, Verdadeiro, Verdade, Sim + * - False + - Desativado, Falso, Desligado, Nada, Não + + + +Brazilian Portuguese (pt-BR) +---------------------------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Configurações + * - Variables + - Variáveis + * - Test Cases + - Casos de Teste + * - Tasks + - Tarefas + * - Keywords + - Palavras-Chave + * - Comments + - Comentários + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Biblioteca + * - Resource + - Recurso + * - Variables + - Variável + * - Documentation + - Documentação + * - Metadata + - Metadados + * - Suite Setup + - Configuração da Suíte + * - Suite Teardown + - Finalização de Suíte + * - Test Setup + - Inicialização de Teste + * - Task Setup + - Inicialização de Tarefa + * - Test Teardown + - Finalização de Teste + * - Task Teardown + - Finalização de Tarefa + * - Test Template + - Modelo de Teste + * - Task Template + - Modelo de Tarefa + * - Test Timeout + - Tempo Limite de Teste + * - Task Timeout + - Tempo Limite de Tarefa + * - Test Tags + - Test Tags + * - Task Tags + - Task Tags + * - Keyword Tags + - Keyword Tags + * - Tags + - Etiquetas + * - Setup + - Inicialização + * - Teardown + - Finalização + * - Template + - Modelo + * - Timeout + - Tempo Limite + * - Arguments + - Argumentos + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Dado + * - When + - Quando + * - Then + - Então + * - And + - E + * - But + - Mas + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Ligado, Verdadeiro, Verdade, Sim + * - False + - Desativado, Falso, Desligado, Nada, Não + + + +Romanian (ro) +------------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Setari + * - Variables + - Variabile + * - Test Cases + - Cazuri De Test + * - Tasks + - Sarcini + * - Keywords + - Cuvinte Cheie + * - Comments + - Comentarii + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Librarie + * - Resource + - Resursa + * - Variables + - Variabila + * - Documentation + - Documentatie + * - Metadata + - Metadate + * - Suite Setup + - Configurare De Suita + * - Suite Teardown + - Configurare De Intrerupere + * - Test Setup + - Setare De Test + * - Task Setup + - Configuarare activitate + * - Test Teardown + - Inrerupere De Test + * - Task Teardown + - Intrerupere activitate + * - Test Template + - Sablon De Test + * - Task Template + - Sablon de activitate + * - Test Timeout + - Timp Expirare Test + * - Task Timeout + - Timp de expirare activitate + * - Test Tags + - Taguri De Test + * - Task Tags + - Etichete activitate + * - Keyword Tags + - Etichete metode + * - Tags + - Etichete + * - Setup + - Setare + * - Teardown + - Intrerupere + * - Template + - Sablon + * - Timeout + - Expirare + * - Arguments + - Argumente + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Fie ca + * - When + - Cand + * - Then + - Atunci + * - And + - Si + * - But + - Dar + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Da, Cand, Adevarat + * - False + - Nu, Niciun, Fals, Oprit + + + +Russian (ru) +------------ + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Настройки + * - Variables + - Переменные + * - Test Cases + - Заголовки тестов + * - Tasks + - Задача + * - Keywords + - Ключевые слова + * - Comments + - Комментарии + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Библиотека + * - Resource + - Ресурс + * - Variables + - Переменные + * - Documentation + - Документация + * - Metadata + - Метаданные + * - Suite Setup + - Инициализация комплекта тестов + * - Suite Teardown + - Завершение комплекта тестов + * - Test Setup + - Инициализация теста + * - Task Setup + - Инициализация задания + * - Test Teardown + - Завершение теста + * - Task Teardown + - Завершение задания + * - Test Template + - Шаблон теста + * - Task Template + - Шаблон задания + * - Test Timeout + - Лимит выполнения теста + * - Task Timeout + - Лимит задания + * - Test Tags + - Теги тестов + * - Task Tags + - Метки заданий + * - Keyword Tags + - Метки ключевых слов + * - Tags + - Метки + * - Setup + - Инициализация + * - Teardown + - Завершение + * - Template + - Шаблон + * - Timeout + - Лимит + * - Arguments + - Аргументы + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Дано + * - When + - Когда + * - Then + - Тогда + * - And + - И + * - But + - Но + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - + * - False + - + + + +Swedish (sv) +------------ + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Inställningar + * - Variables + - Variabler + * - Test Cases + - Testfall + * - Tasks + - Taskar + * - Keywords + - Nyckelord + * - Comments + - Kommentarer + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Bibliotek + * - Resource + - Resurs + * - Variables + - Variabel + * - Documentation + - Dokumentation + * - Metadata + - Metadata + * - Suite Setup + - Svit konfigurering + * - Suite Teardown + - Svit nedrivning + * - Test Setup + - Test konfigurering + * - Task Setup + - Task konfigurering + * - Test Teardown + - Test nedrivning + * - Task Teardown + - Task nedrivning + * - Test Template + - Test mall + * - Task Template + - Task mall + * - Test Timeout + - Test timeout + * - Task Timeout + - Task timeout + * - Test Tags + - Test taggar + * - Task Tags + - Arbetsuppgift taggar + * - Keyword Tags + - Nyckelord taggar + * - Tags + - Taggar + * - Setup + - Konfigurering + * - Teardown + - Nedrivning + * - Template + - Mall + * - Timeout + - Timeout + * - Arguments + - Argument + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Givet + * - When + - När + * - Then + - Då + * - And + - Och + * - But + - Men + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Sant, Ja, På + * - False + - Nej, Av, Ingen, Falskt + + + +Thai (th) +--------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - การตั้งค่า + * - Variables + - กำหนดตัวแปร + * - Test Cases + - การทดสอบ + * - Tasks + - งาน + * - Keywords + - คำสั่งเพิ่มเติม + * - Comments + - คำอธิบาย + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - ชุดคำสั่งที่ใช้ + * - Resource + - ไฟล์ที่ใช้ + * - Variables + - ชุดตัวแปร + * - Documentation + - เอกสาร + * - Metadata + - รายละเอียดเพิ่มเติม + * - Suite Setup + - กำหนดค่าเริ่มต้นของชุดการทดสอบ + * - Suite Teardown + - คืนค่าของชุดการทดสอบ + * - Test Setup + - กำหนดค่าเริ่มต้นของการทดสอบ + * - Task Setup + - กำหนดค่าเริ่มต้นของงาน + * - Test Teardown + - คืนค่าของการทดสอบ + * - Task Teardown + - คืนค่าของงาน + * - Test Template + - โครงสร้างของการทดสอบ + * - Task Template + - โครงสร้างของงาน + * - Test Timeout + - เวลารอของการทดสอบ + * - Task Timeout + - เวลารอของงาน + * - Test Tags + - กลุ่มของการทดสอบ + * - Task Tags + - กลุ่มของงาน + * - Keyword Tags + - กลุ่มของคำสั่งเพิ่มเติม + * - Tags + - กลุ่ม + * - Setup + - กำหนดค่าเริ่มต้น + * - Teardown + - คืนค่า + * - Template + - โครงสร้าง + * - Timeout + - หมดเวลา + * - Arguments + - ค่าที่ส่งเข้ามา + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - กำหนดให้ + * - When + - เมื่อ + * - Then + - ดังนั้น + * - And + - และ + * - But + - แต่ + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - + * - False + - + + + +Turkish (tr) +------------ + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Ayarlar + * - Variables + - Değişkenler + * - Test Cases + - Test Durumları + * - Tasks + - Görevler + * - Keywords + - Anahtar Kelimeler + * - Comments + - Yorumlar + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Kütüphane + * - Resource + - Kaynak + * - Variables + - Değişkenler + * - Documentation + - Dokümantasyon + * - Metadata + - Üstveri + * - Suite Setup + - Takım Kurulumu + * - Suite Teardown + - Takım Bitişi + * - Test Setup + - Test Kurulumu + * - Task Setup + - Görev Kurulumu + * - Test Teardown + - Test Bitişi + * - Task Teardown + - Görev Bitişi + * - Test Template + - Test Taslağı + * - Task Template + - Görev Taslağı + * - Test Timeout + - Test Zaman Aşımı + * - Task Timeout + - Görev Zaman Aşımı + * - Test Tags + - Test Etiketleri + * - Task Tags + - Görev Etiketleri + * - Keyword Tags + - Anahtar Kelime Etiketleri + * - Tags + - Etiketler + * - Setup + - Kurulum + * - Teardown + - Bitiş + * - Template + - Taslak + * - Timeout + - Zaman Aşımı + * - Arguments + - Argümanlar + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Diyelim ki + * - When + - Eğer ki + * - Then + - O zaman + * - And + - Ve + * - But + - Ancak + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Doğru, Evet, Açik + * - False + - Hayir, Yanliş, Kapali + + + +Ukrainian (uk) +-------------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Налаштування + * - Variables + - Змінні + * - Test Cases + - Тест-кейси + * - Tasks + - Завдань + * - Keywords + - Ключових слова + * - Comments + - Коментарів + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Бібліотека + * - Resource + - Ресурс + * - Variables + - Змінна + * - Documentation + - Документація + * - Metadata + - Метадані + * - Suite Setup + - Налаштування Suite + * - Suite Teardown + - Розбірка Suite + * - Test Setup + - Налаштування тесту + * - Task Setup + - Налаштування завдання + * - Test Teardown + - Розбирання тестy + * - Task Teardown + - Розбір завдання + * - Test Template + - Тестовий шаблон + * - Task Template + - Шаблон завдання + * - Test Timeout + - Час тестування + * - Task Timeout + - Час очікування завдання + * - Test Tags + - Тестові теги + * - Task Tags + - Теги завдань + * - Keyword Tags + - Теги ключових слів + * - Tags + - Теги + * - Setup + - Встановлення + * - Teardown + - Cпростовувати пункт за пунктом + * - Template + - Шаблон + * - Timeout + - Час вийшов + * - Arguments + - Аргументи + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Дано + * - When + - Коли + * - Then + - Тоді + * - And + - Та + * - But + - Але + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - + * - False + - + + + +Chinese Simplified (zh-CN) +-------------------------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - 设置 + * - Variables + - 变量 + * - Test Cases + - 用例 + * - Tasks + - 任务 + * - Keywords + - 关键字 + * - Comments + - 备注 + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - 程序库 + * - Resource + - 资源文件 + * - Variables + - 变量文件 + * - Documentation + - 说明 + * - Metadata + - 元数据 + * - Suite Setup + - 用例集启程 + * - Suite Teardown + - 用例集终程 + * - Test Setup + - 用例启程 + * - Task Setup + - 任务启程 + * - Test Teardown + - 用例终程 + * - Task Teardown + - 任务终程 + * - Test Template + - 用例模板 + * - Task Template + - 任务模板 + * - Test Timeout + - 用例超时 + * - Task Timeout + - 任务超时 + * - Test Tags + - 用例标签 + * - Task Tags + - 任务标签 + * - Keyword Tags + - 关键字标签 + * - Tags + - 标签 + * - Setup + - 启程 + * - Teardown + - 终程 + * - Template + - 模板 + * - Timeout + - 超时 + * - Arguments + - 参数 + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - 假定 + * - When + - 当 + * - Then + - 那么 + * - And + - 并且 + * - But + - 但是 + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - 是, 开, 真 + * - False + - 假, 关, 否, 空 + + + +Chinese Traditional (zh-TW) +--------------------------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - 設置 + * - Variables + - 變量 + * - Test Cases + - 案例 + * - Tasks + - 任務 + * - Keywords + - 關鍵字 + * - Comments + - 備註 + +Settings +~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - 函式庫 + * - Resource + - 資源文件 + * - Variables + - 變量文件 + * - Documentation + - 說明 + * - Metadata + - 元數據 + * - Suite Setup + - 測試套啟程 + * - Suite Teardown + - 測試套終程 + * - Test Setup + - 測試啟程 + * - Task Setup + - 任務啟程 + * - Test Teardown + - 測試終程 + * - Task Teardown + - 任務終程 + * - Test Template + - 測試模板 + * - Task Template + - 任務模板 + * - Test Timeout + - 測試逾時 + * - Task Timeout + - 任務逾時 + * - Test Tags + - 測試標籤 + * - Task Tags + - 任務標籤 + * - Keyword Tags + - 關鍵字標籤 + * - Tags + - 標籤 + * - Setup + - 啟程 + * - Teardown + - 終程 + * - Template + - 模板 + * - Timeout + - 逾時 + * - Arguments + - 参数 + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - 假定 + * - When + - 當 + * - Then + - 那麼 + * - And + - 並且 + * - But + - 但是 + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - 是, 開, 真 + * - False + - 假, 關, 否, 空 From 6e1332fa56cb4d99df64b6f592a6e3d3e1c29b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 18 Oct 2022 03:27:53 +0300 Subject: [PATCH 0269/1592] Document available translations. #4390 Actual localization documentation still missing. --- .../src/Appendices/AvailableSettings.rst | 11 +- .../src/Appendices/CommandLineOptions.rst | 4 +- .../src/Appendices/EvaluatingExpressions.rst | 15 +- doc/userguide/src/Appendices/TimeFormat.rst | 4 + doc/userguide/src/Appendices/Translations.rst | 201 ++++++++++++------ .../CreatingTestData/CreatingTestCases.rst | 3 + .../src/CreatingTestData/TestDataSyntax.rst | 36 ++++ .../src/CreatingTestData/Variables.rst | 4 +- .../CreatingTestLibraries.rst | 3 + doc/userguide/src/RobotFrameworkUserGuide.rst | 4 +- ...cument_translations.py => translations.py} | 62 ++++-- doc/userguide/ug2html.py | 4 + 12 files changed, 259 insertions(+), 92 deletions(-) rename doc/userguide/{document_translations.py => translations.py} (73%) diff --git a/doc/userguide/src/Appendices/AvailableSettings.rst b/doc/userguide/src/Appendices/AvailableSettings.rst index 0cbd08bf626..c268305b540 100644 --- a/doc/userguide/src/Appendices/AvailableSettings.rst +++ b/doc/userguide/src/Appendices/AvailableSettings.rst @@ -1,5 +1,10 @@ -All available settings in test data -=================================== +Available settings +================== + +This appendix lists all settings that can be used in different sections. + +.. note:: Settings can be localized_. See the Translations_ appendix for + supported translations. .. contents:: :depth: 2 @@ -8,7 +13,7 @@ All available settings in test data Setting section --------------- -The Setting section is used to import test libraries, resource files and +The Setting section is used to import libraries, resource files and variable files and to define metadata for test suites and test cases. It can be included in test case files and resource files. Note that in a resource file, a Setting section can only include settings for diff --git a/doc/userguide/src/Appendices/CommandLineOptions.rst b/doc/userguide/src/Appendices/CommandLineOptions.rst index d8abdaf2d17..c4477cdc3a8 100644 --- a/doc/userguide/src/Appendices/CommandLineOptions.rst +++ b/doc/userguide/src/Appendices/CommandLineOptions.rst @@ -1,5 +1,5 @@ -All command line options -======================== +Command line options +==================== This appendix lists all the command line options that are available when `executing test cases`_ and when `post-processing outputs`_. diff --git a/doc/userguide/src/Appendices/EvaluatingExpressions.rst b/doc/userguide/src/Appendices/EvaluatingExpressions.rst index c26b047baca..5f0bb21d192 100644 --- a/doc/userguide/src/Appendices/EvaluatingExpressions.rst +++ b/doc/userguide/src/Appendices/EvaluatingExpressions.rst @@ -1,13 +1,23 @@ Evaluating expressions ====================== +This appendix explains how expressions are evaluated using Python in different +contexts and how variables in expressions are handled. + +.. contents:: + :depth: 2 + :local: + +Introduction +------------ + Constructs such as `IF/ELSE structures`_, `WHILE loops`_ and `inline Python evaluation`_ as well as several BuiltIn_ keywords accept an expression that is evaluated in Python: .. sourcecode:: robotframework *** Test Cases *** - If expression + IF/ELSE IF ${x} > 0 Log to console ${x} is positive ELSE @@ -24,8 +34,7 @@ as well as several BuiltIn_ keywords accept an expression that is evaluated in P Should Be True keyword Should Be True ${x} > 0 -This section explains how the expression is evaluated and how variables in -the expression are handled. Notice that instead of creating complicated +Notice that instead of creating complicated expressions, it is often better to move the logic into a `test library`_. That typically eases maintenance and also enhances execution speed. diff --git a/doc/userguide/src/Appendices/TimeFormat.rst b/doc/userguide/src/Appendices/TimeFormat.rst index 03e345fd5a4..87bb0a2b5e5 100644 --- a/doc/userguide/src/Appendices/TimeFormat.rst +++ b/doc/userguide/src/Appendices/TimeFormat.rst @@ -6,6 +6,10 @@ to understand. It is used by several keywords (for example, BuiltIn_ keywords :name:`Sleep` and :name:`Wait Until Keyword Succeeds`), DateTime_ library, and `timeouts`_. +.. contents:: + :depth: 2 + :local: + Time as number -------------- diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index ac9a3bfb1ab..efaa71d9301 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -1,14 +1,27 @@ Translations ============ -Robot Framework supports translating `section headers`_, settings__, Given/Whe +Robot Framework supports translating `section headers`__, settings_, +`Given/When/Then prefixes`__ used in Behavior Driven Development (BDD) +as well as `true and false strings`__ used in automatic Boolean argument +conversion. This appendix lists all translations for all languages, +excluding English, that Robot Framework supports out-of-the-box. + +How to actually activate translations is explained in the Localization_ section. +That section also explains how to create custom translations, +how to contribute new translations, and how to enhance existing ones. + +__ `Test data sections`_ +__ `Behavior-driven style`_ +__ `Supported conversions`_ + .. contents:: - :depth: 2 + :depth: 1 :local: -.. Content below has been generated using `document_translations.py`. -.. Don't edit manually, update the script instead. -.. GENERATED CONTENT BEGINS +.. Content below has been generated using translations.py used by ug2html.py. + +.. START GENERATED CONTENT Bulgarian (bg) -------------- @@ -17,6 +30,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -40,6 +54,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -99,6 +114,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -120,6 +136,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -127,10 +144,9 @@ Boolean strings * - True/False - Values * - True - - Включен, Вярно, Да + - Да, Включен, Вярно * - False - - Изключен, Нищо, Не, Невярно - + - Невярно, Нищо, Изключен, Не Bosnian (bs) @@ -140,6 +156,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -163,6 +180,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -222,6 +240,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -243,6 +262,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -255,7 +275,6 @@ Boolean strings - - Czech (cs) ---------- @@ -263,6 +282,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -286,6 +306,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -345,6 +366,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -366,6 +388,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -373,10 +396,9 @@ Boolean strings * - True/False - Values * - True - - Zapnuto, Ano, Pravda + - Zapnuto, Pravda, Ano * - False - - Ne, Nic, Vypnuto, Nepravda - + - Nic, Nepravda, Vypnuto, Ne German (de) @@ -386,6 +408,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -409,6 +432,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -446,11 +470,11 @@ Settings * - Task Timeout - Aufgabenzeitlimit * - Test Tags - - Test Marker + - Testmarker * - Task Tags - - Aufgaben Marker + - Aufgabenmarker * - Keyword Tags - - Schlüsselwort Marker + - Schlüsselwortmarker * - Tags - Marker * - Setup @@ -468,6 +492,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -489,6 +514,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -496,10 +522,9 @@ Boolean strings * - True/False - Values * - True - - Ein, Ja, Wahr, An + - Wahr, Ja, An, Ein * - False - - Nein, Aus, Unwahr, Falsch - + - Nein, Aus, Falsch, Unwahr Spanish (es) @@ -509,6 +534,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -532,6 +558,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -591,6 +618,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -612,6 +640,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -619,10 +648,9 @@ Boolean strings * - True/False - Values * - True - - Verdadero, On, Si + - On, Si, Verdadero * - False - - Ninguno, No, Falso, Off - + - Ninguno, No, Off, Falso Finnish (fi) @@ -632,6 +660,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -655,6 +684,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -714,6 +744,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -735,6 +766,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -742,12 +774,11 @@ Boolean strings * - True/False - Values * - True - - Tosi, Kyllä, Päällä + - Tosi, Päällä, Kyllä * - False - Ei, Pois, Epätosi - French (fr) ----------- @@ -755,6 +786,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -778,6 +810,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -837,6 +870,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -858,6 +892,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -865,10 +900,9 @@ Boolean strings * - True/False - Values * - True - - Vrai, Actif, Oui + - Vrai, Oui, Actif * - False - - Faux, Désactivé, Non, Aucun - + - Désactivé, Non, Faux, Aucun Hindi (hi) @@ -878,6 +912,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -901,6 +936,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -960,6 +996,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -981,6 +1018,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -988,10 +1026,9 @@ Boolean strings * - True/False - Values * - True - - निश्चित, हां, यथार्थ, पर + - हां, यथार्थ, निश्चित, पर * - False - - गलत, हालाँकि, यद्यपि, हैं, नहीं - + - हालाँकि, नहीं, गलत, यद्यपि, हैं Italian (it) @@ -1001,6 +1038,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1024,6 +1062,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1083,6 +1122,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1104,6 +1144,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1111,10 +1152,9 @@ Boolean strings * - True/False - Values * - True - - Vero, On, Sì + - On, Sì, Vero * - False - - Nessuno, No, Falso, Off - + - Nessuno, No, Off, Falso Dutch (nl) @@ -1124,6 +1164,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1147,6 +1188,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1206,6 +1248,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1213,7 +1256,7 @@ BDD prefixes * - Prefix - Translation * - Given - - Stel, Gegeven + - Gegeven, Stel * - When - Als * - Then @@ -1227,6 +1270,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1234,12 +1278,11 @@ Boolean strings * - True/False - Values * - True - - Waar, Ja, Aan + - Ja, Aan, Waar * - False - Nee, Onwaar, Geen, Uit - Polish (pl) ----------- @@ -1247,6 +1290,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1270,6 +1314,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1329,6 +1374,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1336,13 +1382,13 @@ BDD prefixes * - Prefix - Translation * - Given - - Zakładając, że, Zakładając, Mając + - Mając, Zakładając, Zakładając, że * - When - - Gdy, Kiedy, Jeżeli, Jeśli + - Jeśli, Jeżeli, Gdy, Kiedy * - Then - Wtedy * - And - - I, Oraz + - Oraz, I * - But - Ale @@ -1350,6 +1396,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1362,7 +1409,6 @@ Boolean strings - - Portuguese (pt) --------------- @@ -1370,6 +1416,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1393,6 +1440,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1452,6 +1500,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1473,6 +1522,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1480,10 +1530,9 @@ Boolean strings * - True/False - Values * - True - - Ligado, Verdadeiro, Verdade, Sim + - Verdade, Verdadeiro, Sim, Ligado * - False - - Desativado, Falso, Desligado, Nada, Não - + - Nada, Desligado, Desativado, Falso, Não Brazilian Portuguese (pt-BR) @@ -1493,6 +1542,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1516,6 +1566,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1575,6 +1626,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1596,6 +1648,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1603,10 +1656,9 @@ Boolean strings * - True/False - Values * - True - - Ligado, Verdadeiro, Verdade, Sim + - Verdade, Verdadeiro, Sim, Ligado * - False - - Desativado, Falso, Desligado, Nada, Não - + - Nada, Desligado, Desativado, Falso, Não Romanian (ro) @@ -1616,6 +1668,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1639,6 +1692,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1698,6 +1752,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1719,6 +1774,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1726,10 +1782,9 @@ Boolean strings * - True/False - Values * - True - - Da, Cand, Adevarat + - Cand, Adevarat, Da * - False - - Nu, Niciun, Fals, Oprit - + - Niciun, Fals, Oprit, Nu Russian (ru) @@ -1739,6 +1794,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1762,6 +1818,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1821,6 +1878,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1842,6 +1900,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1854,7 +1913,6 @@ Boolean strings - - Swedish (sv) ------------ @@ -1862,6 +1920,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1885,6 +1944,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1944,6 +2004,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1965,6 +2026,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -1972,10 +2034,9 @@ Boolean strings * - True/False - Values * - True - - Sant, Ja, På + - Ja, Sant, På * - False - - Nej, Av, Ingen, Falskt - + - Av, Falskt, Ingen, Nej Thai (th) @@ -1985,6 +2046,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2008,6 +2070,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2067,6 +2130,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2088,6 +2152,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2100,7 +2165,6 @@ Boolean strings - - Turkish (tr) ------------ @@ -2108,6 +2172,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2131,6 +2196,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2190,6 +2256,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2211,6 +2278,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2218,10 +2286,9 @@ Boolean strings * - True/False - Values * - True - - Doğru, Evet, Açik + - Açik, Evet, Doğru * - False - - Hayir, Yanliş, Kapali - + - Kapali, Yanliş, Hayir Ukrainian (uk) @@ -2231,6 +2298,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2254,6 +2322,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2313,6 +2382,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2334,6 +2404,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2346,7 +2417,6 @@ Boolean strings - - Chinese Simplified (zh-CN) -------------------------- @@ -2354,6 +2424,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2377,6 +2448,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2436,6 +2508,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2457,6 +2530,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2464,10 +2538,9 @@ Boolean strings * - True/False - Values * - True - - 是, 开, 真 + - 开, 是, 真 * - False - - 假, 关, 否, 空 - + - 空, 关, 假, 否 Chinese Traditional (zh-TW) @@ -2477,6 +2550,7 @@ Section headers ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2500,6 +2574,7 @@ Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2559,6 +2634,7 @@ BDD prefixes ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2580,6 +2656,7 @@ Boolean strings ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 @@ -2587,6 +2664,6 @@ Boolean strings * - True/False - Values * - True - - 是, 開, 真 + - 開, 是, 真 * - False - - 假, 關, 否, 空 + - 空, 假, 關, 否 diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index 2f58072d85c..cba83d6f19b 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -1093,6 +1093,9 @@ also allows using the same keyword with different prefixes. For example :name:`Welcome page should be open` could also used as :name:`And welcome page should be open`. +.. note:: These prefixes can be localized_. See the Translations_ appendix + for supported translations. + Embedding data to keywords '''''''''''''''''''''''''' diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 3af47cb464d..43381c7d8a1 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -94,6 +94,9 @@ purposes. This is especially useful when creating test cases using the Possible data before the first section is ignored. +.. note:: Section headers can be localized_. See the Translations_ appendix for + supported translations. + Supported file formats ---------------------- @@ -546,3 +549,36 @@ __ `Newlines in test data`_ ${var} = Get X ... first argument passed to this keyword is pretty long ... second argument passed to this keyword is long too + +Localization +------------ + +TODO + +.. Content below has been generated using translations.py used by ug2html.py. + +.. START GENERATED CONTENT + +Supported languages: + +- `Bulgarian (bg)`_ +- `Bosnian (bs)`_ +- `Czech (cs)`_ +- `German (de)`_ +- `Spanish (es)`_ +- `Finnish (fi)`_ +- `French (fr)`_ +- `Hindi (hi)`_ +- `Italian (it)`_ +- `Dutch (nl)`_ +- `Polish (pl)`_ +- `Portuguese (pt)`_ +- `Brazilian Portuguese (pt-BR)`_ +- `Romanian (ro)`_ +- `Russian (ru)`_ +- `Swedish (sv)`_ +- `Thai (th)`_ +- `Turkish (tr)`_ +- `Ukrainian (uk)`_ +- `Chinese Simplified (zh-CN)`_ +- `Chinese Traditional (zh-TW)`_ diff --git a/doc/userguide/src/CreatingTestData/Variables.rst b/doc/userguide/src/CreatingTestData/Variables.rst index c835d02525e..5ac7d53be76 100644 --- a/doc/userguide/src/CreatingTestData/Variables.rst +++ b/doc/userguide/src/CreatingTestData/Variables.rst @@ -224,7 +224,7 @@ other list variables. Using list variables with settings '''''''''''''''''''''''''''''''''' -List variables can be used only with some of the settings__. They can +List variables can be used only with some of the settings_. They can be used in arguments to imported libraries and variable files, but library and variable file names themselves cannot be list variables. Also with setups and teardowns list variable can not be used @@ -243,8 +243,6 @@ those places where list variables are not supported. Suite Setup @{KEYWORD AND ARGS} # This does not work Default Tags @{TAGS} # This works -__ `All available settings in test data`_ - .. _dictionary variable: .. _dictionary variables: .. _dictionary expansion: diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index aef8d3795e4..6e97bfff4b5 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1187,6 +1187,9 @@ Other types cause conversion failures. | | | | None_ | to `None`. Other strings and other accepted values are | | | | | | | passed as-is, allowing keywords to handle them specially if | | | | | | | needed. All string comparisons are case-insensitive. | | + | | | | | | | + | | | | | True and false strings can be localized_. See the | | + | | | | | Translations_ appendix for supported translations. | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | int_ | Integral_ | integer, | str_, | Conversion is done using the int_ built-in function. Floats | | `42` | | | | long | float_ | are accepted only if they can be represented as integers | | `-1` | diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index 7831ddd2792..6088aac4d92 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -103,6 +103,7 @@ .. include:: Appendices/AvailableSettings.rst .. include:: Appendices/CommandLineOptions.rst +.. include:: Appendices/Translations.rst .. include:: Appendices/DocumentationFormatting.rst .. include:: Appendices/TimeFormat.rst .. include:: Appendices/BooleanArguments.rst @@ -168,6 +169,7 @@ .. _`With Name syntax`: `Setting custom name to test library`_ .. _SeleniumLibrary: https://github.com/robotframework/SeleniumLibrary .. _SwingLibrary: https://github.com/robotframework/SwingLibrary +.. _localized: Localization_ .. 3. Executing test cases @@ -203,7 +205,7 @@ .. 5. Appendices .. _HTML formatting: `Documentation formatting`_ -.. _command line options: `All command line options`_ +.. _settings: `Available settings`_ .. 6. Misc diff --git a/doc/userguide/document_translations.py b/doc/userguide/translations.py similarity index 73% rename from doc/userguide/document_translations.py rename to doc/userguide/translations.py index 9252bfcaa9f..8dc407b60b7 100644 --- a/doc/userguide/document_translations.py +++ b/doc/userguide/translations.py @@ -1,4 +1,13 @@ from pathlib import Path +import sys + + +CURDIR = Path(__file__).absolute().parent +TRANSLATIONS = CURDIR / 'src/Appendices/Translations.rst' +LOCALIZATION = CURDIR / 'src/CreatingTestData/TestDataSyntax.rst' + +sys.path.insert(0, str(CURDIR / '../../src')) + from robot.api import Language @@ -53,10 +62,11 @@ def false_strings(self): ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 - + * - Header - Translation * - Settings @@ -71,15 +81,16 @@ def false_strings(self): - {lang.keywords_header} * - Comments - {lang.comments_header} - + Settings ~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 - + * - Setting - Translation * - Library @@ -135,10 +146,11 @@ def false_strings(self): ~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 - + * - Prefix - Translation * - Given @@ -156,10 +168,11 @@ def false_strings(self): ~~~~~~~~~~~~~~~ .. list-table:: + :class: tabular :width: 40em :widths: 2 3 :header-rows: 1 - + * - True/False - Values * - True @@ -169,20 +182,33 @@ def false_strings(self): ''' -def document_translations(file): - languages = [lang for lang in Language.__subclasses__() if lang.code != 'en'] - for index, lang in enumerate(sorted(languages, key=lambda lang: lang.code)): - file.write(TEMPLATE.format(lang=LanguageWrapper(lang))) +def update_translations(): + languages = sorted([lang for lang in Language.__subclasses__() if lang.code != 'en'], + key=lambda lang: lang.code) + update(TRANSLATIONS, generate_docs(languages)) + update(LOCALIZATION, list_translations(languages)) + + +def generate_docs(languages): + for index, lang in enumerate(languages): + yield from TEMPLATE.format(lang=LanguageWrapper(lang)).splitlines() if index < len(languages) - 1: - file.write('\n\n') + yield '' + + +def list_translations(languages): + yield from ['', 'Supported languages:', ''] + for lang in languages: + yield f'- `{lang.name} ({lang.code})`_' -if __name__ == '__main__': - target = Path(__file__).absolute().parent / 'src/Appendices/Translations.rst' - source = target.read_text(encoding='UTF-8') - with open(target, 'w', encoding='UTF-8') as file: - for line in source.splitlines(keepends=True): - file.write(line) - if line == '.. GENERATED CONTENT BEGINS\n': +def update(path: Path, content): + source = path.read_text(encoding='UTF-8').splitlines() + write = True + with open(path, 'w') as file: + for line in source: + file.write(line + '\n') + if line == '.. START GENERATED CONTENT': break - document_translations(file) + for line in content: + file.write(line.rstrip() + '\n') diff --git a/doc/userguide/ug2html.py b/doc/userguide/ug2html.py index b278c71c891..f041dab9f57 100755 --- a/doc/userguide/ug2html.py +++ b/doc/userguide/ug2html.py @@ -24,6 +24,8 @@ import sys import shutil +from translations import update_translations + # First part of this file is Pygments configuration and actual # documentation generation follows it. # @@ -146,6 +148,8 @@ def create_userguide(): from docutils.core import publish_cmdline print('Creating user guide ...') + print('Updating translations') + update_translations() version, version_file = _update_version() install_file = _copy_installation_instructions() From 3a6d82ffdffc8d2ffa572c7b0faaf5a853007b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 18 Oct 2022 16:19:59 +0300 Subject: [PATCH 0270/1592] Small changes to robot.api.Language API. - BDD prefixes and true/false strings are now lists, not sets, to preserve their order. - BDD prefix attributes were renamed from singulare to plural like `given_prefix` -> `given_prefixes`. These are unfortunate changes so late in RF 6.0 release cycle, but leaving bad APIs or changing them already in the next release would have been worse. --- doc/userguide/src/Appendices/Translations.rst | 88 ++--- doc/userguide/translations.py | 60 ++-- src/robot/conf/languages.py | 307 +++++++++--------- 3 files changed, 222 insertions(+), 233 deletions(-) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index efaa71d9301..2401d92a7a8 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -19,9 +19,9 @@ __ `Supported conversions`_ :depth: 1 :local: -.. Content below has been generated using translations.py used by ug2html.py. .. START GENERATED CONTENT +.. Generated by translations.py used by ug2html.py. Bulgarian (bg) -------------- @@ -144,10 +144,9 @@ Boolean strings * - True/False - Values * - True - - Да, Включен, Вярно + - Вярно, Да, Включен * - False - - Невярно, Нищо, Изключен, Не - + - Невярно, Не, Изключен, Нищо Bosnian (bs) ------------ @@ -274,7 +273,6 @@ Boolean strings * - False - - Czech (cs) ---------- @@ -396,10 +394,9 @@ Boolean strings * - True/False - Values * - True - - Zapnuto, Pravda, Ano + - Pravda, Ano, Zapnuto * - False - - Nic, Nepravda, Vypnuto, Ne - + - Nepravda, Ne, Vypnuto, Nic German (de) ----------- @@ -524,8 +521,7 @@ Boolean strings * - True - Wahr, Ja, An, Ein * - False - - Nein, Aus, Falsch, Unwahr - + - Falsch, Nein, Aus, Unwahr Spanish (es) ------------ @@ -648,10 +644,9 @@ Boolean strings * - True/False - Values * - True - - On, Si, Verdadero + - Verdadero, Si, On * - False - - Ninguno, No, Off, Falso - + - Falso, No, Off, Ninguno Finnish (fi) ------------ @@ -774,10 +769,9 @@ Boolean strings * - True/False - Values * - True - - Tosi, Päällä, Kyllä + - Tosi, Kyllä, Päällä * - False - - Ei, Pois, Epätosi - + - Epätosi, Ei, Pois French (fr) ----------- @@ -902,8 +896,7 @@ Boolean strings * - True - Vrai, Oui, Actif * - False - - Désactivé, Non, Faux, Aucun - + - Faux, Non, Désactivé, Aucun Hindi (hi) ---------- @@ -1026,10 +1019,9 @@ Boolean strings * - True/False - Values * - True - - हां, यथार्थ, निश्चित, पर + - यथार्थ, निश्चित, हां, पर * - False - - हालाँकि, नहीं, गलत, यद्यपि, हैं - + - गलत, नहीं, हालाँकि, यद्यपि, नहीं, हैं Italian (it) ------------ @@ -1152,10 +1144,9 @@ Boolean strings * - True/False - Values * - True - - On, Sì, Vero + - Vero, Sì, On * - False - - Nessuno, No, Off, Falso - + - Falso, No, Off, Nessuno Dutch (nl) ---------- @@ -1256,7 +1247,7 @@ BDD prefixes * - Prefix - Translation * - Given - - Gegeven, Stel + - Stel, Gegeven * - When - Als * - Then @@ -1278,10 +1269,9 @@ Boolean strings * - True/False - Values * - True - - Ja, Aan, Waar + - Waar, Ja, Aan * - False - - Nee, Onwaar, Geen, Uit - + - Onwaar, Nee, Uit, Geen Polish (pl) ----------- @@ -1382,9 +1372,9 @@ BDD prefixes * - Prefix - Translation * - Given - - Mając, Zakładając, Zakładając, że + - Zakładając, Zakładając, że, Mając * - When - - Jeśli, Jeżeli, Gdy, Kiedy + - Jeżeli, Jeśli, Gdy, Kiedy * - Then - Wtedy * - And @@ -1408,7 +1398,6 @@ Boolean strings * - False - - Portuguese (pt) --------------- @@ -1530,10 +1519,9 @@ Boolean strings * - True/False - Values * - True - - Verdade, Verdadeiro, Sim, Ligado + - Verdadeiro, Verdade, Sim, Ligado * - False - - Nada, Desligado, Desativado, Falso, Não - + - Falso, Não, Desligado, Desativado, Nada Brazilian Portuguese (pt-BR) ---------------------------- @@ -1656,10 +1644,9 @@ Boolean strings * - True/False - Values * - True - - Verdade, Verdadeiro, Sim, Ligado + - Verdadeiro, Verdade, Sim, Ligado * - False - - Nada, Desligado, Desativado, Falso, Não - + - Falso, Não, Desligado, Desativado, Nada Romanian (ro) ------------- @@ -1782,10 +1769,9 @@ Boolean strings * - True/False - Values * - True - - Cand, Adevarat, Da + - Adevarat, Da, Cand * - False - - Niciun, Fals, Oprit, Nu - + - Fals, Nu, Oprit, Niciun Russian (ru) ------------ @@ -1912,7 +1898,6 @@ Boolean strings * - False - - Swedish (sv) ------------ @@ -2034,10 +2019,9 @@ Boolean strings * - True/False - Values * - True - - Ja, Sant, På + - Sant, Ja, På * - False - - Av, Falskt, Ingen, Nej - + - Falskt, Nej, Av, Ingen Thai (th) --------- @@ -2164,7 +2148,6 @@ Boolean strings * - False - - Turkish (tr) ------------ @@ -2286,10 +2269,9 @@ Boolean strings * - True/False - Values * - True - - Açik, Evet, Doğru + - Doğru, Evet, Açik * - False - - Kapali, Yanliş, Hayir - + - Yanliş, Hayir, Kapali Ukrainian (uk) -------------- @@ -2416,7 +2398,6 @@ Boolean strings * - False - - Chinese Simplified (zh-CN) -------------------------- @@ -2538,10 +2519,9 @@ Boolean strings * - True/False - Values * - True - - 开, 是, 真 + - 真, 是, 开 * - False - - 空, 关, 假, 否 - + - 假, 否, 关, 空 Chinese Traditional (zh-TW) --------------------------- @@ -2664,6 +2644,6 @@ Boolean strings * - True/False - Values * - True - - 開, 是, 真 + - 真, 是, 開 * - False - - 空, 假, 關, 否 + - 假, 否, 關, 空 diff --git a/doc/userguide/translations.py b/doc/userguide/translations.py index 8dc407b60b7..d081cbe1aff 100644 --- a/doc/userguide/translations.py +++ b/doc/userguide/translations.py @@ -18,7 +18,8 @@ def __init__(self, lang): self.lang = lang def __getattr__(self, name): - return getattr(self.lang, name) or '' + value = getattr(self.lang, name) + return value if value is not None else '' @property def underline(self): @@ -26,24 +27,24 @@ def underline(self): return '-' * width @property - def given_prefix(self): - return ', '.join(self.lang.given_prefix) + def given_prefixes(self): + return ', '.join(self.lang.given_prefixes) @property - def when_prefix(self): - return ', '.join(self.lang.when_prefix) + def when_prefixes(self): + return ', '.join(self.lang.when_prefixes) @property - def then_prefix(self): - return ', '.join(self.lang.then_prefix) + def then_prefixes(self): + return ', '.join(self.lang.then_prefixes) @property - def and_prefix(self): - return ', '.join(self.lang.and_prefix) + def and_prefixes(self): + return ', '.join(self.lang.and_prefixes) @property - def but_prefix(self): - return ', '.join(self.lang.but_prefix) + def but_prefixes(self): + return ', '.join(self.lang.but_prefixes) @property def true_strings(self): @@ -154,15 +155,15 @@ def false_strings(self): * - Prefix - Translation * - Given - - {lang.given_prefix} + - {lang.given_prefixes} * - When - - {lang.when_prefix} + - {lang.when_prefixes} * - Then - - {lang.then_prefix} + - {lang.then_prefixes} * - And - - {lang.and_prefix} + - {lang.and_prefixes} * - But - - {lang.but_prefix} + - {lang.but_prefixes} Boolean strings ~~~~~~~~~~~~~~~ @@ -190,25 +191,32 @@ def update_translations(): def generate_docs(languages): - for index, lang in enumerate(languages): + for lang in languages: yield from TEMPLATE.format(lang=LanguageWrapper(lang)).splitlines() - if index < len(languages) - 1: - yield '' def list_translations(languages): - yield from ['', 'Supported languages:', ''] + yield '' for lang in languages: yield f'- `{lang.name} ({lang.code})`_' + yield '' def update(path: Path, content): source = path.read_text(encoding='UTF-8').splitlines() - write = True with open(path, 'w') as file: - for line in source: - file.write(line + '\n') - if line == '.. START GENERATED CONTENT': - break - for line in content: + write(source, file, end_marker='.. START GENERATED CONTENT') + file.write('.. Generated by translations.py used by ug2html.py.\n') + write(content, file) + write(source, file, start_marker='.. END GENERATED CONTENT') + + +def write(lines, file, start_marker=None, end_marker=None): + include = not start_marker + for line in lines: + if line == start_marker: + include = True + if include: file.write(line.rstrip() + '\n') + if line == end_marker: + include = False diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 4532ae0a289..1b1f4b69b3d 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -14,6 +14,7 @@ # limitations under the License. import inspect +from itertools import chain import os.path from robot.errors import DataError @@ -186,13 +187,13 @@ class Language: template_setting = None timeout_setting = None arguments_setting = None - given_prefix = set() - when_prefix = set() - then_prefix = set() - and_prefix = set() - but_prefix = set() - true_strings = set() - false_strings = set() + given_prefixes = [] + when_prefixes = [] + then_prefixes = [] + and_prefixes = [] + but_prefixes = [] + true_strings = [] + false_strings = [] @classmethod def from_name(cls, name): @@ -283,8 +284,8 @@ def settings(self): @property def bdd_prefixes(self): - return (self.given_prefix | self.when_prefix | self.then_prefix | - self.and_prefix | self.but_prefix) + return set(chain(self.given_prefixes, self.when_prefixes, self.then_prefixes, + self.and_prefixes, self.but_prefixes)) def __eq__(self, other): return isinstance(other, type(self)) @@ -325,13 +326,13 @@ class En(Language): tags_setting = 'Tags' timeout_setting = 'Timeout' arguments_setting = 'Arguments' - given_prefix = {'Given'} - when_prefix = {'When'} - then_prefix = {'Then'} - and_prefix = {'And'} - but_prefix = {'But'} - true_strings = {'True', 'Yes', 'On'} - false_strings = {'False', 'No', 'Off'} + given_prefixes = ['Given'] + when_prefixes = ['When'] + then_prefixes = ['Then'] + and_prefixes = ['And'] + but_prefixes = ['But'] + true_strings = ['True', 'Yes', 'On'] + false_strings = ['False', 'No', 'Off'] class Cs(Language): @@ -366,13 +367,13 @@ class Cs(Language): template_setting = 'Šablona' timeout_setting = 'Časový limit' arguments_setting = 'Argumenty' - given_prefix = {'Pokud'} - when_prefix = {'Když'} - then_prefix = {'Pak'} - and_prefix = {'A'} - but_prefix = {'Ale'} - true_strings = {'Pravda', 'Ano', 'Zapnuto'} - false_strings = {'Nepravda', 'Ne', 'Vypnuto', 'Nic'} + given_prefixes = ['Pokud'] + when_prefixes = ['Když'] + then_prefixes = ['Pak'] + and_prefixes = ['A'] + but_prefixes = ['Ale'] + true_strings = ['Pravda', 'Ano', 'Zapnuto'] + false_strings = ['Nepravda', 'Ne', 'Vypnuto', 'Nic'] class Nl(Language): @@ -407,13 +408,13 @@ class Nl(Language): template_setting = 'Sjabloon' timeout_setting = 'Time-out' arguments_setting = 'Parameters' - given_prefix = {'Stel', 'Gegeven'} - when_prefix = {'Als'} - then_prefix = {'Dan'} - and_prefix = {'En'} - but_prefix = {'Maar'} - true_strings = {'Waar', 'Ja', 'Aan'} - false_strings = {'Onwaar', 'Nee', 'Uit', 'Geen'} + given_prefixes = ['Stel', 'Gegeven'] + when_prefixes = ['Als'] + then_prefixes = ['Dan'] + and_prefixes = ['En'] + but_prefixes = ['Maar'] + true_strings = ['Waar', 'Ja', 'Aan'] + false_strings = ['Onwaar', 'Nee', 'Uit', 'Geen'] class Bs(Language): @@ -448,11 +449,11 @@ class Bs(Language): template_setting = 'Template' timeout_setting = 'Timeout' arguments_setting = 'Argumenti' - given_prefix = {'Uslovno'} - when_prefix = {'Kada'} - then_prefix = {'Tada'} - and_prefix = {'I'} - but_prefix = {'Ali'} + given_prefixes = ['Uslovno'] + when_prefixes = ['Kada'] + then_prefixes = ['Tada'] + and_prefixes = ['I'] + but_prefixes = ['Ali'] class Fi(Language): @@ -487,13 +488,13 @@ class Fi(Language): template_setting = 'Malli' timeout_setting = 'Aikaraja' arguments_setting = 'Argumentit' - given_prefix = {'Oletetaan'} - when_prefix = {'Kun'} - then_prefix = {'Niin'} - and_prefix = {'Ja'} - but_prefix = {'Mutta'} - true_strings = {'Tosi', 'Kyllä', 'Päällä'} - false_strings = {'Epätosi', 'Ei', 'Pois'} + given_prefixes = ['Oletetaan'] + when_prefixes = ['Kun'] + then_prefixes = ['Niin'] + and_prefixes = ['Ja'] + but_prefixes = ['Mutta'] + true_strings = ['Tosi', 'Kyllä', 'Päällä'] + false_strings = ['Epätosi', 'Ei', 'Pois'] class Fr(Language): @@ -528,13 +529,13 @@ class Fr(Language): template_setting = 'Modèle' timeout_setting = "Délai d'attente" arguments_setting = 'Arguments' - given_prefix = {'Étant donné'} - when_prefix = {'Lorsque'} - then_prefix = {'Alors'} - and_prefix = {'Et'} - but_prefix = {'Mais'} - true_strings = {'Vrai', 'Oui', 'Actif'} - false_strings = {'Faux', 'Non', 'Désactivé', 'Aucun'} + given_prefixes = ['Étant donné'] + when_prefixes = ['Lorsque'] + then_prefixes = ['Alors'] + and_prefixes = ['Et'] + but_prefixes = ['Mais'] + true_strings = ['Vrai', 'Oui', 'Actif'] + false_strings = ['Faux', 'Non', 'Désactivé', 'Aucun'] class De(Language): @@ -569,13 +570,13 @@ class De(Language): template_setting = 'Vorlage' timeout_setting = 'Zeitlimit' arguments_setting = 'Argumente' - given_prefix = {'Angenommen'} - when_prefix = {'Wenn'} - then_prefix = {'Dann'} - and_prefix = {'Und'} - but_prefix = {'Aber'} - true_strings = {'Wahr', 'Ja', 'An', 'Ein'} - false_strings = {'Falsch', 'Nein', 'Aus', 'Unwahr'} + given_prefixes = ['Angenommen'] + when_prefixes = ['Wenn'] + then_prefixes = ['Dann'] + and_prefixes = ['Und'] + but_prefixes = ['Aber'] + true_strings = ['Wahr', 'Ja', 'An', 'Ein'] + false_strings = ['Falsch', 'Nein', 'Aus', 'Unwahr'] class PtBr(Language): @@ -610,13 +611,13 @@ class PtBr(Language): template_setting = 'Modelo' timeout_setting = 'Tempo Limite' arguments_setting = 'Argumentos' - given_prefix = {'Dado'} - when_prefix = {'Quando'} - then_prefix = {'Então'} - and_prefix = {'E'} - but_prefix = {'Mas'} - true_strings = {'Verdadeiro', 'Verdade', 'Sim', 'Ligado'} - false_strings = {'Falso', 'Não', 'Desligado', 'Desativado', 'Nada'} + given_prefixes = ['Dado'] + when_prefixes = ['Quando'] + then_prefixes = ['Então'] + and_prefixes = ['E'] + but_prefixes = ['Mas'] + true_strings = ['Verdadeiro', 'Verdade', 'Sim', 'Ligado'] + false_strings = ['Falso', 'Não', 'Desligado', 'Desativado', 'Nada'] class Pt(Language): @@ -651,13 +652,13 @@ class Pt(Language): template_setting = 'Modelo' timeout_setting = 'Tempo Limite' arguments_setting = 'Argumentos' - given_prefix = {'Dado'} - when_prefix = {'Quando'} - then_prefix = {'Então'} - and_prefix = {'E'} - but_prefix = {'Mas'} - true_strings = {'Verdadeiro', 'Verdade', 'Sim', 'Ligado'} - false_strings = {'Falso', 'Não', 'Desligado', 'Desativado', 'Nada'} + given_prefixes = ['Dado'] + when_prefixes = ['Quando'] + then_prefixes = ['Então'] + and_prefixes = ['E'] + but_prefixes = ['Mas'] + true_strings = ['Verdadeiro', 'Verdade', 'Sim', 'Ligado'] + false_strings = ['Falso', 'Não', 'Desligado', 'Desativado', 'Nada'] class Th(Language): @@ -692,11 +693,11 @@ class Th(Language): tags_setting = 'กลุ่ม' timeout_setting = 'หมดเวลา' arguments_setting = 'ค่าที่ส่งเข้ามา' - given_prefix = {'กำหนดให้'} - when_prefix = {'เมื่อ'} - then_prefix = {'ดังนั้น'} - and_prefix = {'และ'} - but_prefix = {'แต่'} + given_prefixes = ['กำหนดให้'] + when_prefixes = ['เมื่อ'] + then_prefixes = ['ดังนั้น'] + and_prefixes = ['และ'] + but_prefixes = ['แต่'] class Pl(Language): @@ -731,11 +732,11 @@ class Pl(Language): template_setting = 'Szablon' timeout_setting = 'Limit czasowy' arguments_setting = 'Argumenty' - given_prefix = {'Zakładając', 'Zakładając, że', 'Mając'} - when_prefix = {'Jeżeli', 'Jeśli', 'Gdy', 'Kiedy'} - then_prefix = {'Wtedy'} - and_prefix = {'Oraz', 'I'} - but_prefix = {'Ale'} + given_prefixes = ['Zakładając', 'Zakładając, że', 'Mając'] + when_prefixes = ['Jeżeli', 'Jeśli', 'Gdy', 'Kiedy'] + then_prefixes = ['Wtedy'] + and_prefixes = ['Oraz', 'I'] + but_prefixes = ['Ale'] class Uk(Language): @@ -770,11 +771,11 @@ class Uk(Language): template_setting = 'Шаблон' timeout_setting = 'Час вийшов' arguments_setting = 'Аргументи' - given_prefix = {'Дано'} - when_prefix = {'Коли'} - then_prefix = {'Тоді'} - and_prefix = {'Та'} - but_prefix = {'Але'} + given_prefixes = ['Дано'] + when_prefixes = ['Коли'] + then_prefixes = ['Тоді'] + and_prefixes = ['Та'] + but_prefixes = ['Але'] class Es(Language): @@ -809,13 +810,13 @@ class Es(Language): template_setting = 'Plantilla' timeout_setting = 'Tiempo agotado' arguments_setting = 'Argumentos' - given_prefix = {'Dado'} - when_prefix = {'Cuando'} - then_prefix = {'Entonces'} - and_prefix = {'Y'} - but_prefix = {'Pero'} - true_strings = {'Verdadero', 'Si', 'On'} - false_strings = {'Falso', 'No', 'Off', 'Ninguno'} + given_prefixes = ['Dado'] + when_prefixes = ['Cuando'] + then_prefixes = ['Entonces'] + and_prefixes = ['Y'] + but_prefixes = ['Pero'] + true_strings = ['Verdadero', 'Si', 'On'] + false_strings = ['Falso', 'No', 'Off', 'Ninguno'] class Ru(Language): @@ -850,11 +851,11 @@ class Ru(Language): template_setting = 'Шаблон' timeout_setting = 'Лимит' arguments_setting = 'Аргументы' - given_prefix = {'Дано'} - when_prefix = {'Когда'} - then_prefix = {'Тогда'} - and_prefix = {'И'} - but_prefix = {'Но'} + given_prefixes = ['Дано'] + when_prefixes = ['Когда'] + then_prefixes = ['Тогда'] + and_prefixes = ['И'] + but_prefixes = ['Но'] class ZhCn(Language): @@ -889,13 +890,13 @@ class ZhCn(Language): template_setting = '模板' timeout_setting = '超时' arguments_setting = '参数' - given_prefix = {'假定'} - when_prefix = {'当'} - then_prefix = {'那么'} - and_prefix = {'并且'} - but_prefix = {'但是'} - true_strings = {'真', '是', '开'} - false_strings = {'假', '否', '关', '空'} + given_prefixes = ['假定'] + when_prefixes = ['当'] + then_prefixes = ['那么'] + and_prefixes = ['并且'] + but_prefixes = ['但是'] + true_strings = ['真', '是', '开'] + false_strings = ['假', '否', '关', '空'] class ZhTw(Language): @@ -930,13 +931,13 @@ class ZhTw(Language): template_setting = '模板' timeout_setting = '逾時' arguments_setting = '参数' - given_prefix = {'假定'} - when_prefix = {'當'} - then_prefix = {'那麼'} - and_prefix = {'並且'} - but_prefix = {'但是'} - true_strings = {'真', '是', '開'} - false_strings = {'假', '否', '關', '空'} + given_prefixes = ['假定'] + when_prefixes = ['當'] + then_prefixes = ['那麼'] + and_prefixes = ['並且'] + but_prefixes = ['但是'] + true_strings = ['真', '是', '開'] + false_strings = ['假', '否', '關', '空'] class Tr(Language): @@ -971,13 +972,13 @@ class Tr(Language): tags_setting = 'Etiketler' timeout_setting = 'Zaman Aşımı' arguments_setting = 'Argümanlar' - given_prefix = {'Diyelim ki'} - when_prefix = {'Eğer ki'} - then_prefix = {'O zaman'} - and_prefix = {'Ve'} - but_prefix = {'Ancak'} - true_strings = {'Doğru', 'Evet', 'Açik'} - false_strings = {'Yanliş', 'Hayir', 'Kapali'} + given_prefixes = ['Diyelim ki'] + when_prefixes = ['Eğer ki'] + then_prefixes = ['O zaman'] + and_prefixes = ['Ve'] + but_prefixes = ['Ancak'] + true_strings = ['Doğru', 'Evet', 'Açik'] + false_strings = ['Yanliş', 'Hayir', 'Kapali'] class Sv(Language): @@ -1012,13 +1013,13 @@ class Sv(Language): template_setting = 'Mall' timeout_setting = 'Timeout' arguments_setting = 'Argument' - given_prefix = {'Givet'} - when_prefix = {'När'} - then_prefix = {'Då'} - and_prefix = {'Och'} - but_prefix = {'Men'} - true_strings = {'Sant', 'Ja', 'På'} - false_strings = {'Falskt', 'Nej', 'Av', 'Ingen'} + given_prefixes = ['Givet'] + when_prefixes = ['När'] + then_prefixes = ['Då'] + and_prefixes = ['Och'] + but_prefixes = ['Men'] + true_strings = ['Sant', 'Ja', 'På'] + false_strings = ['Falskt', 'Nej', 'Av', 'Ingen'] class Bg(Language): @@ -1053,13 +1054,13 @@ class Bg(Language): template_setting = 'Шаблон' timeout_setting = 'Таймаут' arguments_setting = 'Аргументи' - given_prefix = {'В случай че'} - when_prefix = {'Когато'} - then_prefix = {'Тогава'} - and_prefix = {'И'} - but_prefix = {'Но'} - true_strings = {'Вярно', 'Да', 'Включен'} - false_strings = {'Невярно', 'Не', 'Изключен', 'Нищо'} + given_prefixes = ['В случай че'] + when_prefixes = ['Когато'] + then_prefixes = ['Тогава'] + and_prefixes = ['И'] + but_prefixes = ['Но'] + true_strings = ['Вярно', 'Да', 'Включен'] + false_strings = ['Невярно', 'Не', 'Изключен', 'Нищо'] class Ro(Language): @@ -1094,13 +1095,13 @@ class Ro(Language): template_setting = 'Sablon' timeout_setting = 'Expirare' arguments_setting = 'Argumente' - given_prefix = {'Fie ca'} - when_prefix = {'Cand'} - then_prefix = {'Atunci'} - and_prefix = {'Si'} - but_prefix = {'Dar'} - true_strings = {'Adevarat', 'Da', 'Cand'} - false_strings = {'Fals', 'Nu', 'Oprit', 'Niciun'} + given_prefixes = ['Fie ca'] + when_prefixes = ['Cand'] + then_prefixes = ['Atunci'] + and_prefixes = ['Si'] + but_prefixes = ['Dar'] + true_strings = ['Adevarat', 'Da', 'Cand'] + false_strings = ['Fals', 'Nu', 'Oprit', 'Niciun'] class It(Language): @@ -1135,13 +1136,13 @@ class It(Language): template_setting = 'Template' timeout_setting = 'Timeout' arguments_setting = 'Parametri' - given_prefix = {'Dato'} - when_prefix = {'Quando'} - then_prefix = {'Allora'} - and_prefix = {'E'} - but_prefix = {'Ma'} - true_strings = {'Vero', 'Sì', 'On'} - false_strings = {'Falso', 'No', 'Off', 'Nessuno'} + given_prefixes = ['Dato'] + when_prefixes = ['Quando'] + then_prefixes = ['Allora'] + and_prefixes = ['E'] + but_prefixes = ['Ma'] + true_strings = ['Vero', 'Sì', 'On'] + false_strings = ['Falso', 'No', 'Off', 'Nessuno'] class Hi(Language): @@ -1176,10 +1177,10 @@ class Hi(Language): template_setting = 'साँचा' timeout_setting = 'समय समाप्त' arguments_setting = 'प्राचल' - given_prefix = {'दिया हुआ'} - when_prefix = {'जब'} - then_prefix = {'तब'} - and_prefix = {'और'} - but_prefix = {'परंतु'} - true_strings = {'यथार्थ', 'निश्चित', 'हां', 'पर'} - false_strings = {'गलत', 'नहीं', 'हालाँकि', 'यद्यपि', 'नहीं', 'हैं'} + given_prefixes = ['दिया हुआ'] + when_prefixes = ['जब'] + then_prefixes = ['तब'] + and_prefixes = ['और'] + but_prefixes = ['परंतु'] + true_strings = ['यथार्थ', 'निश्चित', 'हां', 'पर'] + false_strings = ['गलत', 'नहीं', 'हालाँकि', 'यद्यपि', 'नहीं', 'हैं'] From dadcc5c3932620276693211cdfa9486d3bc44389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 18 Oct 2022 17:51:41 +0300 Subject: [PATCH 0271/1592] Document localization. Fixes #4390. --- doc/userguide/src/Appendices/Translations.rst | 8 +- .../src/CreatingTestData/TestDataSyntax.rst | 129 +++++++++++++++++- doc/userguide/src/RobotFrameworkUserGuide.rst | 2 + 3 files changed, 130 insertions(+), 9 deletions(-) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index 2401d92a7a8..cce360f159c 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -1,17 +1,16 @@ Translations ============ -Robot Framework supports translating `section headers`__, settings_, +Robot Framework supports translating `section headers`_, settings_, `Given/When/Then prefixes`__ used in Behavior Driven Development (BDD) as well as `true and false strings`__ used in automatic Boolean argument conversion. This appendix lists all translations for all languages, excluding English, that Robot Framework supports out-of-the-box. How to actually activate translations is explained in the Localization_ section. -That section also explains how to create custom translations, -how to contribute new translations, and how to enhance existing ones. +That section also explains how to create custom language definitions and +how to contribute new translations. -__ `Test data sections`_ __ `Behavior-driven style`_ __ `Supported conversions`_ @@ -19,7 +18,6 @@ __ `Supported conversions`_ :depth: 1 :local: - .. START GENERATED CONTENT .. Generated by translations.py used by ug2html.py. diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 43381c7d8a1..a760ef0f835 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -553,13 +553,80 @@ __ `Newlines in test data`_ Localization ------------ -TODO +Robot Framework localization efforts were started in Robot Framework 6.0 +that allowed translation of `section headers`_, settings_, +`Given/When/Then prefixes`__ used in Behavior Driven Development (BDD), and +`true and false strings`__ used in automatic Boolean argument conversion. +The plan is to extend localization support in the future, for example, +to log and report and possibly also to control structures. -.. Content below has been generated using translations.py used by ug2html.py. +This section explains how to `activate languages`__, what `built-in languages`_ +are supported, how to create `custom language files`_ and how new translations +can be contributed__. -.. START GENERATED CONTENT +__ `Enabling languages`_ +__ `Behavior-driven style`_ +__ `Supported conversions`_ +__ `Contributing translations`_ + +Enabling languages +~~~~~~~~~~~~~~~~~~ + +Using command line option +''''''''''''''''''''''''' + +The main mechanism to activate languages is specifying them from the command line +using the :option:`--language` option. When enabling `built-in languages`_, +it is possible to use either the language name like `Finnish` or the language +code like `fi`. Both names and codes are case and space insensitive and also +the hyphen (`-`) is ignored. To enable multiple languages, the +:option:`--language` option needs to be used multiple times:: + + robot --language Finnish testit.robot + robot --language pt --language ptbr testes.robot + +The same :option:`--language` option is also used when activating +`custom language files`_. With them the value can be either a path to the file or, +if the file is in the `module search path`_, the module name:: + + robot --language Custom.py tests.robot + robot --language MyLang tests.robot + +For backwards compatibility reasons, and to support partial translations, +English is always activated automatically. Future versions may allow disabling +it. + +Pre-file configuration +'''''''''''''''''''''' -Supported languages: +It is also possible to enable languages directly in data files by having +a line `Language: <value>` (case-insensitive) before any of the section +headers. The value after the colon is interpreted the same way as with +the :option:`--language` option:: + + Language: Finnish + + *** Asetukset *** + Dokumentaatio Example using Finnish. + +If there is a need to enable multiple languages, the `Language:` line +can be repeated. These configuration lines cannot be in comments so something like +`# Language: Finnish` has no effect. + +Due to technical limitations, the per-file language configuration affects also +parsing subsequent files as well as the whole execution. This +behavior is likely to change in the future and *should not* be relied upon. +If you use per-file configuration, use it with all files or enable languages +globally with the :option:`--language` option. + +Built-in languages +~~~~~~~~~~~~~~~~~~ + +The following languages are supported out-of-the-box. Click the language name +to see the actual translations: + +.. START GENERATED CONTENT +.. Generated by translations.py used by ug2html.py. - `Bulgarian (bg)`_ - `Bosnian (bs)`_ @@ -582,3 +649,57 @@ Supported languages: - `Ukrainian (uk)`_ - `Chinese Simplified (zh-CN)`_ - `Chinese Traditional (zh-TW)`_ + +.. END GENERATED CONTENT + +All these translations have been provided by the awesome Robot Framework +community. If a language you are interested in is not included, you can +consider contributing__ it! + +__ `Contributing translations`_ + +Custom language files +~~~~~~~~~~~~~~~~~~~~~ + +If a language you would need is not available as a built-in language, or you +want to create a totally custom language for some specific need, you can easily +create a custom language file. Language files are Python files that contain +one or more language definitions that are all loaded when the language file +is taken into use. Language definitions are created by extending the +`robot.api.Language` base class and overriding class attributes as needed: + +.. sourcecode:: python + + from robot.api import Language + + + class Example(Language): + test_cases_header = 'Validations' + tags_setting = 'Labels' + given_prefixes = ['Assuming'] + true_strings = ['OK', '\N{THUMBS UP SIGN}'] + +Assuming the above code would be in file :file:`example.py`, a path to that +file or just the module name `example` could be used when the language file +is activated__. + +The above example adds only some of the possible translations. That is fine +because English is automatically enabled anyway. Most values must be specified +as strings, but BDD prefixes and true/false strings allow more than one value +and must be given as lists. For more examples, see Robot Framework's internal +languages__ module that contains the `Language` class as well as all built-in +language definitions. + +__ `Enabling languages`_ +__ https://github.com/robotframework/robotframework/blob/master/src/robot/conf/languages.py + +Contributing translations +~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to add translation for a new language or enhance existing, head +to Crowdin__ that we use for collaboration. For more details, see the +separate Localization__ project, and for questions and free discussion join +the `#localization` channel on our Slack_. + +__ https://robotframework.crowdin.com +__ https://github.com/MarketSquare/localization diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index 6088aac4d92..be435634d52 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -125,6 +125,7 @@ .. _test data: `Creating test data`_ .. _general parsing rules: `Test data syntax`_ +.. _section headers: `Test data sections`_ .. _test case: `Creating test cases`_ .. _test cases: `test case`_ .. _test suite: `Creating test suites`_ @@ -237,3 +238,4 @@ .. _AutoIT: http://www.autoitscript.com/autoit3 .. _XML-RPC: http://www.xmlrpc.com/ .. _RIDE: https://github.com/robotframework/RIDE +.. _Slack: http://slack.robotframework.org From b73aa05b94c58858946853def52c56374c4b4354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 18 Oct 2022 18:00:29 +0300 Subject: [PATCH 0272/1592] Document --language option --- doc/userguide/src/Appendices/CommandLineOptions.rst | 5 +++++ src/robot/run.py | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/doc/userguide/src/Appendices/CommandLineOptions.rst b/doc/userguide/src/Appendices/CommandLineOptions.rst index c4477cdc3a8..1f9d968530c 100644 --- a/doc/userguide/src/Appendices/CommandLineOptions.rst +++ b/doc/userguide/src/Appendices/CommandLineOptions.rst @@ -14,6 +14,11 @@ Command line options for test execution --------------------------------------- --rpa Turn on `generic automation`_ mode. + --language <lang> `Activate the specified language <Localization_>`__. + `lang` can be a name or a code of a + `built-in language <Translations_>`__ + to active or a path or a module name of a custom + language file. -F, --extension <value> `Parse only these files`_ when executing a directory. -N, --name <name> `Sets the name`_ of the top-level test suite. -D, --doc <document> `Sets the documentation`_ of the top-level test suite. diff --git a/src/robot/run.py b/src/robot/run.py index e1adee45e03..91f9f910406 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -89,6 +89,9 @@ terminology so that "test" is replaced with "task" in logs and reports. By default the mode is got from test/task header in data files. + --language lang * Activate the specified language. `lang` can be a name + or a code of a built-in language to active or a path + or a module name of a custom language file. -F --extension value Parse only files with this extension when executing a directory. Has no effect when running individual files or when using resource files. If more than one @@ -98,7 +101,6 @@ -N --name name Set the name of the top level suite. By default the name is created based on the executed file or directory. - --language lang * TODO -D --doc documentation Set the documentation of the top level suite. Simple formatting is supported (e.g. *bold*). If the documentation contains spaces, it must be quoted. From 0b50cffa2ff88d04613f295b866346a1c4d5a60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 12:18:47 +0300 Subject: [PATCH 0273/1592] Update versions in TODOs and FIXMEs. --- src/robot/libraries/BuiltIn.py | 4 ++-- src/robot/parsing/lexer/settings.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 3996ca9bdf6..ea64102409b 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -40,7 +40,7 @@ from robot.version import get_version -# FIXME: Clean-up registering run keyword variants in RF 5! +# FIXME: Clean-up registering run keyword variants! # https://github.com/robotframework/robotframework/issues/2190 def run_keyword_variant(resolve, dry_run=False): @@ -3001,7 +3001,7 @@ def log(self, message, level='INFO', html=False, console=False, Formatter options ``type`` and ``log`` are new in Robot Framework 5.0. """ - # TODO: Remove `repr` altogether in RF 5.2. It was deprecated in RF 5.0. + # TODO: Remove `repr` altogether in RF 7.0. It was deprecated in RF 5.0. if repr == 'DEPRECATED': formatter = self._get_formatter(formatter) else: diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 0c74511337f..b49e1e56eb1 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -103,7 +103,7 @@ def _lex_error(self, setting, values, error): def _lex_setting(self, setting, values, name): self.settings[name] = values - # TODO: Change token type from 'FORCE TAGS' to 'TEST TAGS' in RF 5.2. + # TODO: Change token type from 'FORCE TAGS' to 'TEST TAGS' in RF 7.0. setting.type = name.upper() if name != 'Test Tags' else 'FORCE TAGS' if name in self.name_and_arguments: self._lex_name_and_arguments(values) From f9529fe17da609824f70544f06f4c6e70dafca0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 12:19:10 +0300 Subject: [PATCH 0274/1592] Explicit test for Language.bdd_prefixes --- utest/api/test_languages.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index 2c61d18a6f8..f8f7b7f061d 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -68,6 +68,14 @@ def test_subclasses_dont_have_wrong_attributes(self): raise AssertionError(f"Language class '{cls}' has attribute " f"'{attr}' not found on the base class.") + def test_bdd_prefixes(self): + class X(Language): + given_prefixes = ['List', 'is', 'default'] + when_prefixes = {} + but_prefixes = ('but', 'any', 'iterable', 'works') + assert_equal(X().bdd_prefixes, {'List', 'is', 'default', + 'but', 'any', 'iterable', 'works'}) + class TestLanguageFromName(unittest.TestCase): From b37dc02f3a85d7e80ebf413db8d234037cb359f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 12:21:00 +0300 Subject: [PATCH 0275/1592] Enhance --language docs a bit. --- doc/userguide/src/Appendices/CommandLineOptions.rst | 8 +++----- src/robot/run.py | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/doc/userguide/src/Appendices/CommandLineOptions.rst b/doc/userguide/src/Appendices/CommandLineOptions.rst index 1f9d968530c..f112a02b9a2 100644 --- a/doc/userguide/src/Appendices/CommandLineOptions.rst +++ b/doc/userguide/src/Appendices/CommandLineOptions.rst @@ -14,11 +14,9 @@ Command line options for test execution --------------------------------------- --rpa Turn on `generic automation`_ mode. - --language <lang> `Activate the specified language <Localization_>`__. - `lang` can be a name or a code of a - `built-in language <Translations_>`__ - to active or a path or a module name of a custom - language file. + --language <lang> Activate localization_. `lang` can be a name or a code + of a `built-in language <Translations_>`__, or a path + or a module name of a custom language file. -F, --extension <value> `Parse only these files`_ when executing a directory. -N, --name <name> `Sets the name`_ of the top-level test suite. -D, --doc <document> `Sets the documentation`_ of the top-level test suite. diff --git a/src/robot/run.py b/src/robot/run.py index 91f9f910406..f6e6df6328b 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -89,9 +89,9 @@ terminology so that "test" is replaced with "task" in logs and reports. By default the mode is got from test/task header in data files. - --language lang * Activate the specified language. `lang` can be a name - or a code of a built-in language to active or a path - or a module name of a custom language file. + --language lang * Activate localization. `lang` can be a name or a code + of a built-in language, or a path or a module name of + a custom language file. -F --extension value Parse only files with this extension when executing a directory. Has no effect when running individual files or when using resource files. If more than one From b64775b2d8efd2705e49296e4c47090f9cce0eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 13:26:37 +0300 Subject: [PATCH 0276/1592] Release notes for 6.0 --- doc/releasenotes/rf-6.0.rst | 838 ++++++++++++++++++++++++++++++++++++ 1 file changed, 838 insertions(+) create mode 100644 doc/releasenotes/rf-6.0.rst diff --git a/doc/releasenotes/rf-6.0.rst b/doc/releasenotes/rf-6.0.rst new file mode 100644 index 00000000000..419b9c42657 --- /dev/null +++ b/doc/releasenotes/rf-6.0.rst @@ -0,0 +1,838 @@ +=================== +Robot Framework 6.0 +=================== + +.. default-role:: code + +`Robot Framework`_ 6.0 is a new major release that starts Robot Framework's +localization efforts. In addition to that, it contains several nice enhancements +related to, for example, automatic argument conversion and using embedded arguments. +Initially it had version 5.1 and was considered a feature release, but it grow +so big that we decided to make it a major release instead. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.0 + +to install exactly this version. Alternatively, you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.0 was released on Wednesday October 19, 2022. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Localization +------------ + +Robot Framework 6.0 starts our localization efforts by making it possible to translate +various markers used in the data. It is possible to translate headers (e.g. `Test Cases`) +and settings (e.g. `Documentation`) (`#4096`_), `Given/When/Then` prefixes used in BDD +(`#519`_), as well as true and false strings used in Boolean argument conversion (`#4400`_). +Future versions may allow translating syntax like `IF` and `FOR`, contents of logs and +reports, error messages, and so on. + +Languages to use are specified when starting execution using the `--language` command +line option. With languages supported by Robot Framework out-of-the-box, it is possible +to use just a language code or name like `--language fi` or `--language Finnish`. +It is also possible to create a custom language file and use it like `--language MyLang.py`. +If there is a need to support multiple languages, the `--language` option can be +used multiple times like `--language de --language uk`. + +In addition to specifying the language from the command line, it is possible to +specify it in the data file itself using `language: <lang>` syntax, where `<lang>` is +a language code or name, before the first section:: + + language: fi + + *** Asetukset *** + Dokumentaatio Example using Finnish. + +Due to technical reasons this per-file language configuration affects also parsing +subsequent files, but that behavior is likely to change and *should not* be dependent +on. Either use `language: <lang>` in each parsed file or specify the language to +use from the command line. + +Robot Framework 6.0 contains built-in support for these languages in addition +to English that is automatically supported. Exact translations used by different +languages are listed in the `User Guide`__. + +- Bulgarian (bg) +- Bosnian (bs) +- Czech (cs) +- German (de) +- Spanish (es) +- Finnish (fi) +- French (fr) +- Hindi (hi) +- Italian (it) +- Dutch (nl) +- Polish (pl) +- Portuguese (pt) +- Brazilian Portuguese (pt-BR) +- Romanian (ro) +- Russian (ru) +- Swedish (sv) +- Thai (th) +- Turkish (tr) +- Ukrainian (uk) +- Chinese Simplified (zh-CN) +- Chinese Traditional (zh-TW) + +All these translations have been provided by our awesome community and we hope +to get more community contributed translations in future releases. If you are +interested to help, head to Crowdin__ that we use for collaboration. For more +instructions see the Localization__ project and for general discussion and +questions join the `#localization` channel on our Slack_. + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#translations +__ https://github.com/MarketSquare/localization +__ https://robotframework.crowdin.com/robot-framework + +Enhancements to using keywords with embedded arguments +------------------------------------------------------ + +When using keywords with embedded arguments, it is pretty common that a keyword +that is used matches multiple keyword implementations. For example, +`Execute "ls" with "-lh"` in this example matches both of the keywords: + +.. sourcecode:: robotframework + + *** Test Cases *** + Automatic conflict resolution + Execute "ls" + Execute "ls" with "-lh" + + *** Keywords *** + Execute "${cmd}" + Log Running command '${cmd}'. + + Execute "${cmd}" with "${opts}" + Log Running command '${cmd}' with options '${opts}'. + +Earlier when such conflicts occurred, execution failed due to there being +multiple matching keywords. Nowadays, if there is a match that is better than +others, it will be used and the conflict is resolved. In the above example, +`Execute "${cmd}" with "${opts}"` is considered to be a better match than +the more generic `Execute "${cmd}"` and the example thus succeeds. (`#4454`_) + +There can, however, be cases where it is not possible to find a single best +match. In such cases conflicts cannot be resolved automatically and +execution fails as earlier. + +Another nice enhancement related to keywords using embedded arguments is that +if they are used with `Run Keyword` or its variants, arguments are not anymore +always converted to strings. That allows passing arguments containing other +values than strings as variables also in this context. (`#1595`_) + +Enhancements to keyword namespaces +---------------------------------- + +It is possible to mark keywords in resource files as private by adding +`robot:private` tag to them (`#430`_). If such a keyword is used by keywords +outside that resource file, there will be a warning. These keywords are also +excluded from HTML library documentation generated by Libdoc. + +If a keyword exists in the same resource file as a keyword using it, it will +be used even if there would be keyword with the same name in another resource +file (`#4366`_). Earlier this situation caused a conflict. + +If a keyword exists in the same resource file as a keyword using it and there +is a keyword with the same name in the test case file, the keyword in the test +case file will be used as it has been used earlier. This behavior is nowadays +deprecated__, though, and in the future local keywords will have precedence also +in these cases. + +__ `Keywords in test case files having precedence over local keywords in resource files`_ + +Enhancements to automatic argument conversion +--------------------------------------------- + +Automatic argument conversion makes it possible for library authors to specify +what types certain arguments have and then Robot Framework automatically converts +used arguments accordingly. This support has been enhanced in various ways. + +Nowadays, if a container type like `list` is used with parameters like `list[int]`, +arguments are not only converted to the container type, but items they contain are +also converted to specified nested types (`#4433`_). This works with all containers +Robot Framework's argument conversion works in general. Most important examples +are the already mentioned lists, dictionaries like `dict[str, int]`, tuples like +`tuple[str, int, bool]` and heterogeneous tuples like `tuple[int, ...]`. Notice +that using parameters with Python's standard types `requires Python 3.9`__. With +earlier versions it is possible to use `List`, `Dict` and other such types +available in the typing__ module. + +Another container type that is nowadays handled better is TypedDict__. Earlier, +when TypedDicts were used as type hints, arguments were only converted to +dictionaries, but nowadays items are converted according to the specified +types. In addition to that, Robot Framework validates that all required +items are present. (`#4477`_) + +Another nice enhancement is that automatic conversion nowadays works also with +`pathlib.Path`__. (`#4461`_) + +__ https://peps.python.org/pep-0585/ +__ https://docs.python.org/3/library/typing.html +__ https://docs.python.org/3/library/typing.html#typing.TypedDict +__ https://docs.python.org/3/library/pathlib.html + +Enhancements for setting keyword and test tags +---------------------------------------------- + +It is now possible to set tags for all keywords in a certain file by using +the new `Keyword Tags` setting (`#4373`_). It works in resource files and also +in test case and suite initialization files. When used in initialization files, +it only affects keywords in that file and does not propagate to lower level suites. + +The `Force Tags` setting has been renamed to `Test Tags` (`#4368`_). The motivation +is to make settings related to tests more consistent (`Test Setup`, `Test Timeout`, +`Test Tags`, ...) and to better separate settings for specifying test and keyword tags. +Consistent naming also easies translations. The old `Force Tags` setting still works, +but it will be `deprecated in the future`__. When creating tasks, it is possible +to use `Task Tags` alias instead of `Test Tags`. + +To simplify setting tags, the `Default Tags` setting will `also be deprecated`__. +The functionality it provides, setting tags that some but no all tests get, +will be enabled in the future by using `-tag` syntax with the `[Tags]` setting +to indicate that a test should not get tag `tag`. This syntax will then work +also in combination with the new `Keyword Tags`. For more details see `#4374`__. + +__ `Force Tags and Default Tags settings`_ +__ `Force Tags and Default Tags settings`_ +__ https://github.com/robotframework/robotframework/issues/4374 + +Possibility to disable continue-on-failure mode +----------------------------------------------- + +Robot Framework generally stops executing a keyword or a test case if there +is a failure. Exceptions to this rule include teardowns, templates and +cases where the continue-on-failure mode has been explicitly enabled with +`robot:continue-on-failure` or `robot:recursive-continue-on-failure` +tags. Robot Framework 6.0 makes it possible to disable the implicit or explicit +continue-on-failure mode when needed by using `robot:stop-on-failure` and +`robot:recursive-stop-on-failure` tags (`#4303`_). + +`start/end_keyword` listener methods get more information about control structures +---------------------------------------------------------------------------------- + +When using the listener API v2, `start_keyword` and `end_keyword` methods are not +only used with keywords but also with all control structures. Earlier these methods +always got exactly the same information, but nowadays there is additional context +specific details with control structures. (`#4335`_) + +Libdoc enhancements +------------------- + +Libdoc can now generate keyword documentation not only for libraries and +resource files, but also for suite files (e.g. `tests.robot`) and for suite +initialization files (`__init__.robot`). The primary use case was making it +possible for editors to show HTML documentation for keywords regardless +the file user is editing, but naturally such HTML documentation can be useful +also otherwise. (`#4493`_) + +Libdoc has also got new `--theme` option that can be used to enforce dark +or light theme. The theme used by the browser is used by default as earlier. +External tools can control the theme also programmatically when generating +documentation and by calling the `setTheme()` Javascript function. (`#4497`_) + +Performance enhancements for executing user keywords +---------------------------------------------------- + +The overhead in executing user keywords has been reduced. The difference +can be seen especially if user keywords fail often, for example, when using +`Wait Until Keyword Succeeds` or a loop with `TRY/EXCEPT`. (`#4388`_) + +Python 3.11 support +-------------------- + +Robot Framework 6.0 officially supports the new Python 3.11 release (`#4401`_). +Incompatibilities were pretty small, so also earlier versions work fairly well. +`Python 3.11`__ is 10-60% faster than Python 3.10 (which is also faster than +earlier versions), so upgrading to it is a good idea even if you were not +interested in new features it provides. + +At the other end of the spectrum, Python 3.6 is deprecated and will not +anymore be supported by Robot Framework 7.0 (`#4295`_). + +__ https://docs.python.org/3.11/whatsnew/3.11.html + +Backwards incompatible changes +============================== + +- Space is required after `Given/When/Then` prefixes used with BDD scenarios. (`#4379`_) + +- Dictionary related keywords in `Collections` require dictionaries to inherit `Mapping`. (`#4413`_) + +- `Dictionary Should Contain Item` from the Collections library does not anymore convert + values to strings before comparison. (`#4408`_) + +- Automatic `TypedDict` conversion can cause problems if a keyword expects to get any + dictionary. Nowadays dictionaries that do not match the type spec cause failures + and the keyword is not called at all. (`#4477`_) + +- Generation time in XML and JSON spec files generated by Libdoc has been changed to + `2022-05-27T19:07:15+00:00`. With XML specs the format used to be `2022-05-27T19:07:15Z` + that is equivalent with the new format. JSON spec files did not include the timezone + information at all and the format was `2022-05-27 19:07:15`. (`#4262`_) + +- `BuiltIn.run_keyword()` nowadays resolves variables in the name of the keyword to + execute when earlier they were resolved by Robot Framework before calling the keyword. + This affects programmatic usage if the used name contains variables or backslashes. + The change was done when enhancing how keywords with embedded arguments work with + `BuiltIn.run_keyword()`. (`#1595`_) + + +Deprecated features +=================== + +`Force Tags` and `Default Tags` settings +---------------------------------------- + +As `discussed earlier`__, new `Test Tags` setting has been added to replace `Force Tags` +and there is a plan to remove `Default Tags` altogether. Both of these settings still +work but they are considered deprecated. There is no visible deprecation warning yet, +but such a warning will be emitted starting from Robot Framework 7.0 and eventually these +settings will be removed. (`#4368`_) + +The plan is to add new `-tag` syntax that can be used with the `[Tags]` setting +to enable similar functionality that the `Default Tags` setting provides. Because +of that, using tags starting with a hyphen with the `[Tags]` setting is now deprecated. +If such literal values are needed, it is possible to use escaped format like `\-tag`. +(`#4380`_) + +__ `Enhancements for setting keyword and test tags`_ + +Keywords in test case files having precedence over local keywords in resource files +----------------------------------------------------------------------------------- + +Keywords in test cases files currently always have the highest precedence. They +are used even when a keyword in a resource file uses a keyword that would exist also +in the same resource file. This will change so that local keywords always have +highest precedence and the current behavior is deprecated. (`#4366`_) + +`WITH NAME` in favor of `AS` when giving alias to imported library +------------------------------------------------------------------ + +`WITH NAME` marker that is used when giving an alias to an imported library +will be renamed to `AS` (`#4371`_). The motivation is to be consistent with +Python that uses `as` for similar purpose. We also already use `AS` with +`TRY/EXCEPT` and reusing the same marker and internally used token simplifies +the syntax. Having less markers will also ease translations (but these markers +cannot yet be translated). + +In Robot Framework 6.0 both `AS` and `WITH NAME` work when setting an alias +for a library. `WITH NAME` is considered deprecated, but there will not be +visible deprecation warnings until Robot Framework 7.0. + +Singular section headers like `Test Case` +----------------------------------------- + +Robot Framework has earlier accepted both plural (e.g. `Test Cases`) and singular +(e.g. `Test Case`) section headers. The singular variants are now deprecated +and their support will eventually be removed (`#4431`_). The is no visible +deprecation warning yet, but they will most likely be emitted starting from +Robot Framework 7.0. + +Using variables with embedded arguments so that value does not match custom pattern +----------------------------------------------------------------------------------- + +When keywords accepting embedded arguments are used so that arguments are +passed as variables, variable values are not checked against possible custom +regular expressions. Keywords being called with arguments they explicitly do not +accept is problematic and this behavior will be changed. Due to the backwards +compatibility it is now only deprecated, but validation will be more strict +in the future. (`#4462`_) + +Custom patterns have often been used to avoid conflicts when using embedded arguments. +That need is nowadays smaller because Robot Framework 6.0 can typically resolve +conflicts automatically. (`#4454`_) + +`robot.utils.TRUE_STRINGS` and `robot.utils.FALSE_STRINGS` +---------------------------------------------------------- + +These constants were earlier sometimes needed by libraries when converting +arguments passed to keywords to Boolean values. Nowadays automatic argument +conversion takes care of that and these constants do not have any real usage. +They can still be used and there is not even a deprecation warning yet, +but they will be loudly deprecated in the future and eventually removed. (`#4500`_) + +These constants are internally used by `is_truthy` and `is_falsy` utility +functions that some of Robot Framework standard libraries still use. +Also these utils are likely to be deprecated in the future, and users are +advised to use the automatic argument conversion instead of them. + +Python 3.6 support +------------------ + +Python 3.6 `reached end-of-life in December 2021`__. It will be still supported +by all future Robot Framework 6.x releases, but not anymore by Robot Framework +7.0 (`#4295`_). Users are recommended to upgrade to newer versions already now. + +The reason we still support Python 3.6 is that although its official support +has ended, it is supported by various long-term support Linux distributions. +It is, for example, the default Python version in RHEL 8 that +`is supported until 2029`__. + +__ https://endoflife.date/python +__ https://endoflife.date/rhel + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its ~50 member organizations. Robot Framework 6.0 team funded by the foundation +consisted of `Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the wider open source community has provided several +great contributions: + +- `Elout van Leeuwen <https://github.com/leeuwe>`_ has lead the translation efforts + (`#4390`_). Individual translations have been provided by the following people: + + - Bosnian by `Namik <https://github.com/Delilovic>`_ + - Bulgarian by `Ivo <https://github.com/naschenez>`_ + - Chinese Simplified and Chinese Traditional + by `@nixuewei <https://github.com/nixuewei>`_ + and `charis <https://github.com/mawentao119>`_ + - Czech by `Václav Fuksa <https://github.com/MoreFamed>`_ + - Dutch by `Pim Jansen <https://github.com/pimjansen>`_ + and `Elout van Leeuwen <https://github.com/leeuwe>`_ + - French by `@lesnake <https://github.com/lesnake>`_ + and `Martin Malorni <https://github.com/mmalorni>`_ + - German by `René <https://github.com/Snooz82>`_ + and `Markus <https://github.com/Noordsestern>`_ + - Hindi by `Bharat Patel <https://github.com/bbpatel2001>`_ + - Italian by `Luca Giorgi <https://github.com/lugi0>`_ + - Polish by `Bartłomiej Hirsz <https://github.com/bhirsz>`_ + - Portuguese and Brazilian Portuguese + by `Hélio Guilherme <https://github.com/HelioGuilherme66>`_ + - Romanian by `Liviu Avram <https://github.com/zastress>`_ + - Russian by `Anatoly Kolpakov <https://github.com/axxyhtrx>`_ + - Spanish by Miguel Angel Apolayo Mendoza + - Swedish by `Richard Ludwig <https://github.com/JockeJarre>`_ + - Thai by `Somkiat Puisungnoen <https://github.com/up1>`_ + - Turkish by `Yusuf Can Bayrak <https://github.com/yusufcanb>`_ + - Ukrainian by `@Sunshine0000000 <https://github.com/Sunshine0000000>`_ + +- `Oliver Boehmer <https://github.com/oboehmer>`_ provided several contributions: + + - Support to disable the continue-on-failure mode using `robot:stop-on-failure` and + `robot:recursive-stop-on-failure` tags. (`#4303`_) + - Document that failing test setup stops execution even if the continue-on-failure + mode is active. (`#4404`_) + - Default value to `Get From Dictionary` keyword. (`#4398`_) + - Allow passing explicit flags to regexp related keywords. (`#4429`_) + +- `J. Foederer <https://github.com/JFoederer>`_ enhanced performance of + `Keyword Should Exist` when a keyword is not found (`#4470`_) and provided + the initial pull request to support parameterized generics like `list[int]` (`#4433`_) + +- `Ossi R. <https://github.com/osrjv>`_ added more information to `start/end_keyword` + listener methods when they are used with control structures (`#4335`_). + +- `René <https://github.com/Snooz82>`_ fixed Libdoc's HTML outputs if type hints + matched Javascript variables in browser namespace (`#4464`_) or keyword names (`#4471`_). + +- `Fabio Zadrozny <https://github.com/fabioz>`_ provided a pull request speeding up + user keyword execution (`#4353`_). + +- `Daniel Biehl <https://github.com/d-biehl>`_ helped making the public + `robot.api.Languages` API easier to use for external tools (`#4096`_). + +- `@mikkuja <https://github.com/mikkuja>`_ added support to parse time strings + containing micro and nanoseconds like `100 ns` (`#4490`_). + +- `@Apteryks <https://github.com/Apteryks>`_ added support to generate deterministic + library documentation by using `SOURCE_DATE_EPOCH`__ environment variable (`#4262`_). + +- `@F3licity <https://github.com/F3licity>`_ enhanced `Sleep` keyword documentation. (`#4485`_) + +__ https://reproducible-builds.org/specs/source-date-epoch/ + +Thanks also to all community members who have submitted bug reports, helped debugging +problems, or otherwise helped to make Robot Framework 6.0 our best release so far! + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#4096`_ + - enhancement + - critical + - Multilanguage support for markers used in data + * - `#4390`_ + - enhancement + - critical + - Add and document translations + * - `#519`_ + - enhancement + - critical + - Given/When/Then should support other languages than English + * - `#1595`_ + - bug + - high + - Embedded arguments are not passed as objects when executed with `Run Keyword` or its variants + * - `#4348`_ + - bug + - high + - Invalid IF or WHILE conditions should not cause errors that don't allow continuation + * - `#4483`_ + - bug + - high + - BREAK and CONTINUE hide continuable errors with WHILE loops + * - `#4295`_ + - enhancement + - high + - Deprecate Python 3.6 + * - `#430`_ + - enhancement + - high + - Keyword visibility modifiers for resource files + * - `#4303`_ + - enhancement + - high + - Support disabling continue-on-failure mode using `robot:stop-on-failure` and `robot:recursive-stop-on-failure` tags + * - `#4335`_ + - enhancement + - high + - Pass more information about control structures to `start/end_keyword` listener methods + * - `#4366`_ + - enhancement + - high + - Give local keywords precedence over imported keywords in resource files + * - `#4368`_ + - enhancement + - high + - New `Test Tags` setting as an alias for `Force Tags` + * - `#4373`_ + - enhancement + - high + - Support adding tags for all keywords using `Keyword Tags` setting + * - `#4380`_ + - enhancement + - high + - Deprecate setting tags starting with a hyphen like `-tag` using the `[Tags]` setting + * - `#4388`_ + - enhancement + - high + - Enhance performance of executing user keywords especially when they fail + * - `#4400`_ + - enhancement + - high + - Allow translating True and False words used in Boolean argument conversion + * - `#4401`_ + - enhancement + - high + - Python 3.11 compatibility + * - `#4433`_ + - enhancement + - high + - Convert and validate collection contents when using generics in type hints + * - `#4454`_ + - enhancement + - high + - Automatically select "best" match if there is conflict with keywords using embedded arguments + * - `#4477`_ + - enhancement + - high + - Convert and validate `TypedDict` items + * - `#4493`_ + - enhancement + - high + - Libdoc: Support generating keyword documentation for suite files + * - `#4351`_ + - bug + - medium + - Libdoc can give bad error message if library argument has extension matching resource files + * - `#4355`_ + - bug + - medium + - Continuable failures terminate WHILE loops + * - `#4357`_ + - bug + - medium + - Parsing model: Creating `TRY` and `WHILE` statements using `from_params` is not possible + * - `#4359`_ + - bug + - medium + - Parsing model: `Variable.from_params` doesn't handle list values properly + * - `#4364`_ + - bug + - medium + - `@{list}` used as embedded argument not anymore expanded if keyword accepts varargs + * - `#4381`_ + - bug + - medium + - Parsing errors are recognized as EmptyLines + * - `#4384`_ + - bug + - medium + - RPA aliases for settings do not work in suite initialization files + * - `#4387`_ + - bug + - medium + - Libdoc: Fix storing information about deprecated keywords to spec files + * - `#4408`_ + - bug + - medium + - Collection: `Dictionary Should Contain Item` incorrectly casts values to strings before comparison + * - `#4418`_ + - bug + - medium + - Dictionaries insider lists in YAML variable files not converted to DotDict objects + * - `#4438`_ + - bug + - medium + - `Get Time` returns current time if it is given input time that matches epoch + * - `#4441`_ + - bug + - medium + - Regression: Empty `--include/--exclude/--test/--suite` are not ignored + * - `#4447`_ + - bug + - medium + - Evaluating expressions that modify evaluation namespace (locals) fail + * - `#4455`_ + - bug + - medium + - Standard libraries don't support `pathlib.Path` objects + * - `#4464`_ + - bug + - medium + - Libdoc: Type hints aren't shown for types with same name as Javascript variables available in browser namespace + * - `#4476`_ + - bug + - medium + - BuiltIn: `Call Method` loses traceback if calling the method fails + * - `#4480`_ + - bug + - medium + - Creating log and report fails if WHILE loop has no condition + * - `#4482`_ + - bug + - medium + - WHILE and FOR loop contents not shown in log if running them fails due to errors + * - `#4484`_ + - bug + - medium + - Invalid TRY/EXCEPT structure causes normal error, not syntax error + * - `#4262`_ + - enhancement + - medium + - Honor `SOURCE_DATE_EPOCH` environment variable when generating library documentation + * - `#4312`_ + - enhancement + - medium + - Add project URLs to PyPI + * - `#4353`_ + - enhancement + - medium + - Performance enhancements to parsing + * - `#4354`_ + - enhancement + - medium + - When merging suites with Rebot, copy documentation and metadata from merged suites + * - `#4371`_ + - enhancement + - medium + - Add `AS` alias for `WITH NAME` in library imports + * - `#4379`_ + - enhancement + - medium + - Require space after Given/When/Then prefixes + * - `#4398`_ + - enhancement + - medium + - Collections: `Get From Dictionary` should accept a default value + * - `#4404`_ + - enhancement + - medium + - Document that failing test setup stops execution even if continue-on-failure mode is active + * - `#4413`_ + - enhancement + - medium + - Dictionary related keywords in `Collections` are more script about accepted values + * - `#4429`_ + - enhancement + - medium + - Allow passing flags to regexp related keywords using explicit `flags` argument + * - `#4431`_ + - enhancement + - medium + - Deprecate using singular section headers + * - `#4440`_ + - enhancement + - medium + - Allow using `None` as custom argument converter to enable strict type validation + * - `#4461`_ + - enhancement + - medium + - Automatic argument conversion for `pathlib.Path` + * - `#4462`_ + - enhancement + - medium + - Deprecate using embedded arguments using variables that do not match custom regexp + * - `#4470`_ + - enhancement + - medium + - Enhance `Keyword Should Exist` performance by not looking for possible recommendations + * - `#4490`_ + - enhancement + - medium + - Time string parsing for micro and nanoseconds + * - `#4497`_ + - enhancement + - medium + - Libdoc: Support setting dark or light mode explicitly + * - `#4349`_ + - bug + - low + - User Guide: Example related to YAML variable files is buggy + * - `#4358`_ + - bug + - low + - User Guide: Errors in examples related to TRY/EXCEPT + * - `#4453`_ + - bug + - low + - `Run Keywords`: Execution is not continued in teardown if keyword name contains non-existing variable + * - `#4471`_ + - bug + - low + - Libdoc: If keyword and type have same case-insensitive name, opening type info opens keyword documentation + * - `#4481`_ + - bug + - low + - Invalid BREAK and CONTINUE cause errros even when not actually executed + * - `#4346`_ + - enhancement + - low + - Enhance documentation of the `--timestampoutputs` option + * - `#4372`_ + - enhancement + - low + - Document how to import resource files bundled into Python packages + * - `#4485`_ + - enhancement + - low + - Explain the default value of `Sleep` keyword better in its documentation + * - `#4500`_ + - enhancement + - low + - Deprecate `robot.utils.TRUE/FALSE_STRINGS` + * - `#4511`_ + - enhancement + - low + - Support custom converter with more than one argument as long as they are not mandatory + * - `#4394`_ + - bug + - --- + - Error when `--doc` or `--metadata` value matches an existing directory + +Altogether 68 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0>`__. + +.. _#4096: https://github.com/robotframework/robotframework/issues/4096 +.. _#4390: https://github.com/robotframework/robotframework/issues/4390 +.. _#519: https://github.com/robotframework/robotframework/issues/519 +.. _#1595: https://github.com/robotframework/robotframework/issues/1595 +.. _#4348: https://github.com/robotframework/robotframework/issues/4348 +.. _#4483: https://github.com/robotframework/robotframework/issues/4483 +.. _#4295: https://github.com/robotframework/robotframework/issues/4295 +.. _#430: https://github.com/robotframework/robotframework/issues/430 +.. _#4303: https://github.com/robotframework/robotframework/issues/4303 +.. _#4335: https://github.com/robotframework/robotframework/issues/4335 +.. _#4366: https://github.com/robotframework/robotframework/issues/4366 +.. _#4368: https://github.com/robotframework/robotframework/issues/4368 +.. _#4373: https://github.com/robotframework/robotframework/issues/4373 +.. _#4380: https://github.com/robotframework/robotframework/issues/4380 +.. _#4388: https://github.com/robotframework/robotframework/issues/4388 +.. _#4400: https://github.com/robotframework/robotframework/issues/4400 +.. _#4401: https://github.com/robotframework/robotframework/issues/4401 +.. _#4433: https://github.com/robotframework/robotframework/issues/4433 +.. _#4454: https://github.com/robotframework/robotframework/issues/4454 +.. _#4477: https://github.com/robotframework/robotframework/issues/4477 +.. _#4493: https://github.com/robotframework/robotframework/issues/4493 +.. _#4351: https://github.com/robotframework/robotframework/issues/4351 +.. _#4355: https://github.com/robotframework/robotframework/issues/4355 +.. _#4357: https://github.com/robotframework/robotframework/issues/4357 +.. _#4359: https://github.com/robotframework/robotframework/issues/4359 +.. _#4364: https://github.com/robotframework/robotframework/issues/4364 +.. _#4381: https://github.com/robotframework/robotframework/issues/4381 +.. _#4384: https://github.com/robotframework/robotframework/issues/4384 +.. _#4387: https://github.com/robotframework/robotframework/issues/4387 +.. _#4408: https://github.com/robotframework/robotframework/issues/4408 +.. _#4418: https://github.com/robotframework/robotframework/issues/4418 +.. _#4438: https://github.com/robotframework/robotframework/issues/4438 +.. _#4441: https://github.com/robotframework/robotframework/issues/4441 +.. _#4447: https://github.com/robotframework/robotframework/issues/4447 +.. _#4455: https://github.com/robotframework/robotframework/issues/4455 +.. _#4464: https://github.com/robotframework/robotframework/issues/4464 +.. _#4476: https://github.com/robotframework/robotframework/issues/4476 +.. _#4480: https://github.com/robotframework/robotframework/issues/4480 +.. _#4482: https://github.com/robotframework/robotframework/issues/4482 +.. _#4484: https://github.com/robotframework/robotframework/issues/4484 +.. _#4262: https://github.com/robotframework/robotframework/issues/4262 +.. _#4312: https://github.com/robotframework/robotframework/issues/4312 +.. _#4353: https://github.com/robotframework/robotframework/issues/4353 +.. _#4354: https://github.com/robotframework/robotframework/issues/4354 +.. _#4371: https://github.com/robotframework/robotframework/issues/4371 +.. _#4379: https://github.com/robotframework/robotframework/issues/4379 +.. _#4398: https://github.com/robotframework/robotframework/issues/4398 +.. _#4404: https://github.com/robotframework/robotframework/issues/4404 +.. _#4413: https://github.com/robotframework/robotframework/issues/4413 +.. _#4429: https://github.com/robotframework/robotframework/issues/4429 +.. _#4431: https://github.com/robotframework/robotframework/issues/4431 +.. _#4440: https://github.com/robotframework/robotframework/issues/4440 +.. _#4461: https://github.com/robotframework/robotframework/issues/4461 +.. _#4462: https://github.com/robotframework/robotframework/issues/4462 +.. _#4470: https://github.com/robotframework/robotframework/issues/4470 +.. _#4490: https://github.com/robotframework/robotframework/issues/4490 +.. _#4497: https://github.com/robotframework/robotframework/issues/4497 +.. _#4349: https://github.com/robotframework/robotframework/issues/4349 +.. _#4358: https://github.com/robotframework/robotframework/issues/4358 +.. _#4453: https://github.com/robotframework/robotframework/issues/4453 +.. _#4471: https://github.com/robotframework/robotframework/issues/4471 +.. _#4481: https://github.com/robotframework/robotframework/issues/4481 +.. _#4346: https://github.com/robotframework/robotframework/issues/4346 +.. _#4372: https://github.com/robotframework/robotframework/issues/4372 +.. _#4485: https://github.com/robotframework/robotframework/issues/4485 +.. _#4500: https://github.com/robotframework/robotframework/issues/4500 +.. _#4511: https://github.com/robotframework/robotframework/issues/4511 +.. _#4394: https://github.com/robotframework/robotframework/issues/4394 From e562226eb5b8769912ab8248a3af2ee0171eadbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 13:26:53 +0300 Subject: [PATCH 0277/1592] Updated version to 6.0 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index c3078dbcb37..10aa961595d 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc3.dev1' +VERSION = '6.0' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 4799ad2014d..ee115e840cb 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0rc3.dev1' +VERSION = '6.0' def get_version(naked=False): From 36a179906dd61faf356aa28d047cfce7861cd0dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 13:38:25 +0300 Subject: [PATCH 0278/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 10aa961595d..78e7cdc3560 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0' +VERSION = '6.0.1.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index ee115e840cb..90d6cfd6d70 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0' +VERSION = '6.0.1.dev1' def get_version(naked=False): From 6084b6501ce0eb572f4571262149155b9ff5c923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 17:45:07 +0300 Subject: [PATCH 0279/1592] UG: Remove references to WITH NAME, use AS instead --- .../src/CreatingTestData/AdvancedFeatures.rst | 6 ++++-- .../src/CreatingTestData/UsingTestLibraries.rst | 4 ++-- .../ExtendingRobotFramework/CreatingTestLibraries.rst | 4 +++- .../src/ExtendingRobotFramework/ListenerInterface.rst | 8 ++++---- .../src/ExtendingRobotFramework/RemoteLibrary.rst | 11 ++++++----- doc/userguide/src/RobotFrameworkUserGuide.rst | 1 - 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst b/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst index 78976b0cb29..422cd521ed8 100644 --- a/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst +++ b/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst @@ -56,8 +56,8 @@ from the OperatingSystem_ library could be used as :name:`OperatingSystem.Run`, even if there was another :name:`Run` keyword somewhere else. If the library is in a module or package, the full module or package name must be used (for example, -:name:`com.company.Library.Some Keyword`). If a custom name is given -to a library using the `WITH NAME syntax`_, the specified name must be +:name:`com.company.Library.Some Keyword`). If a `custom name`__ is given +to a library when importing it, the specified name must be used also in the full keyword name. Resource files are specified in the full keyword name, similarly as @@ -70,6 +70,8 @@ cases, either the files or the keywords must be renamed. The full name of the keyword is case-, space- and underscore-insensitive, similarly as normal keyword names. +__ `Setting custom name to library`_ + .. _library search order: Specifying explicit priority between libraries and resources diff --git a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst index 52fb42dce11..20fddb8ed7b 100644 --- a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst +++ b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst @@ -123,8 +123,8 @@ be in a module with the same name as the class`__. __ `Library name`_ -Setting custom name to test library ------------------------------------ +Setting custom name to library +------------------------------ The library name is shown in test logs before keyword names, and if multiple keywords have the same name, they must be used so that the diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 6e97bfff4b5..a3b31a34cd5 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -100,7 +100,9 @@ taken into use using both module and class names, such as :name:`mymodule.MyLibrary` or :name:`parent.submodule.MyLib`. .. tip:: If the library name is really long, it is recommended to give - the library a simpler alias by using the `WITH NAME syntax`_. + the library a `simpler alias`__ by using `AS`. + +__ `Setting custom name to library`_ Providing arguments to libraries ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index bc86a8b56f2..6abba68fe0b 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -331,14 +331,14 @@ it. If that is needed, `listener version 3`_ can be used instead. | library_import | name, attributes | Called when a library has been imported. | | | | | | | | `name` is the name of the imported library. If the library | - | | | has been imported using the `WITH NAME syntax`_, `name` is | - | | | the specified alias. | + | | | has been given a custom name when imported it using `AS`, | + | | | `name` is the specified alias. | | | | | | | | Contents of the attribute dictionary: | | | | | | | | * `args`: Arguments passed to the library as a list. | - | | | * `originalname`: The original library name when using the | - | | | WITH NAME syntax, otherwise same as `name`. | + | | | * `originalname`: The original library name if the library has | + | | | been given an alias using `AS`, otherwise same as `name`. | | | | * `source`: An absolute path to the library source. `None` | | | | if getting the | | | | source of the library failed for some reason. | diff --git a/doc/userguide/src/ExtendingRobotFramework/RemoteLibrary.rst b/doc/userguide/src/ExtendingRobotFramework/RemoteLibrary.rst index 7f99f05a6fd..cd9420cd5af 100644 --- a/doc/userguide/src/ExtendingRobotFramework/RemoteLibrary.rst +++ b/doc/userguide/src/ExtendingRobotFramework/RemoteLibrary.rst @@ -58,15 +58,15 @@ Importing Remote library The Remote library needs to know the address of the remote server but otherwise importing it and using keywords that it provides is no different to how other libraries are used. If you need to use the Remote -library multiple times in a test suite, or just want to give it a more -descriptive name, you can import it using the `WITH NAME syntax`_. +library multiple times in a suite, or just want to give it a more +descriptive name, you can give it an `alias when importing it`__. .. sourcecode:: robotframework *** Settings *** - Library Remote http://127.0.0.1:8270 WITH NAME Example1 - Library Remote http://example.com:8080/ WITH NAME Example2 - Library Remote http://10.0.0.2/example 1 minute WITH NAME Example3 + Library Remote http://127.0.0.1:8270 AS Example1 + Library Remote http://example.com:8080/ AS Example2 + Library Remote http://10.0.0.2/example 1 minute AS Example3 The URL used by the first example above is also the default address that the Remote library uses if no address is given. @@ -98,6 +98,7 @@ is shorter than keyword execution time will interrupt the keyword. `http://127.0.0.1:8270/` nor `http://127.0.0.1:8270/my/path` will be modified. +__ `Setting custom name to library`_ __ http://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?search=8270 __ http://stackoverflow.com/questions/14504450/pythons-xmlrpc-extremely-slow-one-second-per-call diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index be435634d52..9adc98d1391 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -167,7 +167,6 @@ .. _libraries: `test libraries`_ .. _library keyword: `test libraries`_ .. _library keywords: `library keyword`_ -.. _`With Name syntax`: `Setting custom name to test library`_ .. _SeleniumLibrary: https://github.com/robotframework/SeleniumLibrary .. _SwingLibrary: https://github.com/robotframework/SwingLibrary .. _localized: Localization_ From fc6bfc4edde0b5f7ce09b8717ff89bc3e37b7b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 17:51:06 +0300 Subject: [PATCH 0280/1592] Tuning --- doc/releasenotes/rf-6.0.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/releasenotes/rf-6.0.rst b/doc/releasenotes/rf-6.0.rst index 419b9c42657..5e88c28fde8 100644 --- a/doc/releasenotes/rf-6.0.rst +++ b/doc/releasenotes/rf-6.0.rst @@ -6,9 +6,10 @@ Robot Framework 6.0 `Robot Framework`_ 6.0 is a new major release that starts Robot Framework's localization efforts. In addition to that, it contains several nice enhancements -related to, for example, automatic argument conversion and using embedded arguments. -Initially it had version 5.1 and was considered a feature release, but it grow -so big that we decided to make it a major release instead. +related to, for example, automatic argument conversion, keyword namespaces and +using embedded arguments. It was initially considered a feature release and +had version 5.1, but it grew so big that we considered flipping the major +number more appropriate. Questions and comments related to the release can be sent to the `robotframework-users`_ mailing list or to `Robot Framework Slack`_, From 314f39879a2d669769f82d25586dee7f82cc7a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 19 Oct 2022 19:29:55 +0300 Subject: [PATCH 0281/1592] API doc typo fix --- src/robot/conf/languages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 1b1f4b69b3d..f444dacba23 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -41,7 +41,7 @@ def __init__(self, languages=None, add_english=True): :param add_english: If True, English is added automatically. :raises: :class:`~robot.errors.DataError` if a given language is not found. - :meth:`add.language` can be used to add languages after initialization. + :meth:`add_language` can be used to add languages after initialization. """ self.languages = [] self.headers = {} From 886ffcf83522b2d5559efc78190e024dbbc9ff42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 20 Oct 2022 19:51:57 +0300 Subject: [PATCH 0282/1592] Fix multipart BDD prefixes. #4515 --- .../keywords/optional_given_when_then.robot | 12 +++++++++- .../keywords/optional_given_when_then.robot | 21 +++++++++++++++-- src/robot/running/namespace.py | 23 +++++++++---------- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/atest/robot/keywords/optional_given_when_then.robot b/atest/robot/keywords/optional_given_when_then.robot index 3fc6b6b878e..88784053514 100644 --- a/atest/robot/keywords/optional_given_when_then.robot +++ b/atest/robot/keywords/optional_given_when_then.robot @@ -46,7 +46,7 @@ Keyword can be used with and without prefix Should Be Equal ${tc.kws[5].name} Then we are in Berlin city Should Be Equal ${tc.kws[6].name} we are in Berlin city -In user keyword name with normal arguments and localized prefixes +Localized prefixes ${tc} = Check Test Case ${TEST NAME} Should Be Equal ${tc.kws[0].name} Oletetaan we don't drink too many beers Should Be Equal ${tc.kws[1].name} Kun we are in @@ -55,5 +55,15 @@ In user keyword name with normal arguments and localized prefixes Should Be Equal ${tc.kws[4].name} Niin we get this feature ready today Should Be Equal ${tc.kws[5].name} ja we don't drink too many beers +Prefix consisting of multiple words + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].name} Étant donné multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[1].name} Zakładając, że multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[2].name} Diyelim ki multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[3].name} Eğer ki multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[4].name} O zaman multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[5].name} В случай че multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[6].name} Fie ca multipart prefixes didn't work with RF 6.0 + Prefix must be followed by space Check Test Case ${TEST NAME} diff --git a/atest/testdata/keywords/optional_given_when_then.robot b/atest/testdata/keywords/optional_given_when_then.robot index b5afa8f374b..16358241249 100644 --- a/atest/testdata/keywords/optional_given_when_then.robot +++ b/atest/testdata/keywords/optional_given_when_then.robot @@ -1,3 +1,9 @@ +Language: French +Language: Polish +Language: Turkish +Language: Bulgarian +Language: Romanian + *** Settings *** Resource resources/optional_given_when_then.robot @@ -42,7 +48,7 @@ Keyword can be used with and without prefix Then we are in Berlin city we are in Berlin city -In user keyword name with normal arguments and localized prefixes +Localized prefixes Oletetaan we don't drink too many beers Kun we are in museum cafe mutta we don't drink too many beers @@ -50,13 +56,21 @@ In user keyword name with normal arguments and localized prefixes Niin we get this feature ready today ja we don't drink too many beers +Prefix consisting of multiple words + Étant donné multipart prefixes didn't work with RF 6.0 + Zakładając, że multipart prefixes didn't work with RF 6.0 + Diyelim ki multipart prefixes didn't work with RF 6.0 + Eğer ki multipart prefixes didn't work with RF 6.0 + O zaman multipart prefixes didn't work with RF 6.0 + В случай че multipart prefixes didn't work with RF 6.0 + Fie ca multipart prefixes didn't work with RF 6.0 + Prefix must be followed by space [Documentation] FAIL ... No keyword with name 'Givenwe don't drink too many beers' found. Did you mean: ... ${SPACE*4}We Don't Drink Too Many Beers Givenwe don't drink too many beers - *** Keywords *** We don't drink too many beers No Operation @@ -83,3 +97,6 @@ We ${x} This ${thing} Implemented We Go To ${somewhere} Should Be Equal ${somewhere} walking tour + +Multipart prefixes didn't work with RF 6.0 + No Operation diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index bce60e2f82c..1467ef3a350 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -295,20 +295,19 @@ def _get_runner(self, name): if not runner: runner = self._get_implicit_runner(name) if not runner: - runner = self._get_bdd_style_runner(name) + runner = self._get_bdd_style_runner(name, self.languages.bdd_prefixes) return runner - def _get_bdd_style_runner(self, name): - parts = name.split(maxsplit=1) - if len(parts) < 2: - return None - prefix, keyword = parts - if prefix.title() in self.languages.bdd_prefixes: - runner = self._get_runner(keyword) - if runner: - runner = copy.copy(runner) - runner.name = name - return runner + def _get_bdd_style_runner(self, name, prefixes): + parts = name.split() + for index in range(1, len(parts)): + prefix = ' '.join(parts[:index]).title() + if prefix in prefixes: + runner = self._get_runner(' '.join(parts[index:])) + if runner: + runner = copy.copy(runner) + runner.name = name + return runner return None def _get_implicit_runner(self, name): From acc61b8c931aa588b77352a8150851e390cd1356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 27 Oct 2022 20:54:14 +0300 Subject: [PATCH 0283/1592] Fix search order w/ two matches when one is from std lib. Search order needs to be used first on standard library keywords filtered only afterwards. Fixes #4516. --- atest/robot/keywords/keyword_namespaces.robot | 29 ++++++++++++++----- .../keywords/keyword_namespaces.robot | 12 ++++++++ src/robot/running/namespace.py | 4 +-- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/atest/robot/keywords/keyword_namespaces.robot b/atest/robot/keywords/keyword_namespaces.robot index a19702c3601..f8b5c96cb44 100644 --- a/atest/robot/keywords/keyword_namespaces.robot +++ b/atest/robot/keywords/keyword_namespaces.robot @@ -46,12 +46,24 @@ Local keyword in resource file has precedence even if search order is set Keyword From Custom Library Overrides Keywords From Standard Library ${tc} = Check Test Case ${TEST NAME} - Verify Override Message ${ERRORS}[2] ${tc.kws[0].msgs[0]} Comment BuiltIn - Verify Override Message ${ERRORS}[3] ${tc.kws[1].msgs[0]} Copy Directory OperatingSystem + Verify Override Message ${ERRORS}[2] ${tc.kws[0]} Comment BuiltIn + Verify Override Message ${ERRORS}[3] ${tc.kws[1]} Copy Directory OperatingSystem + +Search order can give presedence to standard library keyword over custom keyword + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc.kws[1]} BuiltIn.Comment args=Used from BuiltIn + Verify Override Message ${ERRORS}[4] ${tc.kws[2]} Copy Directory OperatingSystem + +Search order can give presedence to custom keyword over standard library keyword + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc.kws[1]} MyLibrary1.Comment + Check Log Message ${tc.kws[1].msgs[0]} Overrides keyword from BuiltIn library + Check Keyword Data ${tc.kws[2]} MyLibrary1.Copy Directory + Check Log Message ${tc.kws[2].msgs[0]} Overrides keyword from OperatingSystem library Keyword From Custom Library Overrides Keywords From Standard Library Even When Std Lib Imported With Different Name ${tc} = Check Test Case ${TEST NAME} - Verify Override Message ${ERRORS}[4] ${tc.kws[0].msgs[0]} Replace String + Verify Override Message ${ERRORS}[5] ${tc.kws[0]} Replace String ... String MyLibrary2 Std With Name My With Name No Warning When Custom Library Keyword Is Registered As RunKeyword Variant And It Has Same Name As Std Keyword @@ -71,16 +83,17 @@ Keywords are first searched from test case file even if they contain dot *** Keywords *** Verify override message - [Arguments] ${error msg} ${kw msg} ${kw} ${standard} ${custom}=MyLibrary1 + [Arguments] ${error msg} ${kw} ${name} ${standard} ${custom}=MyLibrary1 ... ${std with name}= ${ctm with name}= ${std imported as} = Set Variable If "${std with name}" ${SPACE}imported as '${std with name}' ${EMPTY} ${ctm imported as} = Set Variable If "${ctm with name}" ${SPACE}imported as '${ctm with name}' ${EMPTY} ${std long} = Set Variable If "${std with name}" ${std with name} ${standard} ${ctm long} = Set Variable If "${ctm with name}" ${ctm with name} ${custom} ${expected} = Catenate - ... Keyword '${kw}' found both from a custom library '${custom}'${ctm imported as} + ... Keyword '${name}' found both from a custom library '${custom}'${ctm imported as} ... and a standard library '${standard}'${std imported as}. The custom keyword is used. - ... To select explicitly, and to get rid of this warning, use either '${ctm long}.${kw}' - ... or '${std long}.${kw}'. + ... To select explicitly, and to get rid of this warning, use either '${ctm long}.${name}' + ... or '${std long}.${name}'. Check Log Message ${error msg} ${expected} WARN - Check Log Message ${kw msg} ${expected} WARN + Check Log Message ${kw.msgs[0]} ${expected} WARN + Check Log Message ${kw.msgs[1]} Overrides keyword from ${standard} library diff --git a/atest/testdata/keywords/keyword_namespaces.robot b/atest/testdata/keywords/keyword_namespaces.robot index 2753540cf87..81a015abbbc 100644 --- a/atest/testdata/keywords/keyword_namespaces.robot +++ b/atest/testdata/keywords/keyword_namespaces.robot @@ -73,6 +73,18 @@ Keyword From Custom Library Overrides Keywords From Standard Library Comment Copy Directory +Search order can give presedence to standard library keyword over custom keyword + Set Library Search Order BuiltIn + Comment Used from BuiltIn + Copy Directory + [Teardown] Set Library Search Order + +Search order can give presedence to custom keyword over standard library keyword + Set Library Search Order MyLibrary1 + Comment + Copy Directory + [Teardown] Set Library Search Order + Keyword From Custom Library Overrides Keywords From Standard Library Even When Std Lib Imported With Different Name ${ret} = Replace String Should Be Equal ${ret} I replace nothing! diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 1467ef3a350..c71823a8a94 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -390,9 +390,9 @@ def _get_runner_from_libraries(self, name): if len(handlers) > 1: handlers = self._select_best_matches(handlers) if len(handlers) > 1: - handlers, pre_run_message = self._filter_stdlib_handler(handlers) + handlers = self._filter_based_on_search_order(handlers) if len(handlers) > 1: - handlers = self._filter_based_on_search_order(handlers) + handlers, pre_run_message = self._filter_stdlib_handler(handlers) if len(handlers) != 1: self._raise_multiple_keywords_found(handlers, name) runner = handlers[0].create_runner(name, self.languages) From 6c92dd471498a8aa318e9ceb078cbc8936480e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Oct 2022 13:57:00 +0300 Subject: [PATCH 0284/1592] Enhance docs of Libdoc's public API. Fixes #4520. --- src/robot/libdoc.py | 10 ++++++---- src/robot/libdocpkg/__init__.py | 5 +---- src/robot/libdocpkg/builder.py | 28 ++++++++++++++++++++++++++-- src/robot/libdocpkg/model.py | 4 ++++ utest/libdoc/test_libdoc_api.py | 4 ++++ 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/robot/libdoc.py b/src/robot/libdoc.py index 4df5b93fec4..74045608e69 100755 --- a/src/robot/libdoc.py +++ b/src/robot/libdoc.py @@ -23,10 +23,12 @@ python -m robot.libdoc python path/to/robot/libdoc.py -Instead of ``python`` it is possible to use also other Python interpreters. +This module also exposes the following public API: -This module also provides :func:`libdoc` and :func:`libdoc_cli` functions -that can be used programmatically. Other code is for internal usage. +- :func:`libdoc_cli` function for simple command line tools. +- :func:`libdoc` function as a high level programmatic API. +- :func:`~robot.libdocpkg.builder.LibraryDocumentation` as the API to generate + :class:`~robot.libdocpkg.model.LibraryDoc` instances. Libdoc itself is implemented in the :mod:`~robot.libdocpkg` package. """ @@ -255,7 +257,7 @@ def libdoc(library_or_resource, outfile, name='', version='', format=None, :param library_or_resource: Name or path of the library or resource file to be documented. - :param outfile: Path path to the file where to write outputs. + :param outfile: Path to the file where to write outputs. :param name: Custom name to give to the documented library or resource. :param version: Version to give to the documented library or resource. :param format: Specifies whether to generate HTML, XML or JSON output. diff --git a/src/robot/libdocpkg/__init__.py b/src/robot/libdocpkg/__init__.py index 7ca6db75272..fac429867fa 100644 --- a/src/robot/libdocpkg/__init__.py +++ b/src/robot/libdocpkg/__init__.py @@ -15,10 +15,7 @@ """Implements the `Libdoc` tool. -The command line entry point and programmatic interface for Libdoc -are provided by the separate :mod:`robot.libdoc` module. - -This package is considered stable but it is not part of the public API. +The public Libdoc API is exposed via the :mod:`robot.libdoc` module. """ from .builder import LibraryDocumentation diff --git a/src/robot/libdocpkg/builder.py b/src/robot/libdocpkg/builder.py index 4b38fcdbb39..6bb3b2a9a1c 100644 --- a/src/robot/libdocpkg/builder.py +++ b/src/robot/libdocpkg/builder.py @@ -27,8 +27,32 @@ XML_EXTENSIONS = ('xml', 'libspec') -def LibraryDocumentation(library_or_resource, name=None, version=None, - doc_format=None): +def LibraryDocumentation(library_or_resource, name=None, version=None, doc_format=None): + """Generate keyword documentation for the given library, resource or suite file. + + :param library_or_resource: + Name or path of the library, or path of a resource or a suite file. + :param name: + Set name with the given value. + :param version: + Set version to the given value. + :param doc_format: + Set documentation format to the given value. + :return: + :class:`~.model.LibraryDoc` instance. + + This factory method is the recommended API to generate keyword documentation + programmatically. It should be imported via the :mod:`robot.libdoc` module. + + Example:: + + from robot.libdoc import LibraryDocumentation + + lib = LibraryDocumentation('OperatingSystem') + print(lib.name, lib.version) + for kw in lib.keywords: + print(kw.name) + """ builder = DocumentationBuilder(library_or_resource) libdoc = _build(builder, library_or_resource) if name: diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index efa17b4f6bf..2b7c4a12288 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -27,6 +27,7 @@ class LibraryDoc: + """Documentation for a library, a resource file or a suite file.""" def __init__(self, name='', doc='', version='', type='LIBRARY', scope='TEST', doc_format='ROBOT', source=None, lineno=-1): @@ -67,10 +68,12 @@ def doc_format(self, format): @setter def inits(self, inits): + """Initializer docs as :class:`~KeywordDoc` instances.""" return self._process_keywords(inits) @setter def keywords(self, kws): + """Keyword docs as :class:`~KeywordDoc` instances.""" return self._process_keywords(kws) @setter @@ -146,6 +149,7 @@ def to_json(self, indent=None, include_private=True, theme=None): class KeywordDoc(Sortable): + """Documentation for a single keyword or an initializer.""" def __init__(self, name='', args=None, doc='', shortdoc='', tags=(), private=False, deprecated=False, source=None, lineno=-1, parent=None): diff --git a/utest/libdoc/test_libdoc_api.py b/utest/libdoc/test_libdoc_api.py index de2ea0957c9..d305ee26c55 100644 --- a/utest/libdoc/test_libdoc_api.py +++ b/utest/libdoc/test_libdoc_api.py @@ -43,6 +43,10 @@ def test_quiet(self): with open(output) as f: assert '"name": "String"' in f.read() + def test_LibraryDocumentation(self): + doc = libdoc.LibraryDocumentation('OperatingSystem') + assert_equal(doc.name, 'OperatingSystem') + if __name__ == '__main__': unittest.main() From e4bdf022e8041f74e99bc31e19405b1c027c2237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Oct 2022 14:01:57 +0300 Subject: [PATCH 0285/1592] Fix DocumentationBuilder w/ resource files having .robot extension. Fixes #4519. --- src/robot/libdocpkg/builder.py | 98 ++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/src/robot/libdocpkg/builder.py b/src/robot/libdocpkg/builder.py index 6bb3b2a9a1c..16574dae6a0 100644 --- a/src/robot/libdocpkg/builder.py +++ b/src/robot/libdocpkg/builder.py @@ -53,8 +53,7 @@ def LibraryDocumentation(library_or_resource, name=None, version=None, doc_forma for kw in lib.keywords: print(kw.name) """ - builder = DocumentationBuilder(library_or_resource) - libdoc = _build(builder, library_or_resource) + libdoc = DocumentationBuilder().build(library_or_resource) if name: libdoc.name = name if version: @@ -64,47 +63,56 @@ def LibraryDocumentation(library_or_resource, name=None, version=None, doc_forma return libdoc -def _build(builder, source): - try: - return builder.build(source) - except DataError: - # Possible resource file in PYTHONPATH. Something like `xxx.resource` that - # did not exist has been considered to be a library earlier, now we try to - # parse it as a resource file. - if (isinstance(builder, LibraryDocBuilder) - and not os.path.exists(source) - and _get_extension(source) in RESOURCE_EXTENSIONS): - return _build(ResourceDocBuilder(), source) - # Resource file with other extension than '.resource' parsed as a suite file. - if isinstance(builder, SuiteDocBuilder): - return _build(ResourceDocBuilder(), source) - raise - except Exception: - raise DataError(f"Building library '{source}' failed: {get_error_message()}") - - -def _get_extension(source): - path, *args = source.split('::') - return os.path.splitext(path)[1][1:].lower() - - -def DocumentationBuilder(library_or_resource): - """Create a documentation builder for the specified library or resource. - - The argument can be a path to a library, a resource file or to a spec file - generated by Libdoc earlier. If the argument does not point to an existing file, - it is expected to be the name of the library to be imported. If a resource file - is to be imported from PYTHONPATH, then :class:`~.robotbuilder.ResourceDocBuilder` - must be used explicitly instead. +class DocumentationBuilder: + """Keyword documentation builder. + + This is not part of Libdoc's public API. Use :func:`LibraryDocumentation` + instead. """ - if os.path.exists(library_or_resource): - extension = _get_extension(library_or_resource) - if extension == 'resource': - return ResourceDocBuilder() - if extension in RESOURCE_EXTENSIONS: - return SuiteDocBuilder() - if extension in XML_EXTENSIONS: - return XmlDocBuilder() - if extension == 'json': - return JsonDocBuilder() - return LibraryDocBuilder() + + def __init__(self, library_or_resource=None): + """`library_or_resource` is accepted for backwards compatibility reasons. + + It is not used for anything internally and passing it to the builder is + considered deprecated starting from RF 6.0.1. + """ + pass + + def build(self, source): + builder = self._get_builder(source) + return self._build(builder, source) + + def _get_builder(self, source): + if os.path.exists(source): + extension = self._get_extension(source) + if extension == 'resource': + return ResourceDocBuilder() + if extension in RESOURCE_EXTENSIONS: + return SuiteDocBuilder() + if extension in XML_EXTENSIONS: + return XmlDocBuilder() + if extension == 'json': + return JsonDocBuilder() + return LibraryDocBuilder() + + def _get_extension(self, source): + path, *args = source.split('::') + return os.path.splitext(path)[1][1:].lower() + + def _build(self, builder, source): + try: + return builder.build(source) + except DataError: + # Possible resource file in PYTHONPATH. Something like `xxx.resource` that + # did not exist has been considered to be a library earlier, now we try to + # parse it as a resource file. + if (isinstance(builder, LibraryDocBuilder) + and not os.path.exists(source) + and self._get_extension(source) in RESOURCE_EXTENSIONS): + return self._build(ResourceDocBuilder(), source) + # Resource file with other extension than '.resource' parsed as a suite file. + if isinstance(builder, SuiteDocBuilder): + return self._build(ResourceDocBuilder(), source) + raise + except Exception: + raise DataError(f"Building library '{source}' failed: {get_error_message()}") From e1ccafe5487aeb05b2cb12a34cf55c39d1010113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Oct 2022 14:28:45 +0300 Subject: [PATCH 0286/1592] Add `timedelta` support to `timestr_to_secs`. Fixes #4521. Also mention that `accept_plain_values` is deprecated (#4522). --- src/robot/utils/robottime.py | 14 +++++++++++++- utest/utils/test_robottime.py | 6 +++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 80eeb0cace3..9b325b7db42 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -15,6 +15,7 @@ import re import time +from datetime import timedelta from .normalizing import normalize from .misc import plural_or_not @@ -39,7 +40,16 @@ def _float_secs_to_secs_and_millis(secs): def timestr_to_secs(timestr, round_to=3, accept_plain_values=True): - """Parses time like '1h 10s', '01:00:10' or '42' and returns seconds.""" + """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, + as a `timedelta` instance. + + The result is rounded according to the `round_to` argument. + Use `round_to=None` to disable rounding altogether. + + `accept_plain_values` is considered deprecated and should not be used. + """ if is_string(timestr) or is_number(timestr): if accept_plain_values: converters = [_number_to_secs, _timer_to_secs, _time_string_to_secs] @@ -49,6 +59,8 @@ def timestr_to_secs(timestr, round_to=3, accept_plain_values=True): secs = converter(timestr) if secs is not None: return secs if round_to is None else round(secs, round_to) + if isinstance(timestr, timedelta): + return timestr.total_seconds() raise ValueError("Invalid time string '%s'." % timestr) diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index 6cbb909a240..1123f73b604 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -1,7 +1,7 @@ import unittest import re import time -from datetime import datetime +from datetime import datetime, timedelta from robot.utils.asserts import (assert_equal, assert_raises_with_msg, assert_true, assert_not_none) @@ -154,6 +154,10 @@ def test_timestr_to_secs_with_timer_string(self): exp += 0.5 if inp[0] != '-' else -0.5 assert_equal(timestr_to_secs(inp), exp, inp) + def test_timestr_to_secs_with_timedelta(self): + assert_equal(timestr_to_secs(timedelta(minutes=1)), 60) + assert_equal(timestr_to_secs(timedelta(microseconds=1000)), 0.001) + def test_timestr_to_secs_custom_rounding(self): secs = 0.123456789 for round_to in 0, 1, 6: From 628b44ba2cc12f7979beb74a2d5e2504af56a7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 30 Oct 2022 20:34:46 +0200 Subject: [PATCH 0287/1592] Fix unit test failing around DST. Fixes #4523. --- utest/utils/test_robottime.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index 1123f73b604..4447ab96059 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -340,13 +340,17 @@ def test_parse_time_with_now_and_utc(self): ('now - 1 day 100 seconds', -86500), ('now + 1day 10hours 1minute 10secs', 122470), ('NOW - 1D 10H 1MIN 10S', -122470)]: - expected = get_time('epoch') + adjusted + now = int(time.time()) parsed = parse_time(input) - assert_true(expected <= parsed <= expected + 1), + expected = now + adjusted + if time.localtime(now).tm_isdst is not time.localtime(expected).tm_isdst: + dst_diff = time.timezone - time.altzone + expected += dst_diff if time.localtime(now).tm_isdst else -dst_diff + assert_true(expected - parsed < 0.1) parsed = parse_time(input.upper().replace('NOW', 'UtC')) - zone = time.altzone if time.localtime().tm_isdst else time.timezone + zone = time.altzone if time.localtime(now).tm_isdst else time.timezone expected += zone - assert_true(expected <= parsed <= expected + 1) + assert_true(expected - parsed < 0.1) def test_get_time_with_zero(self): assert_equal(get_time('epoch', 0), 0) From a5b698ddb9258cc8ec48a22ea1020ff229604f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 31 Oct 2022 16:23:54 +0200 Subject: [PATCH 0288/1592] Fix version numbers in docs and in a warning. Fixes #4525. --- atest/robot/tags/-tag_syntax.robot | 2 +- .../src/CreatingTestData/CreatingTestCases.rst | 6 +++--- .../src/CreatingTestData/CreatingUserKeywords.rst | 2 +- .../src/ExecutingTestCases/TestExecution.rst | 1 - src/robot/running/arguments/embedded.py | 11 +++++------ src/robot/running/builder/transformers.py | 2 +- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/atest/robot/tags/-tag_syntax.robot b/atest/robot/tags/-tag_syntax.robot index 446960b45f0..ed5685688fc 100644 --- a/atest/robot/tags/-tag_syntax.robot +++ b/atest/robot/tags/-tag_syntax.robot @@ -31,6 +31,6 @@ Check Deprecation Warning [Arguments] ${index} ${source} ${lineno} ${tag} Error in file ${index} ${source} ${lineno} ... Settings tags starting with a hyphen using the '[Tags]' setting is deprecated. - ... In Robot Framework 5.2 this syntax will be used for removing tags. + ... In Robot Framework 6.1 this syntax will be used for removing tags. ... Escape '${tag}' like '\\${tag}' to use the literal value and to avoid this warning. ... level=WARN pattern=False diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index cba83d6f19b..d28d3344fea 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -677,10 +677,10 @@ using two different settings: Both of these settings still work, but they are considered deprecated. A visible deprecation warning will be added in the future, most likely -in Robot Framework 6.0, and eventually these settings will be removed. +in Robot Framework 7.0, and eventually these settings will be removed. Tools like Tidy__ can be used to ease transition. -Robot Framework 5.2 will introduce a new way for tests to indicate they +Robot Framework 6.1 will introduce a new way for tests to indicate they `should not get certain globally specified tags`__. Instead of using a separate setting that tests can override, tests can use syntax `-tag` with their :setting:`[Tags]` setting to tell they should not get a tag named `tag`. @@ -689,7 +689,7 @@ This syntax *does not* yet work in Robot Framework 6.0, but using If such tags are needed, they can be set using :setting:`Test Tags` or escaped__ syntax `\-tag` can be used with :setting:`[Tags]`. -__ https://robotidy.readthedocs.io/ +__ https://robotidy.readthedocs.io __ https://github.com/robotframework/robotframework/issues/4374 __ https://github.com/robotframework/robotframework/issues/4380 __ escaping_ diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 9b9d691614e..a6747c81104 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -195,7 +195,7 @@ activating the special functionality. versions all keyword tags need to be specified using the :setting:`[Tags]` setting. -.. note:: Robot Framework 5.2 will support `removing globally set tags`__ using +.. note:: Robot Framework 6.1 will support `removing globally set tags`__ using the `-tag` syntax with the :setting:`[Tags]` setting. Creating tags with literal value like `-tag` `is deprecated`__ in Robot Framework 6.0 and escaped__ syntax `\-tag` must be used if such tags are actually diff --git a/doc/userguide/src/ExecutingTestCases/TestExecution.rst b/doc/userguide/src/ExecutingTestCases/TestExecution.rst index b6d18e86f04..5137e5e07f1 100644 --- a/doc/userguide/src/ExecutingTestCases/TestExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/TestExecution.rst @@ -621,7 +621,6 @@ using signals `INT` and `TERM`. These signals can be sent from the command line using ``kill`` command, and sending signals can also be easily automated. - Using keywords ~~~~~~~~~~~~~~ diff --git a/src/robot/running/arguments/embedded.py b/src/robot/running/arguments/embedded.py index be56a925cbc..1b501430b49 100644 --- a/src/robot/running/arguments/embedded.py +++ b/src/robot/running/arguments/embedded.py @@ -44,13 +44,12 @@ def map(self, values): def validate(self, values): # Validating that embedded args match custom regexps also if args are # given as variables was initially implemented in RF 6.0. It needed - # to be reverted due to backwards incompatibility reasons: + # to be reverted due to backwards incompatibility reasons but the plan + # is to enable it again in RF 7.0: # https://github.com/robotframework/robotframework/issues/4069 # - # We hopefully can add validation back in RF 5.2 or 6.0. A precondition - # is implementing better approach to handle conflicts with keywords - # using embedded arguments: - # https://github.com/robotframework/robotframework/issues/4454 + # TODO: Emit deprecation warnings if patterns don't match in RF 6.1: + # https://github.com/robotframework/robotframework/issues/4524 # # Because the plan is to add validation back, the code was not removed # but the `ENABLE_STRICT_ARGUMENT_VALIDATION` guard was added instead. @@ -65,7 +64,7 @@ def validate(self, values): for arg, value in zip(self.args, values): if arg in self.custom_patterns and is_string(value): pattern = self.custom_patterns[arg] - if not re.match(pattern + '$', value): + if not re.fullmatch(pattern, value): raise ValueError(f"Embedded argument '{arg}' got value '{value}' " f"that does not match custom pattern '{pattern}'.") diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index bdfc775daec..26bc90d82de 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -566,7 +566,7 @@ def deprecate_tags_starting_with_hyphen(node, source): LOGGER.warn( f"Error in file '{source}' on line {node.lineno}: " f"Settings tags starting with a hyphen using the '[Tags]' setting " - f"is deprecated. In Robot Framework 5.2 this syntax will be used " + f"is deprecated. In Robot Framework 6.1 this syntax will be used " f"for removing tags. Escape '{tag}' like '\\{tag}' to use the " f"literal value and to avoid this warning." ) From 03f0119f30fe8bae5a4a4ec97e97ca523d12f9bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 3 Nov 2022 18:17:44 +0200 Subject: [PATCH 0289/1592] Release notes for 6.0.1 --- doc/releasenotes/rf-6.0.1.rst | 92 +++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 doc/releasenotes/rf-6.0.1.rst diff --git a/doc/releasenotes/rf-6.0.1.rst b/doc/releasenotes/rf-6.0.1.rst new file mode 100644 index 00000000000..7f7e69c369c --- /dev/null +++ b/doc/releasenotes/rf-6.0.1.rst @@ -0,0 +1,92 @@ +===================== +Robot Framework 6.0.1 +===================== + +.. default-role:: code + +`Robot Framework`_ 6.0.1 is the first bug fix release in the `RF 6.0 <rf-6.0.rst>`_ +series. It mainly fixes a bug in using `localized <rf-6.0.rst#localization>`_ +BDD prefixes consisting of more than one word (`#4515`_) as well as a regression +related to the library search order (`#4516`_). + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.0.1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.0.1 was released on Thursday November 3, 2022. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#4515`_ + - bug + - high + - Localized BDD prefixes consisting of more than one word don't work + * - `#4516`_ + - bug + - high + - `Set Library Search Order` doesn't work if there are two matches and one is from standard libraries + * - `#4519`_ + - bug + - medium + - Libdoc's `DocumentationBuilder` doesn't anymore work with resource files with `.robot` extension + * - `#4520`_ + - enhancement + - medium + - Document Libdoc's public API better + * - `#4521`_ + - enhancement + - medium + - Enhance `robot.utils.timestr_to_secs` so that it works with `timedelta` objects + * - `#4523`_ + - bug + - low + - Unit test `test_parse_time_with_now_and_utc` fails around DST change + * - `#4525`_ + - bug + - low + - Wrong version numbers used in the User Guide and in a deprecation warning + +Altogether 7 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0.1>`__. + +.. _#4515: https://github.com/robotframework/robotframework/issues/4515 +.. _#4516: https://github.com/robotframework/robotframework/issues/4516 +.. _#4519: https://github.com/robotframework/robotframework/issues/4519 +.. _#4520: https://github.com/robotframework/robotframework/issues/4520 +.. _#4521: https://github.com/robotframework/robotframework/issues/4521 +.. _#4523: https://github.com/robotframework/robotframework/issues/4523 +.. _#4525: https://github.com/robotframework/robotframework/issues/4525 From 677976a21355cd5b7e57bc02e8b62444eb7ac573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 3 Nov 2022 18:17:56 +0200 Subject: [PATCH 0290/1592] Updated version to 6.0.1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 78e7cdc3560..aee5f357db3 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.1.dev1' +VERSION = '6.0.1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 90d6cfd6d70..31fe86e42f7 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.1.dev1' +VERSION = '6.0.1' def get_version(naked=False): From a05a6a839e2befa7b9426e87e484d6e8f6857dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 3 Nov 2022 18:22:37 +0200 Subject: [PATCH 0291/1592] Mention 6.0.1 in 6.0 release notes --- doc/releasenotes/rf-6.0.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/releasenotes/rf-6.0.rst b/doc/releasenotes/rf-6.0.rst index 5e88c28fde8..c9f33f7ab22 100644 --- a/doc/releasenotes/rf-6.0.rst +++ b/doc/releasenotes/rf-6.0.rst @@ -31,7 +31,8 @@ to install exactly this version. Alternatively, you can download the source distribution from PyPI_ and install it manually. For more details and other installation approaches, see the `installation instructions`_. -Robot Framework 6.0 was released on Wednesday October 19, 2022. +Robot Framework 6.0 was released on Wednesday October 19, 2022. It was +superseded by `Robot Framework 6.0.1 <rf-6.0.1.rst>`_ on Thursday November 3, 2022. .. _Robot Framework: http://robotframework.org .. _Robot Framework Foundation: http://robotframework.org/foundation From bb3c4ae2225af6e94678245f6c07c12ecea9cf78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 3 Nov 2022 18:42:14 +0200 Subject: [PATCH 0292/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index aee5f357db3..1dfaca1a270 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.1' +VERSION = '6.0.2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 31fe86e42f7..d286f75c2b5 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.1' +VERSION = '6.0.2.dev1' def get_version(naked=False): From edad8d5b7b8c595ae5823576100f62474ba829cd Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Thu, 3 Nov 2022 17:49:00 +0100 Subject: [PATCH 0293/1592] Add Polish Booleans (#4526) --- src/robot/conf/languages.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index f444dacba23..095b0c53023 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -737,6 +737,8 @@ class Pl(Language): then_prefixes = ['Wtedy'] and_prefixes = ['Oraz', 'I'] but_prefixes = ['Ale'] + true_strings = ['Prawda', 'Tak', 'Włączone'] + false_strings = ['Fałsz', 'Nie', 'Wyłączone', 'Nic'] class Uk(Language): From 2bf64c34be0d28164b59b31bc125257d58e5f7f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 12 Nov 2022 13:56:52 +0200 Subject: [PATCH 0294/1592] Include IF and WHILE evaluating time to elapsed time. Fixes #4533. --- atest/robot/running/if/if_else.robot | 7 +++++++ atest/robot/running/while/while.robot | 5 +++++ atest/testdata/running/if/if_else.robot | 9 +++++++++ atest/testdata/running/while/while.robot | 5 +++++ src/robot/running/bodyrunner.py | 21 ++++++++++++--------- src/robot/running/statusreporter.py | 3 ++- 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/atest/robot/running/if/if_else.robot b/atest/robot/running/if/if_else.robot index e4b489659bf..216faad9739 100644 --- a/atest/robot/running/if/if_else.robot +++ b/atest/robot/running/if/if_else.robot @@ -38,3 +38,10 @@ If failing in keyword If failing in else keyword Check Test Case ${TESTNAME} + +Expression evaluation time is included in elapsed time + ${tc} = Check Test Case ${TESTNAME} + Should Be True ${tc.body[0].elapsedtime} >= 200 + Should Be True ${tc.body[0].body[0].elapsedtime} >= 100 + Should Be True ${tc.body[0].body[1].elapsedtime} >= 100 + Should Be True ${tc.body[0].body[2].elapsedtime} < 1000 diff --git a/atest/robot/running/while/while.robot b/atest/robot/running/while/while.robot index 2182290f6e0..b7c3bd82747 100644 --- a/atest/robot/running/while/while.robot +++ b/atest/robot/running/while/while.robot @@ -44,3 +44,8 @@ Loop fails in keyword With RETURN Check While Loop PASS 1 path=body[0].body[0] + +Condition evaluation time is included in elapsed time + ${loop} = Check WHILE loop PASS 1 + Should Be True ${loop.elapsedtime} >= 200 + Should Be True ${loop.body[0].elapsedtime} >= 100 diff --git a/atest/testdata/running/if/if_else.robot b/atest/testdata/running/if/if_else.robot index 82f8c2fcb06..6405569b487 100644 --- a/atest/testdata/running/if/if_else.robot +++ b/atest/testdata/running/if/if_else.robot @@ -66,6 +66,15 @@ If failing in else keyword [Documentation] FAIL expected Failing else keyword +Expression evaluation time is included in elapsed time + IF ${{time.sleep(0.1)}} + Fail Not run + ELSE IF ${{time.sleep(0.1)}} is None + Log Run + ELSE IF ${{time.sleep(1)}} is None + Fail Not run + END + *** Keywords *** Passing if keyword IF ${1} diff --git a/atest/testdata/running/while/while.robot b/atest/testdata/running/while/while.robot index cb38b8d1725..55f1be5f3ad 100644 --- a/atest/testdata/running/while/while.robot +++ b/atest/testdata/running/while/while.robot @@ -104,6 +104,11 @@ Loop fails in keyword With RETURN While with RETURN +Condition evaluation time is included in elapsed time + WHILE ${{time.sleep(0.1)}} or ${variable} + ${variable}= Evaluate $variable - 1 + END + *** Keywords *** While keyword WHILE $variable < 4 diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index e6ce91f572a..07c49f0fec8 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -24,9 +24,10 @@ IfBranch as IfBranchResult, Try as TryResult, TryBranch as TryBranchResult) from robot.output import librarylogger as logger -from robot.utils import (cut_assign_value, frange, get_error_message, is_string, - is_list_like, is_number, plural_or_not as s, seq2str, - split_from_equals, type_name, Matcher, timestr_to_secs) +from robot.utils import (cut_assign_value, frange, get_error_message, get_timestamp, + is_string, is_list_like, is_number, plural_or_not as s, + seq2str, split_from_equals, type_name, Matcher, + timestr_to_secs) from robot.variables import is_dict_variable, evaluate_expression from .statusreporter import StatusReporter @@ -340,6 +341,8 @@ def run(self, data): error = None run = False limit = None + loop_result = WhileResult(data.condition, data.limit, starttime=get_timestamp()) + iter_result = loop_result.body.create_iteration(starttime=get_timestamp()) if self._run: if data.error: error = DataError(data.error, syntax=True) @@ -349,10 +352,9 @@ def run(self, data): run = self._should_run(data.condition, ctx.variables) except DataError as err: error = err - result = WhileResult(data.condition, data.limit) - with StatusReporter(data, result, self._context, run): + with StatusReporter(data, loop_result, self._context, run): if ctx.dry_run or not run: - self._run_iteration(data, result, run) + self._run_iteration(data, iter_result, run) if error: raise error return @@ -360,7 +362,7 @@ def run(self, data): while True: try: with limit: - self._run_iteration(data, result) + self._run_iteration(data, iter_result) except (BreakLoop, ContinueLoop) as ctrl: if ctrl.earlier_failures: errors.extend(ctrl.earlier_failures.get_errors()) @@ -373,6 +375,7 @@ def run(self, data): errors.extend(failed.get_errors()) if not failed.can_continue(ctx, self._templated): break + iter_result = loop_result.body.create_iteration(starttime=get_timestamp()) if not self._should_run(data.condition, ctx.variables): break if errors: @@ -380,7 +383,7 @@ def run(self, data): def _run_iteration(self, data, result, run=True): runner = BodyRunner(self._context, run, self._templated) - with StatusReporter(data, result.body.create_iteration(), self._context, run): + with StatusReporter(data, result, self._context, run): runner.run(data.body) def _should_run(self, condition, variables): @@ -432,7 +435,7 @@ def _dry_run_recursion_detection(self, data): def _run_if_branch(self, branch, recursive_dry_run=False, syntax_error=None): context = self._context - result = IfBranchResult(branch.type, branch.condition) + result = IfBranchResult(branch.type, branch.condition, starttime=get_timestamp()) error = None if syntax_error: run_branch = False diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index 5ad7aba1ba8..1e4f573eb8d 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -38,7 +38,8 @@ def __enter__(self): context = self.context result = self.result self.initial_test_status = context.test.status if context.test else None - result.starttime = get_timestamp() + if not result.starttime: + result.starttime = get_timestamp() context.start_keyword(ModelCombiner(self.data, result)) self._warn_if_deprecated(result.doc, result.name) return self From fb54dd66256ed381dfc5d4b4f677ef934c1f40cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 19 Nov 2022 15:34:48 +0200 Subject: [PATCH 0295/1592] f-strings --- src/robot/parsing/lexer/settings.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index b49e1e56eb1..0b691e7b7a8 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -77,11 +77,11 @@ def _validate(self, orig, name, statement): message = self._get_non_existing_setting_message(orig, name) raise ValueError(message) if self.settings[name] is not None and name not in self.multi_use: - raise ValueError("Setting '%s' is allowed only once. " - "Only the first value is used." % orig) + raise ValueError(f"Setting '{orig}' is allowed only once. " + f"Only the first value is used.") if name in self.single_value and len(statement) > 2: - raise ValueError("Setting '%s' accepts only one value, got %s." - % (orig, len(statement) - 1)) + raise ValueError(f"Setting '{orig}' accepts only one value, " + f"got {len(statement)-1}.") def _get_non_existing_setting_message(self, name, normalized): if normalized in (set(TestCaseFileSettings.names) | @@ -93,7 +93,7 @@ def _get_non_existing_setting_message(self, name, normalized): return RecommendationFinder(normalize).find_and_format( name=normalized, candidates=tuple(self.settings) + tuple(self.aliases), - message="Non-existing setting '%s'." % name + message=f"Non-existing setting '{name}'." ) def _lex_error(self, setting, values, error): From bfe4dc0d6f922ccf1b5210f82dc512b5dc70487c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 19 Nov 2022 15:37:52 +0200 Subject: [PATCH 0296/1592] cleanup --- atest/testdata/parsing/test_case_settings.robot | 8 ++++---- atest/testdata/parsing/user_keyword_settings.robot | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/atest/testdata/parsing/test_case_settings.robot b/atest/testdata/parsing/test_case_settings.robot index 27cdfa1afd8..87585b6dcde 100644 --- a/atest/testdata/parsing/test_case_settings.robot +++ b/atest/testdata/parsing/test_case_settings.robot @@ -1,12 +1,12 @@ -*** Setting *** +*** Settings *** Test Setup Log Default setup Test Teardown Log Default teardown INFO Force Tags \ force-1 # Empty tags should be ignored Default Tags @{DEFAULT TAGS} \ default-3 Test Timeout ${TIMEOUT} milliseconds -*** Variable *** -${VARIABLE}  variable +*** Variables *** +${VARIABLE} variable ${DOC VERSION} 1.2 @{DEFAULT TAGS} default-1 default-2 # default-3 added separately ${TAG BASE} test @@ -14,7 +14,7 @@ ${TAG BASE} test ${LOG} Log ${TIMEOUT} 99999 -*** Test Case *** +*** Test Cases *** Normal name No Operation diff --git a/atest/testdata/parsing/user_keyword_settings.robot b/atest/testdata/parsing/user_keyword_settings.robot index 2846404e127..1c2362798fb 100644 --- a/atest/testdata/parsing/user_keyword_settings.robot +++ b/atest/testdata/parsing/user_keyword_settings.robot @@ -1,7 +1,7 @@ -*** Variable *** +*** Variables *** ${VERSION} 1.2 -*** Test Case *** +*** Test Cases *** Normal name Normal name @@ -91,7 +91,7 @@ Invalid setting Small typo should provide recommendation Small typo should provide recommendation -*** Keyword *** +*** Keywords *** Normal name No Operation From af6d4d68a25921aa1de3ed6ad822619437713124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 20 Nov 2022 18:14:59 +0200 Subject: [PATCH 0297/1592] Fix error message if setting used in invalid place. For example, if using `[Metadata]` with tests or `[Template]` with keywords. Fixes #4527. --- atest/robot/parsing/test_case_settings.robot | 10 +++++- .../robot/parsing/user_keyword_settings.robot | 22 ++++++++---- .../testdata/parsing/test_case_settings.robot | 5 +++ .../parsing/user_keyword_settings.robot | 8 +++++ src/robot/parsing/lexer/settings.py | 36 ++++++++++++++----- 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/atest/robot/parsing/test_case_settings.robot b/atest/robot/parsing/test_case_settings.robot index d366d9656bc..5ca3043154e 100644 --- a/atest/robot/parsing/test_case_settings.robot +++ b/atest/robot/parsing/test_case_settings.robot @@ -179,9 +179,17 @@ Invalid setting Error In File 1 parsing/test_case_settings.robot 217 ... Non-existing setting 'Invalid'. +Setting not valid with tests + Check Test Case ${TEST NAME} + Error In File 2 parsing/test_case_settings.robot 221 + ... Setting 'Metadata' is not allowed with tests or tasks. + Check Test Case ${TEST NAME} + Error In File 3 parsing/test_case_settings.robot 222 + ... Setting 'Arguments' is not allowed with tests or tasks. + Small typo should provide recommendation Check Test Doc ${TEST NAME} - Error In File 2 parsing/test_case_settings.robot 221 + Error In File 4 parsing/test_case_settings.robot 226 ... SEPARATOR=\n ... Non-existing setting 'Doc U ment a tion'. Did you mean: ... ${SPACE*4}Documentation diff --git a/atest/robot/parsing/user_keyword_settings.robot b/atest/robot/parsing/user_keyword_settings.robot index bad894c487d..efec9ad6584 100644 --- a/atest/robot/parsing/user_keyword_settings.robot +++ b/atest/robot/parsing/user_keyword_settings.robot @@ -94,22 +94,30 @@ Multiple settings Invalid setting Check Test Case ${TEST NAME} - Error In File 0 parsing/user_keyword_settings.robot 195 + Error In File 0 parsing/user_keyword_settings.robot 198 ... Non-existing setting 'Invalid Setting'. - Error In File 1 parsing/user_keyword_settings.robot 199 + Error In File 1 parsing/user_keyword_settings.robot 202 ... Non-existing setting 'invalid'. +Setting not valid with user keywords + Check Test Case ${TEST NAME} + Error In File 2 parsing/user_keyword_settings.robot 206 + ... Setting 'Metadata' is not allowed with user keywords. + Check Test Case ${TEST NAME} + Error In File 3 parsing/user_keyword_settings.robot 207 + ... Setting 'Template' is not allowed with user keywords. + Small typo should provide recommendation Check Test Case ${TEST NAME} - Error In File 2 parsing/user_keyword_settings.robot 203 + Error In File 4 parsing/user_keyword_settings.robot 211 ... SEPARATOR=\n ... Non-existing setting 'Doc Umentation'. Did you mean: ... ${SPACE*4}Documentation -Invalid empty line continuation in arguments should throw an error - Error in File 3 parsing/user_keyword_settings.robot 206 - ... Creating keyword 'Invalid empty line continuation in arguments should throw an error' failed: Invalid argument specification: Invalid argument syntax ''. - +Invalid empty line continuation in arguments should throw an error + Error in File 5 parsing/user_keyword_settings.robot 214 + ... Creating keyword 'Invalid empty line continuation in arguments should throw an error' failed: + ... Invalid argument specification: Invalid argument syntax ''. *** Keywords *** Verify Documentation diff --git a/atest/testdata/parsing/test_case_settings.robot b/atest/testdata/parsing/test_case_settings.robot index 87585b6dcde..930ffb1d62e 100644 --- a/atest/testdata/parsing/test_case_settings.robot +++ b/atest/testdata/parsing/test_case_settings.robot @@ -217,6 +217,11 @@ Invalid setting [Invalid] This is invalid No Operation +Setting not valid with tests + [Metadata] Not valid. + [Arguments] Not valid. + No Operation + Small typo should provide recommendation [Doc U ment a tion] This actually worked before RF 3.2. No Operation diff --git a/atest/testdata/parsing/user_keyword_settings.robot b/atest/testdata/parsing/user_keyword_settings.robot index 1c2362798fb..3afb6129f37 100644 --- a/atest/testdata/parsing/user_keyword_settings.robot +++ b/atest/testdata/parsing/user_keyword_settings.robot @@ -88,6 +88,9 @@ Invalid setting Invalid passing Invalid failing +Setting not valid with user keywords + Setting not valid with user keywords + Small typo should provide recommendation Small typo should provide recommendation @@ -199,6 +202,11 @@ Invalid failing [invalid] Yes, this is also invalid Fail Keywords are executed regardless invalid settings +Setting not valid with user keywords + [Metadata] Not valid. + [Template] Not valid. + No Operation + Small typo should provide recommendation [Doc Umentation] No Operation diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 0b691e7b7a8..18c84f68b19 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -84,18 +84,23 @@ def _validate(self, orig, name, statement): f"got {len(statement)-1}.") def _get_non_existing_setting_message(self, name, normalized): - if normalized in (set(TestCaseFileSettings.names) | - set(TestCaseFileSettings.aliases)): - is_resource = isinstance(self, ResourceFileSettings) - return "Setting '%s' is not allowed in %s file." % ( - name, 'resource' if is_resource else 'suite initialization' - ) + if self._is_valid_somewhere(normalized): + return self._not_valid_here(name) return RecommendationFinder(normalize).find_and_format( name=normalized, candidates=tuple(self.settings) + tuple(self.aliases), message=f"Non-existing setting '{name}'." ) + def _is_valid_somewhere(self, normalized): + for cls in Settings.__subclasses__(): + if normalized in cls.names or normalized in cls.aliases: + return True + return False + + def _not_valid_here(self, name): + raise NotImplementedError + def _lex_error(self, setting, values, error): setting.set_error(error) for token in values: @@ -155,6 +160,9 @@ class TestCaseFileSettings(Settings): 'Task Timeout': 'Test Timeout', } + def _not_valid_here(self, name): + return f"Setting '{name}' is not allowed in suite file." + class InitFileSettings(Settings): names = ( @@ -179,6 +187,9 @@ class InitFileSettings(Settings): 'Task Timeout': 'Test Timeout', } + def _not_valid_here(self, name): + return f"Setting '{name}' is not allowed in suite initialization file." + class ResourceFileSettings(Settings): names = ( @@ -189,6 +200,9 @@ class ResourceFileSettings(Settings): 'Variables' ) + def _not_valid_here(self, name): + return f"Setting '{name}' is not allowed in resource file." + class TestCaseSettings(Settings): names = ( @@ -200,8 +214,8 @@ class TestCaseSettings(Settings): 'Timeout' ) - def __init__(self, parent, markers): - super().__init__(markers) + def __init__(self, parent, languages): + super().__init__(languages) self.parent = parent def _format_name(self, name): @@ -223,6 +237,9 @@ def _has_disabling_value(self, setting): def _has_value(self, setting): return setting and setting[0].value + def _not_valid_here(self, name): + return f"Setting '{name}' is not allowed with tests or tasks." + class KeywordSettings(Settings): names = ( @@ -236,3 +253,6 @@ class KeywordSettings(Settings): def _format_name(self, name): return name[1:-1].strip() + + def _not_valid_here(self, name): + return f"Setting '{name}' is not allowed with user keywords." From 16ec174d980a161e20621b1546f558ce98322be1 Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sun, 11 Dec 2022 10:39:07 +0100 Subject: [PATCH 0298/1592] [limit_exceed_message] Adding unit tests --- utest/parsing/test_model.py | 31 +++++++++++++++++++++++++++++-- utest/parsing/test_statements.py | 19 +++++++++++++++++++ utest/result/test_resultmodel.py | 4 ++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 19e8ee03e88..527c9b070ba 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -373,11 +373,37 @@ def test_limit(self): ) get_and_assert_model(data, expected) + def test_limit_exceed_message(self): + data = ''' +*** Test Cases *** +Example + WHILE True limit=10s limit_exceed_message=Error message + Log ${x} + END +''' + expected = While( + header=WhileHeader([ + Token(Token.WHILE, 'WHILE', 3, 4), + Token(Token.ARGUMENT, 'True', 3, 13), + Token(Token.OPTION, 'limit=10s', 3, 21), + Token(Token.OPTION, 'limit_exceed_message=Error message', + 3, 34) + ]), + body=[ + KeywordCall([Token(Token.KEYWORD, 'Log', 4, 8), + Token(Token.ARGUMENT, '${x}', 4, 15)]) + ], + end=End([ + Token(Token.END, 'END', 5, 4) + ]) + ) + get_and_assert_model(data, expected) + def test_invalid(self): data = ''' *** Test Cases *** Example - WHILE too many values + WHILE too many values ! # Empty body END ''' @@ -386,7 +412,8 @@ def test_invalid(self): tokens=[Token(Token.WHILE, 'WHILE', 3, 4), Token(Token.ARGUMENT, 'too', 3, 13), Token(Token.ARGUMENT, 'many', 3, 20), - Token(Token.ARGUMENT, 'values', 3, 28)], + Token(Token.ARGUMENT, 'values', 3, 28), + Token(Token.ARGUMENT, '!', 3, 38)], errors=('WHILE cannot have more than one condition.',) ), end=End([ diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 8e327c2d1b7..b7de22245df 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -901,6 +901,25 @@ def test_WhileHeader(self): condition='$cond', limit='100s' ) + # WHILE $cond limit=10 limit_exceed_message=Error message + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.WHILE), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, '$cond'), + Token(Token.SEPARATOR, ' '), + Token(Token.OPTION, 'limit=10'), + Token(Token.SEPARATOR, ' '), + Token(Token.OPTION, 'limit_exceed_message=Error message'), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + WhileHeader, + condition='$cond', + limit='10', + limit_exceed_message='Error message' + ) def test_End(self): tokens = [ diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index a6c53317c97..dcf8af7b7cc 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -171,6 +171,10 @@ def test_while_name(self): assert_equal(While('$x > 0').name, '$x > 0') assert_equal(While('True', '1 minute').name, 'True | limit=1 minute') assert_equal(While(limit='1 minute').name, 'limit=1 minute') + assert_equal(While('True', '1 s', 'Error message').name, + 'True | limit=1 s | limit_exceed_message=Error message') + assert_equal(While(limit_exceed_message='Error message').name, + 'limit_exceed_message=Error message') def test_break_continue_return(self): for cls in Break, Continue, Return: From 46893584a2c2fd265c214230a996ade9aa15966e Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sun, 11 Dec 2022 10:40:36 +0100 Subject: [PATCH 0299/1592] [limit_exceed_message] Adding acceptance tests --- atest/robot/running/while/invalid_while.robot | 2 +- .../while/while_limit_exceed_message.robot | 25 ++++++++++ .../running/while/invalid_while.robot | 2 +- .../while/while_limit_exceed_message.robot | 50 +++++++++++++++++++ .../listeners/VerifyAttributes.py | 3 +- 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 atest/robot/running/while/while_limit_exceed_message.robot create mode 100644 atest/testdata/running/while/while_limit_exceed_message.robot diff --git a/atest/robot/running/while/invalid_while.robot b/atest/robot/running/while/invalid_while.robot index 9a5d49f1d2c..507a922fffd 100644 --- a/atest/robot/running/while/invalid_while.robot +++ b/atest/robot/running/while/invalid_while.robot @@ -9,7 +9,7 @@ No condition Multiple conditions ${tc} = Check Invalid WHILE Test Case - Should Be Equal ${tc.body[0].condition} Too, many, ! + Should Be Equal ${tc.body[0].condition} Too, many, conditions, ! Invalid condition Check Invalid WHILE Test Case diff --git a/atest/robot/running/while/while_limit_exceed_message.robot b/atest/robot/running/while/while_limit_exceed_message.robot new file mode 100644 index 00000000000..15e789e22b9 --- /dev/null +++ b/atest/robot/running/while/while_limit_exceed_message.robot @@ -0,0 +1,25 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/while/while_limit_exceed_message.robot +Resource while.resource + +*** Test Cases *** +limit_exceed_message without limit + Check Test Case ${TESTNAME} + +Testing error message + Check Test Case ${TESTNAME} + +Wrong third argument + Check Test Case ${TESTNAME} + +Limit exceed message from variable + Check Test Case ${TESTNAME} + +Part of limit exceed message from variable + Check Test Case ${TESTNAME} + +No error message + Check Test Case ${TESTNAME} + +Nested while error message + Check Test Case ${TESTNAME} \ No newline at end of file diff --git a/atest/testdata/running/while/invalid_while.robot b/atest/testdata/running/while/invalid_while.robot index 42f9a164c19..5275905bedc 100644 --- a/atest/testdata/running/while/invalid_while.robot +++ b/atest/testdata/running/while/invalid_while.robot @@ -7,7 +7,7 @@ No condition Multiple conditions [Documentation] FAIL WHILE cannot have more than one condition. - WHILE Too many ! + WHILE Too many conditions ! Fail Not executed! END diff --git a/atest/testdata/running/while/while_limit_exceed_message.robot b/atest/testdata/running/while/while_limit_exceed_message.robot new file mode 100644 index 00000000000..066cf32d9df --- /dev/null +++ b/atest/testdata/running/while/while_limit_exceed_message.robot @@ -0,0 +1,50 @@ +*** Variables *** +${variable} ${1} +${limit} 11 +${number} ${0.2} +${errorMsg} Error Message + +*** Test Cases *** +limit_exceed_message without limit + [Documentation] FAIL Second WHILE loop argument must be 'limit', got 'limit_exceed_message=Error'. + WHILE $variable < 2 limit_exceed_message=Error + Log ${variable} + END + +Testing error message + [Documentation] FAIL Custom error message + WHILE $variable < 2 limit=5 limit_exceed_message=Custom error message + Log ${variable} + END + +Wrong third argument + [Documentation] FAIL Third WHILE loop argument must be 'limit_exceed_message', got 'limit_exceed_messag=Custom error message'. + WHILE $variable < 2 limit=5 limit_exceed_messag=Custom error message + Log ${variable} + END + +Limit exceed message from variable + [Documentation] FAIL ${errorMsg} + WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} + Log ${variable} + END + +Part of limit exceed message from variable + [Documentation] FAIL ${errorMsg} 2 + WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 2 + Log ${variable} + END + +No error message + WHILE $variable < 3 limit=10 limit_exceed_message=${errorMsg} 2 + Log ${variable} + ${variable}= Evaluate $variable + 1 + END + +Nested while error message + [Documentation] FAIL ${errorMsg} 2 + WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 1 + WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 2 + Log ${variable} + END + END \ No newline at end of file diff --git a/atest/testresources/listeners/VerifyAttributes.py b/atest/testresources/listeners/VerifyAttributes.py index bc26632761f..4b29c963b86 100644 --- a/atest/testresources/listeners/VerifyAttributes.py +++ b/atest/testresources/listeners/VerifyAttributes.py @@ -7,7 +7,7 @@ TEST = 'id longname tags template originalname source lineno ' KW = 'kwname libname args assign tags type lineno source status ' KW_TYPES = {'FOR': 'variables flavor values', - 'WHILE': 'condition limit', + 'WHILE': 'condition limit limit_exceed_message', 'IF': 'condition', 'ELSE IF': 'condition', 'EXCEPT': 'patterns pattern_type variable', @@ -27,6 +27,7 @@ 'values': (list, dict), 'condition': str, 'limit': (str, type(None)), + 'limit_exceed_message': (str, type(None)), 'patterns': (str, list), 'pattern_type': (str, type(None)), 'variable': (str, type(None))} From 548dd618c72f8705ac435c411343f8ccf1272041 Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sun, 11 Dec 2022 10:41:57 +0100 Subject: [PATCH 0300/1592] [limit_exceed_message] Adding the 'limit_exceed_message' enhancement --- src/robot/model/control.py | 10 +++-- src/robot/output/listenerarguments.py | 2 +- src/robot/output/xmllogger.py | 3 +- src/robot/parsing/lexer/statementlexers.py | 5 +++ src/robot/parsing/model/blocks.py | 4 ++ src/robot/parsing/model/statements.py | 35 ++++++++++++++-- src/robot/result/model.py | 9 +++-- src/robot/result/xmlelementhandlers.py | 3 +- src/robot/running/bodyrunner.py | 46 +++++++++++++++------- src/robot/running/builder/transformers.py | 3 +- src/robot/running/model.py | 5 ++- 11 files changed, 94 insertions(+), 31 deletions(-) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index ce20b859ca9..926ff9a18c2 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -59,12 +59,14 @@ def __str__(self): class While(BodyItem): type = BodyItem.WHILE body_class = Body - repr_args = ('condition', 'limit') - __slots__ = ['condition', 'limit'] + repr_args = ('condition', 'limit', 'limit_exceed_message') + __slots__ = ['condition', 'limit', 'limit_exceed_message'] - def __init__(self, condition=None, limit=None, parent=None): + def __init__(self, condition=None, limit=None, + limit_exceed_message=None, parent=None): self.condition = condition self.limit = limit + self.limit_exceed_message = limit_exceed_message self.parent = parent self.body = None @@ -76,7 +78,7 @@ def visit(self, visitor): visitor.visit_while(self) def __str__(self): - return f'WHILE {self.condition}' + (f' {self.limit}' if self.limit else '') + return f'WHILE {self.condition}' + (f' {self.limit}' if self.limit else '') + (f' {self.limit_exceed_message}' if self.limit_exceed_message else '') class IfBranch(BodyItem): diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index f2ee7402c0d..47c44750d83 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -134,7 +134,7 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): BodyItem.IF: ('condition',), BodyItem.ELSE_IF: ('condition'), BodyItem.EXCEPT: ('patterns', 'pattern_type', 'variable'), - BodyItem.WHILE: ('condition', 'limit'), + BodyItem.WHILE: ('condition', 'limit', 'limit_exceed_message'), BodyItem.RETURN: ('values',), BodyItem.ITERATION: ('variables',)} diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index d88fbb2f924..f6f20cec5e3 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -145,7 +145,8 @@ def end_try_branch(self, branch): def start_while(self, while_): self._writer.start('while', attrs={ 'condition': while_.condition, - 'limit': while_.limit + 'limit': while_.limit, + 'limit_exceed_message': while_.limit_exceed_message }) self._writer.element('doc', while_.doc) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 7f5bcdce00c..c5d834fa8d4 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -298,6 +298,11 @@ def lex(self): token.type = Token.ARGUMENT if self.statement[-1].value.startswith('limit='): self.statement[-1].type = Token.OPTION + if len(self.statement) > 3: + if self.statement[-2].value.startswith('limit='): + self.statement[-2].type = Token.OPTION + if self.statement[-1].value.startswith('limit_exceed_message='): + self.statement[-1].type = Token.OPTION class EndLexer(TypeAndArguments): diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 47f9a02a6ed..81800ba87fd 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -342,6 +342,10 @@ def condition(self): def limit(self): return self.header.limit + @property + def limit_exceed_message(self): + return self.header.limit_exceed_message + def validate(self, context): if self._body_is_empty(): self.errors += ('WHILE loop cannot be empty.',) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 5af603a8834..2095f480824 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -968,8 +968,8 @@ class WhileHeader(Statement): type = Token.WHILE @classmethod - def from_params(cls, condition, limit=None, indent=FOUR_SPACES, - separator=FOUR_SPACES, eol=EOL): + def from_params(cls, condition, limit=None, limit_exceed_message=None, + indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): tokens = [Token(Token.SEPARATOR, indent), Token(cls.type), Token(Token.SEPARATOR, separator), @@ -977,6 +977,11 @@ def from_params(cls, condition, limit=None, indent=FOUR_SPACES, if limit: tokens.extend([Token(Token.SEPARATOR, indent), Token(Token.OPTION, f'limit={limit}')]) + if limit_exceed_message: + tokens.extend([Token(Token.SEPARATOR, indent), + Token(Token.OPTION, + f'limit_exceed_message={limit_exceed_message}' + )]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -989,14 +994,36 @@ def limit(self): value = self.get_value(Token.OPTION) return value[len('limit='):] if value else None + @property + def limit_exceed_message(self): + values = self.get_values(Token.OPTION) + if(len(values) > 1): + value = values[1] + else: + value = None + return value[len('limit_exceed_message='):] if value else None + def validate(self, context): values = self.get_values(Token.ARGUMENT) + options = self.get_values(Token.OPTION) if len(values) == 0: self.errors += ('WHILE must have a condition.',) if len(values) == 2: - self.errors += (f"Second WHILE loop argument must be 'limit', " + if(len(options) > 0): + if("limit=" not in options[0]): + self.errors += ( + f"Second WHILE loop argument must be 'limit', " f"got '{values[1]}'.",) - if len(values) > 2: + elif("limit_exceed_message=" not in options[0]): + self.errors += ( + f"Third WHILE loop argument must be " + f"'limit_exceed_message', " + f"got '{values[1]}'.",) + else: + self.errors += ( + f"Second WHILE loop argument must be 'limit', " + f"got '{values[1]}'.",) + if len(values) > 3: self.errors += ('WHILE cannot have more than one condition.',) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 8e1ff902b11..e5f7092b44a 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -222,9 +222,10 @@ class While(model.While, StatusMixin, DeprecatedAttributesMixin): iteration_class = WhileIteration __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, condition=None, limit=None, parent=None, status='FAIL', - starttime=None, endtime=None, doc=''): - super().__init__(condition, limit, parent) + def __init__(self, condition=None, limit=None, limit_exceed_message=None, + parent=None, status='FAIL', starttime=None, + endtime=None, doc=''): + super().__init__(condition, limit, limit_exceed_message, parent) self.status = status self.starttime = starttime self.endtime = endtime @@ -242,6 +243,8 @@ def name(self): parts.append(self.condition) if self.limit: parts.append(f'limit={self.limit}') + if self.limit_exceed_message: + parts.append(f'limit_exceed_message={self.limit_exceed_message}') return ' | '.join(parts) diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index b0e5514164b..0e150e9100e 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -189,7 +189,8 @@ class WhileHandler(ElementHandler): def start(self, elem, result): return result.body.create_while( condition=elem.get('condition'), - limit=elem.get('limit') + limit=elem.get('limit'), + limit_exceed_message=elem.get('limit_exceed_message') ) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 07c49f0fec8..0f7874a2a0a 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -341,14 +341,20 @@ def run(self, data): error = None run = False limit = None - loop_result = WhileResult(data.condition, data.limit, starttime=get_timestamp()) + loop_result = WhileResult(data.condition, data.limit, + data.limit_exceed_message, + starttime=get_timestamp() + ) iter_result = loop_result.body.create_iteration(starttime=get_timestamp()) if self._run: if data.error: error = DataError(data.error, syntax=True) elif not ctx.dry_run: try: - limit = WhileLimit.create(data.limit, ctx.variables) + limit = WhileLimit.create(data.limit, + data.limit_exceed_message, + ctx.variables + ) run = self._should_run(data.condition, ctx.variables) except DataError as err: error = err @@ -596,9 +602,14 @@ def _run_finally(self, data, run): class WhileLimit: @classmethod - def create(cls, limit, variables): + def create(cls, limit, limit_exceed_message, variables): if not limit: - return IterationCountLimit(DEFAULT_WHILE_LIMIT) + return IterationCountLimit(DEFAULT_WHILE_LIMIT, + limit_exceed_message + ) + if limit_exceed_message: + limit_exceed_message = variables.replace_string( + limit_exceed_message) value = variables.replace_string(limit) if value.upper() == 'NONE': return NoLimit() @@ -610,18 +621,23 @@ def create(cls, limit, variables): if count <= 0: raise DataError(f"Invalid WHILE loop limit: Iteration count must be " f"a positive integer, got '{count}'.") - return IterationCountLimit(count) + return IterationCountLimit(count, limit_exceed_message) try: secs = timestr_to_secs(value) except ValueError as err: raise DataError(f'Invalid WHILE loop limit: {err.args[0]}') else: - return DurationLimit(secs) + return DurationLimit(secs, limit_exceed_message) - def limit_exceeded(self): - raise ExecutionFailed(f"WHILE loop was aborted because it did not finish " - f"within the limit of {self}. Use the 'limit' argument " - f"to increase or remove the limit if needed.") + def limit_exceeded(self, limit_exceed_message): + if limit_exceed_message: + raise ExecutionFailed(limit_exceed_message) + else: + raise ExecutionFailed(f"WHILE loop was aborted because " + f"it did not finish " + f"within the limit of {self}. " + f"Use the 'limit' argument to " + f"increase or remove the limit if needed.") def __enter__(self): raise NotImplementedError @@ -632,7 +648,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): class DurationLimit(WhileLimit): - def __init__(self, max_time): + def __init__(self, max_time, limit_exceed_message): + self.limit_exceed_message = limit_exceed_message self.max_time = max_time self.start_time = None @@ -640,7 +657,7 @@ def __enter__(self): if not self.start_time: self.start_time = time.time() if time.time() - self.start_time > self.max_time: - self.limit_exceeded() + self.limit_exceeded(self.limit_exceed_message) def __str__(self): return f'{self.max_time} seconds' @@ -648,13 +665,14 @@ def __str__(self): class IterationCountLimit(WhileLimit): - def __init__(self, max_iterations): + def __init__(self, max_iterations, limit_exceed_message): + self.limit_exceed_message = limit_exceed_message self.max_iterations = max_iterations self.current_iterations = 0 def __enter__(self): if self.current_iterations >= self.max_iterations: - self.limit_exceeded() + self.limit_exceeded(self.limit_exceed_message) self.current_iterations += 1 def __str__(self): diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 26bc90d82de..deeac9c1589 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -510,7 +510,8 @@ def __init__(self, parent): def build(self, node): error = format_error(self._get_errors(node)) self.model = self.parent.body.create_while( - node.condition, node.limit, lineno=node.lineno, error=error + node.condition, node.limit, node.limit_exceed_message, + lineno=node.lineno, error=error ) for step in node.body: self.visit(step) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 83a409d66ba..8f7cf88c436 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -100,8 +100,9 @@ class While(model.While): __slots__ = ['lineno', 'error'] body_class = Body - def __init__(self, condition=None, limit=None, parent=None, lineno=None, error=None): - super().__init__(condition, limit, parent) + def __init__(self, condition=None, limit=None, limit_exceed_message=None, + parent=None, lineno=None, error=None): + super().__init__(condition, limit, limit_exceed_message, parent) self.lineno = lineno self.error = error From 8ee4ddfcf5c2ebce7d77f0e2a17296f31ee1c8d1 Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sun, 11 Dec 2022 10:54:12 +0100 Subject: [PATCH 0301/1592] [limit_exceed_message] atest : Adding newline at end of line --- atest/robot/running/while/while_limit_exceed_message.robot | 2 +- atest/testdata/running/while/while_limit_exceed_message.robot | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/atest/robot/running/while/while_limit_exceed_message.robot b/atest/robot/running/while/while_limit_exceed_message.robot index 15e789e22b9..1425ddf9087 100644 --- a/atest/robot/running/while/while_limit_exceed_message.robot +++ b/atest/robot/running/while/while_limit_exceed_message.robot @@ -22,4 +22,4 @@ No error message Check Test Case ${TESTNAME} Nested while error message - Check Test Case ${TESTNAME} \ No newline at end of file + Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/while/while_limit_exceed_message.robot b/atest/testdata/running/while/while_limit_exceed_message.robot index 066cf32d9df..17efd29e1b1 100644 --- a/atest/testdata/running/while/while_limit_exceed_message.robot +++ b/atest/testdata/running/while/while_limit_exceed_message.robot @@ -47,4 +47,4 @@ Nested while error message WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 2 Log ${variable} END - END \ No newline at end of file + END From 4b2ace53cd1d866455b73db0ea6cd799ca2d8334 Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sun, 11 Dec 2022 12:34:08 +0100 Subject: [PATCH 0302/1592] [limit_exceed_message] Updating user guide --- .../src/CreatingTestData/ControlStructures.rst | 15 +++++++++++++++ .../ExtendingRobotFramework/ListenerInterface.rst | 2 ++ 2 files changed, 17 insertions(+) diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index b78cc6a1738..a783573565f 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -588,6 +588,21 @@ Keywords in a loop are not forcefully stopped if the limit is exceeded. Instead the loop is exited similarly as if the loop condition would have become false. A major difference is that the loop status will be `FAIL` in this case. +By default, the error message raised when the limit is reached is +`WHILE loop was aborted because it did not finish within the limit of 0.5 +seconds. Use the 'limit' argument to increase or remove the limit if +needed.`. The error message can be changed with the `limit_exceed_message` +configuration parameter. This parameter must be placed after the `limit` +configuration parameter. + +.. sourcecode:: robotframework + + *** Test Cases *** + Limit as iteration count + WHILE True limit=0.5s limit_exceed_message=Custom While loop error message + Log This is run 0.5 seconds. + END + __ `Time format`_ Nesting `WHILE` loops diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 6abba68fe0b..6853048e5ec 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -263,6 +263,8 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | | | | | * `condition`: The looping condition. | | | | * `limit`: The maximum iteration limit. | + | | | * `limit_exceed_message`: The custom error message if the | + | | | limit is reached. | | | | | | | | Additional attributes for `IF` and `ELSE_IF` types: | | | | | From 403aa7b4cfa3e8975a66ad9de0f5ce0e66f7d486 Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sun, 11 Dec 2022 14:40:16 +0100 Subject: [PATCH 0303/1592] [limit_exceed_message] Updating atest --- .../while/while_limit_exceed_message.robot | 10 ++++----- .../while/while_limit_exceed_message.robot | 22 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/atest/robot/running/while/while_limit_exceed_message.robot b/atest/robot/running/while/while_limit_exceed_message.robot index 1425ddf9087..9a77606dd9e 100644 --- a/atest/robot/running/while/while_limit_exceed_message.robot +++ b/atest/robot/running/while/while_limit_exceed_message.robot @@ -3,13 +3,13 @@ Suite Setup Run Tests ${EMPTY} running/while/while_limit_exceed_mess Resource while.resource *** Test Cases *** -limit_exceed_message without limit +Limit exceed message without limit Check Test Case ${TESTNAME} -Testing error message +Wrong third argument Check Test Case ${TESTNAME} -Wrong third argument +Limit exceed message Check Test Case ${TESTNAME} Limit exceed message from variable @@ -18,8 +18,8 @@ Limit exceed message from variable Part of limit exceed message from variable Check Test Case ${TESTNAME} -No error message +No limit exceed message Check Test Case ${TESTNAME} -Nested while error message +Nested while limit exceed message Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/while/while_limit_exceed_message.robot b/atest/testdata/running/while/while_limit_exceed_message.robot index 17efd29e1b1..d72cfb78e92 100644 --- a/atest/testdata/running/while/while_limit_exceed_message.robot +++ b/atest/testdata/running/while/while_limit_exceed_message.robot @@ -5,24 +5,24 @@ ${number} ${0.2} ${errorMsg} Error Message *** Test Cases *** -limit_exceed_message without limit +Limit exceed message without limit [Documentation] FAIL Second WHILE loop argument must be 'limit', got 'limit_exceed_message=Error'. WHILE $variable < 2 limit_exceed_message=Error Log ${variable} END -Testing error message - [Documentation] FAIL Custom error message - WHILE $variable < 2 limit=5 limit_exceed_message=Custom error message - Log ${variable} - END - Wrong third argument [Documentation] FAIL Third WHILE loop argument must be 'limit_exceed_message', got 'limit_exceed_messag=Custom error message'. WHILE $variable < 2 limit=5 limit_exceed_messag=Custom error message Log ${variable} END +Limit exceed message + [Documentation] FAIL Custom error message + WHILE $variable < 2 limit=${limit} limit_exceed_message=Custom error message + Log ${variable} + END + Limit exceed message from variable [Documentation] FAIL ${errorMsg} WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} @@ -30,18 +30,18 @@ Limit exceed message from variable END Part of limit exceed message from variable - [Documentation] FAIL ${errorMsg} 2 - WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 2 + [Documentation] FAIL While ${errorMsg} 2 ${number} + WHILE $variable < 2 limit=5 limit_exceed_message=While ${errorMsg} 2 ${number} Log ${variable} END -No error message +No limit exceed message WHILE $variable < 3 limit=10 limit_exceed_message=${errorMsg} 2 Log ${variable} ${variable}= Evaluate $variable + 1 END -Nested while error message +Nested while limit exceed message [Documentation] FAIL ${errorMsg} 2 WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 1 WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 2 From 894b1d83327b0943da7be1768ef69d800ac4368b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 11 Dec 2022 20:42:51 +0200 Subject: [PATCH 0304/1592] Bump actions/setup-python from 4.3.0 to 4.3.1 (#4559) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.3.0 to 4.3.1. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.3.0...v4.3.1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 2734cea3bcd..b089c775da8 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v4.3.1 with: python-version: '3.10' architecture: 'x64' @@ -51,7 +51,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v4.3.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index b63ca41d25c..b76ab020b7b 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v4.3.1 with: python-version: '3.10' 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@v4.3.0 + uses: actions/setup-python@v4.3.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 9e644c43f4d..b50966368ac 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v4.3.1 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 5e3383716e2..00ec83d674b 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v4.3.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From 5cc0ec7ae436a5a18da165effbb6a5c6ca502962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 20 Dec 2022 23:37:15 +0200 Subject: [PATCH 0305/1592] Fix --reportbackground documentation. The correct order is pass:fail:skip. Also enhanced these docs a bit in general. Fixes #4557. --- .../src/ExecutingTestCases/BasicUsage.rst | 2 +- .../src/ExecutingTestCases/OutputFiles.rst | 27 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst index c8b7e0dba7d..b7c7dd7e282 100644 --- a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst +++ b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst @@ -250,7 +250,7 @@ avoid the need to repeat them every time tests are run or Rebot used. export ROBOT_OPTIONS="--outputdir results --tagdoc 'mytag:Example doc with spaces'" robot tests.robot - export REBOT_OPTIONS="--reportbackground green:yellow:red" + export REBOT_OPTIONS="--reportbackground blue:red:yellow" rebot --name example output.xml __ `Post-processing outputs`_ diff --git a/doc/userguide/src/ExecutingTestCases/OutputFiles.rst b/doc/userguide/src/ExecutingTestCases/OutputFiles.rst index d0947fd3333..e29b1aa0194 100644 --- a/doc/userguide/src/ExecutingTestCases/OutputFiles.rst +++ b/doc/userguide/src/ExecutingTestCases/OutputFiles.rst @@ -194,32 +194,31 @@ Example:: Setting background colors ~~~~~~~~~~~~~~~~~~~~~~~~~ -By default the `report file`_ has a green background when all the -tests pass, yellow background when all the test have been skipped and -a red background if there are any test failrues. These colors -can be customized by using the :option:`--reportbackground` command line -option, which takes two or three colors separated with a colon as an -argument:: +By default the `report file`_ has red background if there are failures, +green background if there are passed tests and possibly some skipped ones, +and a yellow background if all tests are skipped or no tests have been run. +These colors can be customized by using the :option:`--reportbackground` +command line option, which takes two or three colors separated with a colon +as an argument:: --reportbackground blue:red - --reportbackground green:yellow:red + --reportbackground blue:red:orange --reportbackground #00E:#E00 If you specify two colors, the first one will be used instead of the -default green color and the second instead of the default red. This -allows, for example, using blue instead of green to make backgrounds +default green (pass) color and the second instead of the default red (fail). +This allows, for example, using blue instead of green to make backgrounds easier to separate for color blind people. -If you specify three colors, the first one will be used when all the -tests pass, the second when all tests have been skipped, and -the last when there are any failures. +If you specify three colors, the first two have same semantics as earlier +and the last one replaces the default yellow (skip) color. The specified colors are used as a value for the `body` element's `background` CSS property. The value is used as-is and can be a HTML color name (e.g. `red`), a hexadecimal value (e.g. `#f00` or `#ff0000`), or an RGB value -(e.g. `rgb(255,0,0)`). The default green and red colors are -specified using hexadecimal values `#9e9` and `#f66`, +(e.g. `rgb(255,0,0)`). The default green, red and yellow colors are +specified using hexadecimal values `#9e9`, `#f66` and `#fed84f`, respectively. Log levels From 2ddbe8d6aee6c2f3e347d2a93221771e1bc71505 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Dec 2022 13:51:00 +0200 Subject: [PATCH 0306/1592] Bump actions/setup-python from 4.3.1 to 4.4.0 (#4573) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.3.1 to 4.4.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.3.1...v4.4.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index b089c775da8..676344fa113 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.3.1 + uses: actions/setup-python@v4.4.0 with: python-version: '3.10' architecture: 'x64' @@ -51,7 +51,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.3.1 + uses: actions/setup-python@v4.4.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index b76ab020b7b..ee5b2696e35 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.3.1 + uses: actions/setup-python@v4.4.0 with: python-version: '3.10' 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@v4.3.1 + uses: actions/setup-python@v4.4.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index b50966368ac..7dcba915a49 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.3.1 + uses: actions/setup-python@v4.4.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 00ec83d674b..5d0d079c6b4 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.3.1 + uses: actions/setup-python@v4.4.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From bdd2b567501cc9bb79f5fafdf5ab65cc34b2bc6b Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sat, 31 Dec 2022 16:37:49 +0100 Subject: [PATCH 0307/1592] limit_exceed_message -> on_limit_message --- ...d_message.robot => on_limit_message.robot} | 2 +- ...d_message.robot => on_limit_message.robot} | 18 +++++----- .../listeners/VerifyAttributes.py | 4 +-- .../CreatingTestData/ControlStructures.rst | 5 ++- .../ListenerInterface.rst | 4 +-- src/robot/model/control.py | 10 +++--- src/robot/output/listenerarguments.py | 2 +- src/robot/output/xmllogger.py | 2 +- src/robot/parsing/lexer/statementlexers.py | 2 +- src/robot/parsing/model/blocks.py | 4 +-- src/robot/parsing/model/statements.py | 15 ++++---- src/robot/result/model.py | 8 ++--- src/robot/result/xmlelementhandlers.py | 2 +- src/robot/running/bodyrunner.py | 36 +++++++++---------- src/robot/running/builder/transformers.py | 2 +- src/robot/running/model.py | 4 +-- utest/parsing/test_model.py | 6 ++-- utest/parsing/test_statements.py | 6 ++-- utest/result/test_resultmodel.py | 6 ++-- 19 files changed, 69 insertions(+), 69 deletions(-) rename atest/robot/running/while/{while_limit_exceed_message.robot => on_limit_message.robot} (85%) rename atest/testdata/running/while/{while_limit_exceed_message.robot => on_limit_message.robot} (61%) diff --git a/atest/robot/running/while/while_limit_exceed_message.robot b/atest/robot/running/while/on_limit_message.robot similarity index 85% rename from atest/robot/running/while/while_limit_exceed_message.robot rename to atest/robot/running/while/on_limit_message.robot index 9a77606dd9e..9636eb96c2f 100644 --- a/atest/robot/running/while/while_limit_exceed_message.robot +++ b/atest/robot/running/while/on_limit_message.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} running/while/while_limit_exceed_message.robot +Suite Setup Run Tests ${EMPTY} running/while/on_limit_message.robot Resource while.resource *** Test Cases *** diff --git a/atest/testdata/running/while/while_limit_exceed_message.robot b/atest/testdata/running/while/on_limit_message.robot similarity index 61% rename from atest/testdata/running/while/while_limit_exceed_message.robot rename to atest/testdata/running/while/on_limit_message.robot index d72cfb78e92..bcaeba73b33 100644 --- a/atest/testdata/running/while/while_limit_exceed_message.robot +++ b/atest/testdata/running/while/on_limit_message.robot @@ -6,45 +6,45 @@ ${errorMsg} Error Message *** Test Cases *** Limit exceed message without limit - [Documentation] FAIL Second WHILE loop argument must be 'limit', got 'limit_exceed_message=Error'. - WHILE $variable < 2 limit_exceed_message=Error + [Documentation] FAIL Second WHILE loop argument must be 'limit', got 'on_limit_message=Error'. + WHILE $variable < 2 on_limit_message=Error Log ${variable} END Wrong third argument - [Documentation] FAIL Third WHILE loop argument must be 'limit_exceed_message', got 'limit_exceed_messag=Custom error message'. + [Documentation] FAIL Third WHILE loop argument must be 'on_limit_message', got 'limit_exceed_messag=Custom error message'. WHILE $variable < 2 limit=5 limit_exceed_messag=Custom error message Log ${variable} END Limit exceed message [Documentation] FAIL Custom error message - WHILE $variable < 2 limit=${limit} limit_exceed_message=Custom error message + WHILE $variable < 2 limit=${limit} on_limit_message=Custom error message Log ${variable} END Limit exceed message from variable [Documentation] FAIL ${errorMsg} - WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} + WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} Log ${variable} END Part of limit exceed message from variable [Documentation] FAIL While ${errorMsg} 2 ${number} - WHILE $variable < 2 limit=5 limit_exceed_message=While ${errorMsg} 2 ${number} + WHILE $variable < 2 limit=5 on_limit_message=While ${errorMsg} 2 ${number} Log ${variable} END No limit exceed message - WHILE $variable < 3 limit=10 limit_exceed_message=${errorMsg} 2 + WHILE $variable < 3 limit=10 on_limit_message=${errorMsg} 2 Log ${variable} ${variable}= Evaluate $variable + 1 END Nested while limit exceed message [Documentation] FAIL ${errorMsg} 2 - WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 1 - WHILE $variable < 2 limit=5 limit_exceed_message=${errorMsg} 2 + WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} 1 + WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} 2 Log ${variable} END END diff --git a/atest/testresources/listeners/VerifyAttributes.py b/atest/testresources/listeners/VerifyAttributes.py index 4b29c963b86..ee07937557b 100644 --- a/atest/testresources/listeners/VerifyAttributes.py +++ b/atest/testresources/listeners/VerifyAttributes.py @@ -7,7 +7,7 @@ TEST = 'id longname tags template originalname source lineno ' KW = 'kwname libname args assign tags type lineno source status ' KW_TYPES = {'FOR': 'variables flavor values', - 'WHILE': 'condition limit limit_exceed_message', + 'WHILE': 'condition limit on_limit_message', 'IF': 'condition', 'ELSE IF': 'condition', 'EXCEPT': 'patterns pattern_type variable', @@ -27,7 +27,7 @@ 'values': (list, dict), 'condition': str, 'limit': (str, type(None)), - 'limit_exceed_message': (str, type(None)), + 'on_limit_message': (str, type(None)), 'patterns': (str, list), 'pattern_type': (str, type(None)), 'variable': (str, type(None))} diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index a783573565f..7fd543bc495 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -591,15 +591,14 @@ A major difference is that the loop status will be `FAIL` in this case. By default, the error message raised when the limit is reached is `WHILE loop was aborted because it did not finish within the limit of 0.5 seconds. Use the 'limit' argument to increase or remove the limit if -needed.`. The error message can be changed with the `limit_exceed_message` -configuration parameter. This parameter must be placed after the `limit` +needed.`. The error message can be changed with the `on_limit_message` configuration parameter. .. sourcecode:: robotframework *** Test Cases *** Limit as iteration count - WHILE True limit=0.5s limit_exceed_message=Custom While loop error message + WHILE True limit=0.5s on_limit_message=Custom While loop error message Log This is run 0.5 seconds. END diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 6853048e5ec..df13d10f6bb 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -263,8 +263,8 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | | | | | * `condition`: The looping condition. | | | | * `limit`: The maximum iteration limit. | - | | | * `limit_exceed_message`: The custom error message if the | - | | | limit is reached. | + | | | * `on_limit_message`: The custom error raised when the | + | | | limit of the WHILE loop is reached. | | | | | | | | Additional attributes for `IF` and `ELSE_IF` types: | | | | | diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 926ff9a18c2..873d7341fa3 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -59,14 +59,14 @@ def __str__(self): class While(BodyItem): type = BodyItem.WHILE body_class = Body - repr_args = ('condition', 'limit', 'limit_exceed_message') - __slots__ = ['condition', 'limit', 'limit_exceed_message'] + repr_args = ('condition', 'limit', 'on_limit_message') + __slots__ = ['condition', 'limit', 'on_limit_message'] def __init__(self, condition=None, limit=None, - limit_exceed_message=None, parent=None): + on_limit_message=None, parent=None): self.condition = condition self.limit = limit - self.limit_exceed_message = limit_exceed_message + self.on_limit_message = on_limit_message self.parent = parent self.body = None @@ -78,7 +78,7 @@ def visit(self, visitor): visitor.visit_while(self) def __str__(self): - return f'WHILE {self.condition}' + (f' {self.limit}' if self.limit else '') + (f' {self.limit_exceed_message}' if self.limit_exceed_message else '') + return f'WHILE {self.condition}' + (f' {self.limit}' if self.limit else '') + (f' {self.on_limit_message}' if self.on_limit_message else '') class IfBranch(BodyItem): diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 47c44750d83..c331977b11c 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -134,7 +134,7 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): BodyItem.IF: ('condition',), BodyItem.ELSE_IF: ('condition'), BodyItem.EXCEPT: ('patterns', 'pattern_type', 'variable'), - BodyItem.WHILE: ('condition', 'limit', 'limit_exceed_message'), + BodyItem.WHILE: ('condition', 'limit', 'on_limit_message'), BodyItem.RETURN: ('values',), BodyItem.ITERATION: ('variables',)} diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index f6f20cec5e3..438475df502 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -146,7 +146,7 @@ def start_while(self, while_): self._writer.start('while', attrs={ 'condition': while_.condition, 'limit': while_.limit, - 'limit_exceed_message': while_.limit_exceed_message + 'on_limit_message': while_.on_limit_message }) self._writer.element('doc', while_.doc) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index c5d834fa8d4..20b4ff7deb9 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -301,7 +301,7 @@ def lex(self): if len(self.statement) > 3: if self.statement[-2].value.startswith('limit='): self.statement[-2].type = Token.OPTION - if self.statement[-1].value.startswith('limit_exceed_message='): + if self.statement[-1].value.startswith('on_limit_message='): self.statement[-1].type = Token.OPTION diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 81800ba87fd..37d2c267fc7 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -343,8 +343,8 @@ def limit(self): return self.header.limit @property - def limit_exceed_message(self): - return self.header.limit_exceed_message + def on_limit_message(self): + return self.header.on_limit_message def validate(self, context): if self._body_is_empty(): diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 2095f480824..25e7918ed2d 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -968,7 +968,7 @@ class WhileHeader(Statement): type = Token.WHILE @classmethod - def from_params(cls, condition, limit=None, limit_exceed_message=None, + def from_params(cls, condition, limit=None, on_limit_message=None, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): tokens = [Token(Token.SEPARATOR, indent), Token(cls.type), @@ -977,10 +977,10 @@ def from_params(cls, condition, limit=None, limit_exceed_message=None, if limit: tokens.extend([Token(Token.SEPARATOR, indent), Token(Token.OPTION, f'limit={limit}')]) - if limit_exceed_message: + if on_limit_message: tokens.extend([Token(Token.SEPARATOR, indent), Token(Token.OPTION, - f'limit_exceed_message={limit_exceed_message}' + f'on_limit_message={on_limit_message}' )]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -995,17 +995,18 @@ def limit(self): return value[len('limit='):] if value else None @property - def limit_exceed_message(self): + def on_limit_message(self): values = self.get_values(Token.OPTION) if(len(values) > 1): value = values[1] else: value = None - return value[len('limit_exceed_message='):] if value else None + return value[len('on_limit_message='):] if value else None def validate(self, context): values = self.get_values(Token.ARGUMENT) options = self.get_values(Token.OPTION) + print(values, options) if len(values) == 0: self.errors += ('WHILE must have a condition.',) if len(values) == 2: @@ -1014,10 +1015,10 @@ def validate(self, context): self.errors += ( f"Second WHILE loop argument must be 'limit', " f"got '{values[1]}'.",) - elif("limit_exceed_message=" not in options[0]): + elif("on_limit_message=" not in options[0]): self.errors += ( f"Third WHILE loop argument must be " - f"'limit_exceed_message', " + f"'on_limit_message', " f"got '{values[1]}'.",) else: self.errors += ( diff --git a/src/robot/result/model.py b/src/robot/result/model.py index e5f7092b44a..02414ffd9c3 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -222,10 +222,10 @@ class While(model.While, StatusMixin, DeprecatedAttributesMixin): iteration_class = WhileIteration __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, condition=None, limit=None, limit_exceed_message=None, + def __init__(self, condition=None, limit=None, on_limit_message=None, parent=None, status='FAIL', starttime=None, endtime=None, doc=''): - super().__init__(condition, limit, limit_exceed_message, parent) + super().__init__(condition, limit, on_limit_message, parent) self.status = status self.starttime = starttime self.endtime = endtime @@ -243,8 +243,8 @@ def name(self): parts.append(self.condition) if self.limit: parts.append(f'limit={self.limit}') - if self.limit_exceed_message: - parts.append(f'limit_exceed_message={self.limit_exceed_message}') + if self.on_limit_message: + parts.append(f'on_limit_message={self.on_limit_message}') return ' | '.join(parts) diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 0e150e9100e..68028d9fcdf 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -190,7 +190,7 @@ def start(self, elem, result): return result.body.create_while( condition=elem.get('condition'), limit=elem.get('limit'), - limit_exceed_message=elem.get('limit_exceed_message') + on_limit_message=elem.get('on_limit_message') ) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 0f7874a2a0a..97611e6021a 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -342,7 +342,7 @@ def run(self, data): run = False limit = None loop_result = WhileResult(data.condition, data.limit, - data.limit_exceed_message, + data.on_limit_message, starttime=get_timestamp() ) iter_result = loop_result.body.create_iteration(starttime=get_timestamp()) @@ -352,7 +352,7 @@ def run(self, data): elif not ctx.dry_run: try: limit = WhileLimit.create(data.limit, - data.limit_exceed_message, + data.on_limit_message, ctx.variables ) run = self._should_run(data.condition, ctx.variables) @@ -602,14 +602,14 @@ def _run_finally(self, data, run): class WhileLimit: @classmethod - def create(cls, limit, limit_exceed_message, variables): + def create(cls, limit, on_limit_message, variables): if not limit: return IterationCountLimit(DEFAULT_WHILE_LIMIT, - limit_exceed_message + on_limit_message ) - if limit_exceed_message: - limit_exceed_message = variables.replace_string( - limit_exceed_message) + if on_limit_message: + on_limit_message = variables.replace_string( + on_limit_message) value = variables.replace_string(limit) if value.upper() == 'NONE': return NoLimit() @@ -621,17 +621,17 @@ def create(cls, limit, limit_exceed_message, variables): if count <= 0: raise DataError(f"Invalid WHILE loop limit: Iteration count must be " f"a positive integer, got '{count}'.") - return IterationCountLimit(count, limit_exceed_message) + return IterationCountLimit(count, on_limit_message) try: secs = timestr_to_secs(value) except ValueError as err: raise DataError(f'Invalid WHILE loop limit: {err.args[0]}') else: - return DurationLimit(secs, limit_exceed_message) + return DurationLimit(secs, on_limit_message) - def limit_exceeded(self, limit_exceed_message): - if limit_exceed_message: - raise ExecutionFailed(limit_exceed_message) + def limit_exceeded(self, on_limit_message): + if on_limit_message: + raise ExecutionFailed(on_limit_message) else: raise ExecutionFailed(f"WHILE loop was aborted because " f"it did not finish " @@ -648,8 +648,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): class DurationLimit(WhileLimit): - def __init__(self, max_time, limit_exceed_message): - self.limit_exceed_message = limit_exceed_message + def __init__(self, max_time, on_limit_message): + self.on_limit_message = on_limit_message self.max_time = max_time self.start_time = None @@ -657,7 +657,7 @@ def __enter__(self): if not self.start_time: self.start_time = time.time() if time.time() - self.start_time > self.max_time: - self.limit_exceeded(self.limit_exceed_message) + self.limit_exceeded(self.on_limit_message) def __str__(self): return f'{self.max_time} seconds' @@ -665,14 +665,14 @@ def __str__(self): class IterationCountLimit(WhileLimit): - def __init__(self, max_iterations, limit_exceed_message): - self.limit_exceed_message = limit_exceed_message + def __init__(self, max_iterations, on_limit_message): + self.on_limit_message = on_limit_message self.max_iterations = max_iterations self.current_iterations = 0 def __enter__(self): if self.current_iterations >= self.max_iterations: - self.limit_exceeded(self.limit_exceed_message) + self.limit_exceeded(self.on_limit_message) self.current_iterations += 1 def __str__(self): diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index deeac9c1589..50a38b55b64 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -510,7 +510,7 @@ def __init__(self, parent): def build(self, node): error = format_error(self._get_errors(node)) self.model = self.parent.body.create_while( - node.condition, node.limit, node.limit_exceed_message, + node.condition, node.limit, node.on_limit_message, lineno=node.lineno, error=error ) for step in node.body: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 8f7cf88c436..be3949f5e28 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -100,9 +100,9 @@ class While(model.While): __slots__ = ['lineno', 'error'] body_class = Body - def __init__(self, condition=None, limit=None, limit_exceed_message=None, + def __init__(self, condition=None, limit=None, on_limit_message=None, parent=None, lineno=None, error=None): - super().__init__(condition, limit, limit_exceed_message, parent) + super().__init__(condition, limit, on_limit_message, parent) self.lineno = lineno self.error = error diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 527c9b070ba..7fc37b92821 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -373,11 +373,11 @@ def test_limit(self): ) get_and_assert_model(data, expected) - def test_limit_exceed_message(self): + def test_on_limit_message(self): data = ''' *** Test Cases *** Example - WHILE True limit=10s limit_exceed_message=Error message + WHILE True limit=10s on_limit_message=Error message Log ${x} END ''' @@ -386,7 +386,7 @@ def test_limit_exceed_message(self): Token(Token.WHILE, 'WHILE', 3, 4), Token(Token.ARGUMENT, 'True', 3, 13), Token(Token.OPTION, 'limit=10s', 3, 21), - Token(Token.OPTION, 'limit_exceed_message=Error message', + Token(Token.OPTION, 'on_limit_message=Error message', 3, 34) ]), body=[ diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index b7de22245df..1fc10e709f4 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -901,7 +901,7 @@ def test_WhileHeader(self): condition='$cond', limit='100s' ) - # WHILE $cond limit=10 limit_exceed_message=Error message + # WHILE $cond limit=10 on_limit_message=Error message tokens = [ Token(Token.SEPARATOR, ' '), Token(Token.WHILE), @@ -910,7 +910,7 @@ def test_WhileHeader(self): Token(Token.SEPARATOR, ' '), Token(Token.OPTION, 'limit=10'), Token(Token.SEPARATOR, ' '), - Token(Token.OPTION, 'limit_exceed_message=Error message'), + Token(Token.OPTION, 'on_limit_message=Error message'), Token(Token.EOL, '\n') ] assert_created_statement( @@ -918,7 +918,7 @@ def test_WhileHeader(self): WhileHeader, condition='$cond', limit='10', - limit_exceed_message='Error message' + on_limit_message='Error message' ) def test_End(self): diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index dcf8af7b7cc..8bb1cdd8b3a 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -172,9 +172,9 @@ def test_while_name(self): assert_equal(While('True', '1 minute').name, 'True | limit=1 minute') assert_equal(While(limit='1 minute').name, 'limit=1 minute') assert_equal(While('True', '1 s', 'Error message').name, - 'True | limit=1 s | limit_exceed_message=Error message') - assert_equal(While(limit_exceed_message='Error message').name, - 'limit_exceed_message=Error message') + 'True | limit=1 s | on_limit_message=Error message') + assert_equal(While(on_limit_message='Error message').name, + 'on_limit_message=Error message') def test_break_continue_return(self): for cls in Break, Continue, Return: From de57e36b743865fc9f415bda7047a2f07a0bf547 Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sat, 31 Dec 2022 18:25:10 +0100 Subject: [PATCH 0308/1592] on_limit_message can be placed before 'limit' or without 'limit' --- .../running/while/on_limit_message.robot | 20 +++++++---- .../running/while/on_limit_message.robot | 31 +++++++++++----- .../testdata/running/while/while_limit.robot | 2 +- src/robot/parsing/lexer/statementlexers.py | 6 ++-- src/robot/parsing/model/statements.py | 36 +++++++------------ 5 files changed, 53 insertions(+), 42 deletions(-) diff --git a/atest/robot/running/while/on_limit_message.robot b/atest/robot/running/while/on_limit_message.robot index 9636eb96c2f..5cea78ea333 100644 --- a/atest/robot/running/while/on_limit_message.robot +++ b/atest/robot/running/while/on_limit_message.robot @@ -3,23 +3,29 @@ Suite Setup Run Tests ${EMPTY} running/while/on_limit_message.robot Resource while.resource *** Test Cases *** -Limit exceed message without limit +On limit message without limit Check Test Case ${TESTNAME} -Wrong third argument +Wrong WHILE argument Check Test Case ${TESTNAME} -Limit exceed message +On limit message Check Test Case ${TESTNAME} -Limit exceed message from variable +On limit message from variable Check Test Case ${TESTNAME} -Part of limit exceed message from variable +Part of on limit message from variable Check Test Case ${TESTNAME} -No limit exceed message +No on limit message Check Test Case ${TESTNAME} -Nested while limit exceed message +Nested while on limit message Check Test Case ${TESTNAME} + +On limit message before limit + Check Test Case ${TESTNAME} + +Wrong WHILE arguments + Check Test Case ${TESTNAME} \ No newline at end of file diff --git a/atest/testdata/running/while/on_limit_message.robot b/atest/testdata/running/while/on_limit_message.robot index bcaeba73b33..719133e2afa 100644 --- a/atest/testdata/running/while/on_limit_message.robot +++ b/atest/testdata/running/while/on_limit_message.robot @@ -5,46 +5,59 @@ ${number} ${0.2} ${errorMsg} Error Message *** Test Cases *** -Limit exceed message without limit - [Documentation] FAIL Second WHILE loop argument must be 'limit', got 'on_limit_message=Error'. +On limit message without limit + [Documentation] FAIL Error WHILE $variable < 2 on_limit_message=Error Log ${variable} END -Wrong third argument - [Documentation] FAIL Third WHILE loop argument must be 'on_limit_message', got 'limit_exceed_messag=Custom error message'. +Wrong WHILE argument + [Documentation] FAIL WHILE loop arguments must be 'limit' or 'on_limit_message', got 'limit_exceed_messag=Custom error message'. WHILE $variable < 2 limit=5 limit_exceed_messag=Custom error message Log ${variable} END -Limit exceed message +On limit message [Documentation] FAIL Custom error message WHILE $variable < 2 limit=${limit} on_limit_message=Custom error message Log ${variable} END -Limit exceed message from variable +On limit message from variable [Documentation] FAIL ${errorMsg} WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} Log ${variable} END -Part of limit exceed message from variable +Part of on limit message from variable [Documentation] FAIL While ${errorMsg} 2 ${number} WHILE $variable < 2 limit=5 on_limit_message=While ${errorMsg} 2 ${number} Log ${variable} END -No limit exceed message +No on limit message WHILE $variable < 3 limit=10 on_limit_message=${errorMsg} 2 Log ${variable} ${variable}= Evaluate $variable + 1 END -Nested while limit exceed message +Nested while on limit message [Documentation] FAIL ${errorMsg} 2 WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} 1 WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} 2 Log ${variable} END END + +On limit message before limit + [Documentation] FAIL Error + WHILE $variable < 2 on_limit_message=Error limit=5 + Log ${variable} + END + + +Wrong WHILE arguments + [Documentation] FAIL WHILE loop arguments must be 'limit' or 'on_limit_message', got 'limite=5'. + WHILE $variable < 2 limite=5 limit_exceed_messag=Custom error message + Log ${variable} + END \ No newline at end of file diff --git a/atest/testdata/running/while/while_limit.robot b/atest/testdata/running/while/while_limit.robot index 688eb3cd41f..f755a61e2e3 100644 --- a/atest/testdata/running/while/while_limit.robot +++ b/atest/testdata/running/while/while_limit.robot @@ -65,7 +65,7 @@ Invalid limit invalid value END Invalid limit mistyped prefix - [Documentation] FAIL Second WHILE loop argument must be 'limit', got 'limitation=-1x'. + [Documentation] FAIL WHILE loop arguments must be 'limit' or 'on_limit_message', got 'limitation=-1x'. WHILE $variable < 2 limitation=-1x Log ${variable} END diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 20b4ff7deb9..00389729cff 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -298,11 +298,13 @@ def lex(self): token.type = Token.ARGUMENT if self.statement[-1].value.startswith('limit='): self.statement[-1].type = Token.OPTION + if self.statement[-1].value.startswith('on_limit_message='): + self.statement[-1].type = Token.OPTION if len(self.statement) > 3: if self.statement[-2].value.startswith('limit='): self.statement[-2].type = Token.OPTION - if self.statement[-1].value.startswith('on_limit_message='): - self.statement[-1].type = Token.OPTION + if self.statement[-2].value.startswith('on_limit_message='): + self.statement[-2].type = Token.OPTION class EndLexer(TypeAndArguments): diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 25e7918ed2d..306e7232118 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -991,39 +991,29 @@ def condition(self): @property def limit(self): - value = self.get_value(Token.OPTION) - return value[len('limit='):] if value else None + values = self.get_values(Token.OPTION) + for value in values: + if value.startswith('limit='): + return value[len('limit='):] + return None @property def on_limit_message(self): values = self.get_values(Token.OPTION) - if(len(values) > 1): - value = values[1] - else: - value = None - return value[len('on_limit_message='):] if value else None + for value in values: + if value.startswith('on_limit_message='): + return value[len('on_limit_message='):] + return None def validate(self, context): values = self.get_values(Token.ARGUMENT) - options = self.get_values(Token.OPTION) - print(values, options) if len(values) == 0: self.errors += ('WHILE must have a condition.',) - if len(values) == 2: - if(len(options) > 0): - if("limit=" not in options[0]): - self.errors += ( - f"Second WHILE loop argument must be 'limit', " + if len(values) == 2 or len(values) == 3: + self.errors += ( + f"WHILE loop arguments must be 'limit' " + f"or 'on_limit_message', " f"got '{values[1]}'.",) - elif("on_limit_message=" not in options[0]): - self.errors += ( - f"Third WHILE loop argument must be " - f"'on_limit_message', " - f"got '{values[1]}'.",) - else: - self.errors += ( - f"Second WHILE loop argument must be 'limit', " - f"got '{values[1]}'.",) if len(values) > 3: self.errors += ('WHILE cannot have more than one condition.',) From 5976992a2daf6066d7c269022def8cf1a2d3c5db Mon Sep 17 00:00:00 2001 From: asaout <saoutart@gmail.com> Date: Sat, 31 Dec 2022 18:30:16 +0100 Subject: [PATCH 0309/1592] atest : adding newline at end of file --- atest/robot/running/while/on_limit_message.robot | 2 +- atest/testdata/running/while/on_limit_message.robot | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/atest/robot/running/while/on_limit_message.robot b/atest/robot/running/while/on_limit_message.robot index 5cea78ea333..05351aacfd4 100644 --- a/atest/robot/running/while/on_limit_message.robot +++ b/atest/robot/running/while/on_limit_message.robot @@ -28,4 +28,4 @@ On limit message before limit Check Test Case ${TESTNAME} Wrong WHILE arguments - Check Test Case ${TESTNAME} \ No newline at end of file + Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/while/on_limit_message.robot b/atest/testdata/running/while/on_limit_message.robot index 719133e2afa..1bff8ccb904 100644 --- a/atest/testdata/running/while/on_limit_message.robot +++ b/atest/testdata/running/while/on_limit_message.robot @@ -60,4 +60,4 @@ Wrong WHILE arguments [Documentation] FAIL WHILE loop arguments must be 'limit' or 'on_limit_message', got 'limite=5'. WHILE $variable < 2 limite=5 limit_exceed_messag=Custom error message Log ${variable} - END \ No newline at end of file + END From 3d89e7e7cf0ff212e03ffefe9a0f5835acdebd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 6 Jan 2023 17:54:37 +0200 Subject: [PATCH 0310/1592] Fix version number in deprecation warning. Fixes #4587. --- src/robot/running/namespace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index c71823a8a94..83a9348323f 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -333,7 +333,7 @@ def _get_runner_from_suite_file(self, name): f"Keyword '{caller.longname}' called keyword '{name}' that exists " f"both in the same resource file as the caller and in the suite " f"file using that resource. The keyword in the suite file is used " - f"now, but this will change in Robot Framework 6.0." + f"now, but this will change in Robot Framework 7.0." ) runner.pre_run_messages += Message(message, level='WARN'), return runner From bce5651769954bc98a9a5755a6d68f9203e5fc45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 6 Jan 2023 18:01:39 +0200 Subject: [PATCH 0311/1592] Don't run tests using Python 3.6 on CI GitHub Actions doesn't anymore support Python 3.6 on latest Ubuntu and I doubt its well supported on Windows either. Easier to just remove it from there than running it on some older Ubuntu version. We can just run tests with it locally now and then until we drop Python 3.6 support ourselves (#4294). --- .github/workflows/acceptance_tests_cpython.yml | 4 +--- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 676344fa113..b42453129f2 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', '3.11.0-beta - 3.11', 'pypy-3.8' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 @@ -27,8 +27,6 @@ jobs: exclude: - os: windows-latest python-version: 'pypy-3.8' - - os: windows-latest - python-version: '3.11.0-beta - 3.11' runs-on: ${{ matrix.os }} diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index ee5b2696e35..e1ec685eeeb 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.10' ] + python-version: [ '3.7', '3.11' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 @@ -31,7 +31,7 @@ jobs: - name: Setup python for starting the tests uses: actions/setup-python@v4.4.0 with: - python-version: '3.10' + python-version: '3.11' architecture: 'x64' - name: Get test starter Python at Windows diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 7dcba915a49..69e8d8b2d15 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10', '3.11.0-beta - 3.11', 'pypy-3.8' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] exclude: - os: windows-latest python-version: 'pypy-3.8' diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml index 5d0d079c6b4..653bb69bcae 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.6', '3.10' ] + python-version: [ '3.7', '3.11' ] runs-on: ${{ matrix.os }} From 9da7ca548c7e31bb84135f76d847cd3aabf2eb45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 6 Jan 2023 19:30:04 +0200 Subject: [PATCH 0312/1592] Fix test after changing deprecation message. Part of #4587. --- atest/robot/keywords/keyword_namespaces.robot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atest/robot/keywords/keyword_namespaces.robot b/atest/robot/keywords/keyword_namespaces.robot index f8b5c96cb44..f5c6cb75102 100644 --- a/atest/robot/keywords/keyword_namespaces.robot +++ b/atest/robot/keywords/keyword_namespaces.robot @@ -30,7 +30,7 @@ Keyword From Test Case File Overriding Local Keyword In Resource File Is Depreca ${message} = Catenate ... Keyword 'my_resource_1.Use test case file keyword even when local keyword with same name exists' called keyword ... 'Keyword Everywhere' that exists both in the same resource file as the caller and in the suite file using that - ... resource. The keyword in the suite file is used now, but this will change in Robot Framework 6.0. + ... resource. The keyword in the suite file is used now, but this will change in Robot Framework 7.0. Check Log Message ${tc.body[0].body[0].msgs[0]} ${message} WARN Check Log Message ${ERRORS}[1] ${message} WARN From bc2405890ef843d73bb6c4262a61116d21006ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sat, 7 Jan 2023 16:11:06 +0200 Subject: [PATCH 0313/1592] parsing: suppport __init__ file for multisoure suite Relates to #4015 --- atest/robot/cli/runner/multisource.robot | 18 ++++++++++++++++++ src/robot/parsing/suitestructure.py | 18 ++++++++++++++++-- src/robot/running/builder/builders.py | 2 ++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/atest/robot/cli/runner/multisource.robot b/atest/robot/cli/runner/multisource.robot index 2f06a21617a..86fa75c373d 100644 --- a/atest/robot/cli/runner/multisource.robot +++ b/atest/robot/cli/runner/multisource.robot @@ -45,6 +45,24 @@ Wildcards Should Contain Tests ${SUITE.suites[2]} Suite3 First Check Names ${SUITE.suites[2].tests[0]} Suite3 First Tsuite1 & Tsuite2 & Tsuite3.Tsuite3. +With Init File Included + Run Tests ${EMPTY} misc/suites/tsuite1.robot misc/suites/tsuite2.robot misc/suites/__init__.robot + Check Names ${SUITE} Tsuite1 & Tsuite2 + Should Contain Suites ${SUITE} Tsuite1 Tsuite2 + Check Keyword Data ${SUITE.teardown} BuiltIn.Log args=\${SUITE_TEARDOWN_ARG} type=TEARDOWN + Check Names ${SUITE.suites[0]} Tsuite1 Tsuite1 & Tsuite2. + Should Contain Tests ${SUITE.suites[0]} Suite1 First Suite1 Second Third In Suite1 + Check Names ${SUITE.suites[0].tests[0]} Suite1 First Tsuite1 & Tsuite2.Tsuite1. + Check Names ${SUITE.suites[0].tests[1]} Suite1 Second Tsuite1 & Tsuite2.Tsuite1. + Check Names ${SUITE.suites[0].tests[2]} Third In Suite1 Tsuite1 & Tsuite2.Tsuite1. + Check Names ${SUITE.suites[1]} Tsuite2 Tsuite1 & Tsuite2. + Should Contain Tests ${SUITE.suites[1]} Suite2 First + Check Names ${SUITE.suites[1].tests[0]} Suite2 First Tsuite1 & Tsuite2.Tsuite2. + +Multiple Init Files Not Allowed + Run Tests Without Processing Output ${EMPTY} misc/suites/tsuite1.robot misc/suites/__init__.robot misc/suites/__init__.robot + Stderr Should Contain [ ERROR ] Multiple init files not allowed. + Failure When Parsing Any Data Source Fails Run Tests Without Processing Output ${EMPTY} nönex misc/pass_and_fail.robot ${nönex} = Normalize Path ${DATADIR}/nönex diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index f422855e1e5..311801741f7 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -58,8 +58,22 @@ def build(self, paths): paths = list(self._normalize_paths(paths)) if len(paths) == 1: return self._build(paths[0], self.included_suites) - children = [self._build(p, self.included_suites) for p in paths] - return SuiteStructure(children=children) + sources, init_file = self._get_sources(paths) + return SuiteStructure(children=sources, init_file=init_file) + + def _get_sources(self, paths): + init_file = None + sources = [] + for p in paths: + base, ext = os.path.splitext(os.path.basename(p)) + ext = ext[1:].lower() + if self._is_init_file(p, base, ext): + if init_file: + raise DataError("Multiple init files not allowed.") + init_file = p + else: + sources.append(self._build(p, self.included_suites)) + return sources, init_file def _normalize_paths(self, paths): if not paths: diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index cdd696432bf..47deba172b4 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -170,6 +170,8 @@ def _build_suite(self, structure): try: if structure.is_directory: suite = parser.parse_init_file(structure.init_file or source, defaults) + if structure.source is None: + suite.name = None else: suite = parser.parse_suite_file(source, defaults) if not suite.tests: From a5b609d3ff6a2ef0cb0dbe48821e8bc5f86c62d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sat, 7 Jan 2023 20:16:59 +0200 Subject: [PATCH 0314/1592] Revert "parsing: suppport __init__ file for multisoure suite" This reverts commit bc2405890ef843d73bb6c4262a61116d21006ff1. Committed to early --- atest/robot/cli/runner/multisource.robot | 18 ------------------ src/robot/parsing/suitestructure.py | 18 ++---------------- src/robot/running/builder/builders.py | 2 -- 3 files changed, 2 insertions(+), 36 deletions(-) diff --git a/atest/robot/cli/runner/multisource.robot b/atest/robot/cli/runner/multisource.robot index 86fa75c373d..2f06a21617a 100644 --- a/atest/robot/cli/runner/multisource.robot +++ b/atest/robot/cli/runner/multisource.robot @@ -45,24 +45,6 @@ Wildcards Should Contain Tests ${SUITE.suites[2]} Suite3 First Check Names ${SUITE.suites[2].tests[0]} Suite3 First Tsuite1 & Tsuite2 & Tsuite3.Tsuite3. -With Init File Included - Run Tests ${EMPTY} misc/suites/tsuite1.robot misc/suites/tsuite2.robot misc/suites/__init__.robot - Check Names ${SUITE} Tsuite1 & Tsuite2 - Should Contain Suites ${SUITE} Tsuite1 Tsuite2 - Check Keyword Data ${SUITE.teardown} BuiltIn.Log args=\${SUITE_TEARDOWN_ARG} type=TEARDOWN - Check Names ${SUITE.suites[0]} Tsuite1 Tsuite1 & Tsuite2. - Should Contain Tests ${SUITE.suites[0]} Suite1 First Suite1 Second Third In Suite1 - Check Names ${SUITE.suites[0].tests[0]} Suite1 First Tsuite1 & Tsuite2.Tsuite1. - Check Names ${SUITE.suites[0].tests[1]} Suite1 Second Tsuite1 & Tsuite2.Tsuite1. - Check Names ${SUITE.suites[0].tests[2]} Third In Suite1 Tsuite1 & Tsuite2.Tsuite1. - Check Names ${SUITE.suites[1]} Tsuite2 Tsuite1 & Tsuite2. - Should Contain Tests ${SUITE.suites[1]} Suite2 First - Check Names ${SUITE.suites[1].tests[0]} Suite2 First Tsuite1 & Tsuite2.Tsuite2. - -Multiple Init Files Not Allowed - Run Tests Without Processing Output ${EMPTY} misc/suites/tsuite1.robot misc/suites/__init__.robot misc/suites/__init__.robot - Stderr Should Contain [ ERROR ] Multiple init files not allowed. - Failure When Parsing Any Data Source Fails Run Tests Without Processing Output ${EMPTY} nönex misc/pass_and_fail.robot ${nönex} = Normalize Path ${DATADIR}/nönex diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 311801741f7..f422855e1e5 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -58,22 +58,8 @@ def build(self, paths): paths = list(self._normalize_paths(paths)) if len(paths) == 1: return self._build(paths[0], self.included_suites) - sources, init_file = self._get_sources(paths) - return SuiteStructure(children=sources, init_file=init_file) - - def _get_sources(self, paths): - init_file = None - sources = [] - for p in paths: - base, ext = os.path.splitext(os.path.basename(p)) - ext = ext[1:].lower() - if self._is_init_file(p, base, ext): - if init_file: - raise DataError("Multiple init files not allowed.") - init_file = p - else: - sources.append(self._build(p, self.included_suites)) - return sources, init_file + children = [self._build(p, self.included_suites) for p in paths] + return SuiteStructure(children=children) def _normalize_paths(self, paths): if not paths: diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 47deba172b4..cdd696432bf 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -170,8 +170,6 @@ def _build_suite(self, structure): try: if structure.is_directory: suite = parser.parse_init_file(structure.init_file or source, defaults) - if structure.source is None: - suite.name = None else: suite = parser.parse_suite_file(source, defaults) if not suite.tests: From 1b53d722f0d6c025e9310c9cce5226681b596188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 8 Jan 2023 21:18:57 +0200 Subject: [PATCH 0315/1592] Release notes for 6.0.2 --- doc/releasenotes/rf-6.0.2.rst | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 doc/releasenotes/rf-6.0.2.rst diff --git a/doc/releasenotes/rf-6.0.2.rst b/doc/releasenotes/rf-6.0.2.rst new file mode 100644 index 00000000000..45a3b6527fc --- /dev/null +++ b/doc/releasenotes/rf-6.0.2.rst @@ -0,0 +1,95 @@ +===================== +Robot Framework 6.0.2 +===================== + +.. default-role:: code + +`Robot Framework`_ 6.0.2 the second also the last maintenance release in the +`RF 6.0 <rf-6.0.rst>`_ series. It does not contain any high priority fixes or +enhancements and was released mainly to make it possible to fully concentrate +on Robot Framework 6.1. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.0.2 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.0.2 was released on Sunday January 8, 2023. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0.2 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Acknowledgements +================ + +Thanks for `Robot Framework Foundation`_ for sponsoring the development and +for Jerzy Głowacki for providing Polish translations for Boolean words (`#4528`_). + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#4527`_ + - bug + - medium + - Using settings valid in Settings section with tests or keywords (e.g. `[Metadata]`) causes confusing error message + * - `#4528`_ + - enhancement + - medium + - Polish translations for Boolean words + * - `#4533`_ + - bug + - low + - IF and WHILE execution time does not include time taken for evaluating condition + * - `#4557`_ + - bug + - low + - Bug in `--reportbackgroundcolor` documentation in the User Guide + * - `#4587`_ + - bug + - low + - Wrong version number in deprecation warning + +Altogether 5 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.0.2>`__. + +.. _#4527: https://github.com/robotframework/robotframework/issues/4527 +.. _#4528: https://github.com/robotframework/robotframework/issues/4528 +.. _#4533: https://github.com/robotframework/robotframework/issues/4533 +.. _#4557: https://github.com/robotframework/robotframework/issues/4557 +.. _#4587: https://github.com/robotframework/robotframework/issues/4587 From c69eb6ba72791bfe7297e6447d453326b9d3c2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 8 Jan 2023 21:19:24 +0200 Subject: [PATCH 0316/1592] Updated version to 6.0.2 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 1dfaca1a270..31f33b1d2fd 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.2.dev1' +VERSION = '6.0.2' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index d286f75c2b5..f205db2dd33 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.2.dev1' +VERSION = '6.0.2' def get_version(naked=False): From 7168517b8c17a525b91d474d47402bde5b2fc0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 8 Jan 2023 21:31:15 +0200 Subject: [PATCH 0317/1592] Regenerate with v6.0.2 changes. In practice adds Polish Boolean words. --- doc/userguide/src/Appendices/Translations.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index cce360f159c..132452a0b33 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -1392,9 +1392,9 @@ Boolean strings * - True/False - Values * - True - - + - Prawda, Tak, Włączone * - False - - + - Fałsz, Nie, Wyłączone, Nic Portuguese (pt) --------------- From cb72e61e9cbe41b6bd73aee390be81f78751b243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 8 Jan 2023 21:32:00 +0200 Subject: [PATCH 0318/1592] Release notes tuning. - Grammar fixes to 6.0.2 notes. - Mention 6.0.2 in 6.0 and 6.0.1 notes. --- doc/releasenotes/rf-6.0.1.rst | 1 + doc/releasenotes/rf-6.0.2.rst | 6 +++--- doc/releasenotes/rf-6.0.rst | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/doc/releasenotes/rf-6.0.1.rst b/doc/releasenotes/rf-6.0.1.rst index 7f7e69c369c..a125005f9df 100644 --- a/doc/releasenotes/rf-6.0.1.rst +++ b/doc/releasenotes/rf-6.0.1.rst @@ -30,6 +30,7 @@ distribution from PyPI_ and install it manually. For more details and other installation approaches, see the `installation instructions`_. Robot Framework 6.0.1 was released on Thursday November 3, 2022. +It was superseded by `Robot Framework 6.0.2 <rf-6.0.2.rst>`_ .. _Robot Framework: http://robotframework.org .. _Robot Framework Foundation: http://robotframework.org/foundation diff --git a/doc/releasenotes/rf-6.0.2.rst b/doc/releasenotes/rf-6.0.2.rst index 45a3b6527fc..01f68dd2cad 100644 --- a/doc/releasenotes/rf-6.0.2.rst +++ b/doc/releasenotes/rf-6.0.2.rst @@ -4,9 +4,9 @@ Robot Framework 6.0.2 .. default-role:: code -`Robot Framework`_ 6.0.2 the second also the last maintenance release in the -`RF 6.0 <rf-6.0.rst>`_ series. It does not contain any high priority fixes or -enhancements and was released mainly to make it possible to fully concentrate +`Robot Framework`_ 6.0.2 is the second and also the last maintenance release in +the `RF 6.0 <rf-6.0.rst>`_ series. It does not contain any high priority fixes +or enhancements and was released mainly to make it possible to fully concentrate on Robot Framework 6.1. Questions and comments related to the release can be sent to the diff --git a/doc/releasenotes/rf-6.0.rst b/doc/releasenotes/rf-6.0.rst index c9f33f7ab22..8550558323f 100644 --- a/doc/releasenotes/rf-6.0.rst +++ b/doc/releasenotes/rf-6.0.rst @@ -32,7 +32,8 @@ distribution from PyPI_ and install it manually. For more details and other installation approaches, see the `installation instructions`_. Robot Framework 6.0 was released on Wednesday October 19, 2022. It was -superseded by `Robot Framework 6.0.1 <rf-6.0.1.rst>`_ on Thursday November 3, 2022. +superseded by `Robot Framework 6.0.1 <rf-6.0.1.rst>`_ and +`Robot Framework 6.0.2 <rf-6.0.2.rst>`_. .. _Robot Framework: http://robotframework.org .. _Robot Framework Foundation: http://robotframework.org/foundation From 9dfdec541030e85a156e19003690a32713eaf58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 8 Jan 2023 21:36:30 +0200 Subject: [PATCH 0319/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 31f33b1d2fd..03567b11b7e 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.2' +VERSION = '6.0.3.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index f205db2dd33..7379322c340 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.2' +VERSION = '6.0.3.dev1' def get_version(naked=False): From 5ebd6c04a97c73f26093d6f8b5284fbd464bba91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 8 Jan 2023 21:36:54 +0200 Subject: [PATCH 0320/1592] Start 6.1 development --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 03567b11b7e..d6188fff804 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.3.dev1' +VERSION = '6.1.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 7379322c340..3be9a14084a 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.0.3.dev1' +VERSION = '6.1.dev1' def get_version(naked=False): From 230f3377f4ba395fc2e1ca4bc7e06b623f5699e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 9 Jan 2023 10:51:31 +0200 Subject: [PATCH 0321/1592] Initial `from_json` support. #3902 - Add generic `from_json` and `from_dict` to the ModelObject base class. - Enhance ItemList to accept objects as dicts. Not all body items are yet supported (dispatching by type is incomplete) but otherwise this seems to work pretty well. --- src/robot/model/body.py | 11 ++++++--- src/robot/model/itemlist.py | 44 ++++++++++++++++++++++++--------- src/robot/model/modelobject.py | 16 +++++++++++- src/robot/model/testcase.py | 15 ++++++----- utest/model/test_itemlist.py | 28 +++++++++++++++++++++ utest/model/test_modelobject.py | 38 +++++++++++++++++++++++++++- 6 files changed, 127 insertions(+), 25 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index b51b4f9d245..8593bff9749 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -93,12 +93,17 @@ class BaseBody(ItemList): def __init__(self, parent=None, items=None): super().__init__(BodyItem, {'parent': parent}, items) + def _item_from_dict(self, data): + # FIXME: This doesn't work with all objects! + class_name = data.get('type', BodyItem.KEYWORD).lower() + '_class' + return getattr(self, class_name).from_dict(data) + @classmethod def register(cls, item_class): name_parts = re.findall('([A-Z][a-z]+)', item_class.__name__) + ['class'] name = '_'.join(name_parts).lower() if not hasattr(cls, name): - raise TypeError("Cannot register '%s'." % name) + raise TypeError(f"Cannot register '{name}'.") setattr(cls, name, item_class) return item_class @@ -202,7 +207,7 @@ def flatten(self): class Body(BaseBody): - """A list-like object representing body of a suite, a test or a keyword. + """A list-like object representing a body of a test, keyword, etc. Body contains the keywords and other structures such as FOR loops. """ @@ -210,7 +215,7 @@ class Body(BaseBody): class Branches(BaseBody): - """A list-like object representing branches IF and TRY objects contain.""" + """A list-like object representing IF and TRY branches.""" __slots__ = ['branch_class'] def __init__(self, branch_class, parent=None, items=None): diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index 695da536a7c..de89a7de945 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -21,6 +21,19 @@ @total_ordering class ItemList(MutableSequence): + """List of items of a certain enforced type. + + New items can be created using the :meth:`create` method and existing items + added using the common list methods like :meth:`append` or :meth:`insert`. + In addition to the common type, items can have certain common and + automatically assigned attributes. + + Starting from RF 6.1, items can be added as dictionaries and actual items + are generated based on them automatically. If the type has a ``from_dict`` + classmethod, it is used, and otherwise dictionary data is passed to + the type as keyword arguments. + """ + __slots__ = ['_item_class', '_common_attrs', '_items'] def __init__(self, item_class, common_attrs=None, items=None): @@ -34,25 +47,32 @@ def create(self, *args, **kwargs): return self.append(self._item_class(*args, **kwargs)) def append(self, item): - self._check_type_and_set_attrs(item) + item = self._check_type_and_set_attrs(item) self._items.append(item) return item - def _check_type_and_set_attrs(self, *items): - common_attrs = self._common_attrs or {} - for item in items: - if not isinstance(item, self._item_class): + def _check_type_and_set_attrs(self, item): + if not isinstance(item, self._item_class): + if isinstance(item, dict): + item = self._item_from_dict(item) + else: raise TypeError(f'Only {type_name(self._item_class)} objects ' f'accepted, got {type_name(item)}.') - for attr in common_attrs: - setattr(item, attr, common_attrs[attr]) - return items + if self._common_attrs: + for attr, value in self._common_attrs.items(): + setattr(item, attr, value) + return item + + def _item_from_dict(self, data): + if hasattr(self._item_class, 'from_dict'): + return self._item_class.from_dict(data) + return self._item_class(**data) def extend(self, items): - self._items.extend(self._check_type_and_set_attrs(*items)) + self._items.extend(self._check_type_and_set_attrs(i) for i in items) def insert(self, index, item): - self._check_type_and_set_attrs(item) + item = self._check_type_and_set_attrs(item) self._items.insert(index, item) def index(self, item, *start_and_end): @@ -86,9 +106,9 @@ def _create_new_from(self, items): def __setitem__(self, index, item): if isinstance(index, slice): - self._check_type_and_set_attrs(*item) + item = [self._check_type_and_set_attrs(i) for i in item] else: - self._check_type_and_set_attrs(item) + item = self._check_type_and_set_attrs(item) self._items[index] = item def __delitem__(self, index): diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index 5fa752fc6a8..f20f0333593 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -14,6 +14,7 @@ # limitations under the License. import copy +import json from robot.utils import SetterAwareType @@ -22,6 +23,18 @@ class ModelObject(metaclass=SetterAwareType): repr_args = () __slots__ = [] + @classmethod + def from_dict(cls, data): + try: + return cls().config(**data) + except AttributeError as err: + raise ValueError(f"Creating '{full_name(cls)}' object from dictionary " + f"failed: {err}\nDictionary:\n{data}") + + @classmethod + def from_json(cls, data): + return cls.from_dict(json.loads(data)) + def config(self, **attributes): """Configure model object with given attributes. @@ -73,7 +86,8 @@ def _repr(self, repr_args): def full_name(obj): - parts = type(obj).__module__.split('.') + [type(obj).__name__] + typ = type(obj) if not isinstance(obj, type) else obj + parts = typ.__module__.split('.') + [typ.__name__] if len(parts) > 1 and parts[0] == 'robot': parts[2:-1] = [] return '.'.join(parts) diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index fb48a3da46f..dabe0d0b103 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -174,11 +174,10 @@ class TestCases(ItemList): __slots__ = [] def __init__(self, test_class=TestCase, parent=None, tests=None): - ItemList.__init__(self, test_class, {'parent': parent}, tests) - - def _check_type_and_set_attrs(self, *tests): - tests = ItemList._check_type_and_set_attrs(self, *tests) - for test in tests: - for visitor in test.parent._visitors: - test.visit(visitor) - return tests + super().__init__(test_class, {'parent': parent}, tests) + + def _check_type_and_set_attrs(self, test): + test = super()._check_type_and_set_attrs(test) + for visitor in test.parent._visitors: + test.visit(visitor) + return test diff --git a/utest/model/test_itemlist.py b/utest/model/test_itemlist.py index 7a089be1a38..531d16dc539 100644 --- a/utest/model/test_itemlist.py +++ b/utest/model/test_itemlist.py @@ -67,6 +67,10 @@ def test_only_matching_types_can_be_added(self): 'Only Object objects accepted, got integer.', ItemList(Object).insert, 0, 42) + def test_initial_items(self): + assert_equal(list(ItemList(Object, items=[])), []) + assert_equal(list(ItemList(int, items=(1, 2, 3))), [1, 2, 3]) + def test_common_attrs(self): item1 = Object() item2 = Object() @@ -384,6 +388,30 @@ def test_rmul(self): ItemList(int, items=[1, 2, 3, 1, 2, 3])) assert_raises(TypeError, ItemList(int).__rmul__, ItemList(int)) + def test_items_as_dicts_without_from_dict(self): + items = ItemList(Object, items=[{'id': 1}, {}]) + items.append({'id': 3}) + assert_equal(items[0].id, 1) + assert_equal(items[1].id, None) + assert_equal(items[2].id, 3) + + def test_items_as_dicts_with_from_dict(self): + class ObjectWithFromDict(Object): + @classmethod + def from_dict(cls, data): + obj = cls() + for name in data: + setattr(obj, name, data[name]) + return obj + + items = ItemList(ObjectWithFromDict, items=[{'id': 1, 'attr': 2}]) + items.extend([{}, {'new': 3}]) + assert_equal(items[0].id, 1) + assert_equal(items[0].attr, 2) + assert_equal(items[1].id, None) + assert_equal(items[1].attr, 1) + assert_equal(items[2].new, 3) + if __name__ == '__main__': unittest.main() diff --git a/utest/model/test_modelobject.py b/utest/model/test_modelobject.py index e0077ba1afb..26206bf1517 100644 --- a/utest/model/test_modelobject.py +++ b/utest/model/test_modelobject.py @@ -1,7 +1,8 @@ +import re import unittest from robot.model.modelobject import ModelObject -from robot.utils.asserts import assert_equal +from robot.utils.asserts import assert_equal, assert_raises class TestRepr(unittest.TestCase): @@ -21,5 +22,40 @@ class X(ModelObject): assert_equal(repr(X()), '%s.X(z=3, x=1)' % __name__) +class TestFromDictAndJson(unittest.TestCase): + + def test_init_args(self): + class X(ModelObject): + def __init__(self, a=1, b=2): + self.a = a + self.b = b + x = X.from_dict({'a': 3}) + assert_equal(x.a, 3) + assert_equal(x.b, 2) + x = X.from_json('{"a": "A", "b": true}') + assert_equal(x.a, 'A') + assert_equal(x.b, True) + + def test_other_attributes(self): + class X(ModelObject): + pass + x = X.from_dict({'a': 1}) + assert_equal(x.a, 1) + x = X.from_json('{"a": null, "b": 42}') + assert_equal(x.a, None) + assert_equal(x.b, 42) + + def test_not_accepted_attribute(self): + class X(ModelObject): + __slots__ = ['a'] + assert_equal(X.from_dict({'a': 1}).a, 1) + err = assert_raises(ValueError, X.from_dict, {'b': 'bad'}) + expected = (f"Creating '{__name__}.X' object from dictionary failed: .*\n" + f"Dictionary:\n{{'b': 'bad'}}") + if not re.fullmatch(expected, str(err)): + raise AssertionError(f'Unexpected error message. Expected:\n{expected}\n\n' + f'Actual:\n{err}') + + if __name__ == '__main__': unittest.main() From c1534b765aabbd804ce4a5ddd7866ba19afdfed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 9 Jan 2023 13:45:19 +0200 Subject: [PATCH 0322/1592] Remove unused attributes from robot.running.Keyword. `doc`, `tags`, `timeout` and `teardown` aren't used for anything by the execution side keyword. Remove them also from robot.model.Keyword base class and add to robot.result.Keyword. Also enhance docs to make it more explicit that the running side keyword represents a keyword call. Fixes #4589. --- src/robot/model/__init__.py | 13 ++--- src/robot/model/keyword.py | 69 +-------------------------- src/robot/result/model.py | 82 +++++++++++++++++++++++++++----- src/robot/running/model.py | 18 ++++--- utest/model/test_keyword.py | 34 ++++--------- utest/result/test_resultmodel.py | 19 ++++++++ utest/result/test_visitor.py | 17 +++++-- 7 files changed, 132 insertions(+), 120 deletions(-) diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index de57774cc5f..d2ebfed327b 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -28,14 +28,15 @@ from .body import BaseBody, Body, BodyItem, Branches from .configurer import SuiteConfigurer from .control import For, While, If, IfBranch, Try, TryBranch, Return, Continue, Break -from .testsuite import TestSuite -from .testcase import TestCase +from .fixture import create_fixture +from .itemlist import ItemList from .keyword import Keyword, Keywords from .message import Message, Messages from .modifier import ModelModifier -from .tags import Tags, TagPattern, TagPatterns from .namepatterns import SuiteNamePatterns, TestNamePatterns -from .visitor import SuiteVisitor -from .totalstatistics import TotalStatisticsBuilder from .statistics import Statistics -from .itemlist import ItemList +from .tags import Tags, TagPattern, TagPatterns +from .testcase import TestCase +from .testsuite import TestSuite +from .totalstatistics import TotalStatisticsBuilder +from .visitor import SuiteVisitor diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index c5245e8d2c7..685d29c3f3b 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -15,12 +15,8 @@ import warnings -from robot.utils import setter - from .body import Body, BodyItem -from .fixture import create_fixture from .itemlist import ItemList -from .tags import Tags @Body.register @@ -31,18 +27,13 @@ class Keyword(BodyItem): :class:`robot.result.model.Keyword`. """ repr_args = ('name', 'args', 'assign') - __slots__ = ['_name', 'doc', 'args', 'assign', 'timeout', 'type', '_teardown'] + __slots__ = ['_name', 'args', 'assign', 'type'] - def __init__(self, name='', doc='', args=(), assign=(), tags=(), - timeout=None, type=BodyItem.KEYWORD, parent=None): + def __init__(self, name='', args=(), assign=(), type=BodyItem.KEYWORD, parent=None): self._name = name - self.doc = doc self.args = args self.assign = assign - self.tags = tags - self.timeout = timeout self.type = type - self._teardown = None self.parent = parent @property @@ -53,62 +44,6 @@ def name(self): def name(self, name): self._name = name - @property # Cannot use @setter because it would create teardowns recursively. - def teardown(self): - """Keyword teardown as a :class:`Keyword` object. - - Teardown can be modified by setting attributes directly:: - - keyword.teardown.name = 'Example' - keyword.teardown.args = ('First', 'Second') - - Alternatively the :meth:`config` method can be used to set multiple - attributes in one call:: - - keyword.teardown.config(name='Example', args=('First', 'Second')) - - The easiest way to reset the whole teardown is setting it to ``None``. - It will automatically recreate the underlying ``Keyword`` object:: - - keyword.teardown = None - - This attribute is a ``Keyword`` object also when a keyword has no teardown - but in that case its truth value is ``False``. If there is a need to just - check does a keyword have a teardown, using the :attr:`has_teardown` - attribute avoids creating the ``Keyword`` object and is thus more memory - efficient. - - New in Robot Framework 4.0. Earlier teardown was accessed like - ``keyword.keywords.teardown``. :attr:`has_teardown` is new in Robot - Framework 4.1.2. - """ - if self._teardown is None and self: - self._teardown = create_fixture(None, self, self.TEARDOWN) - return self._teardown - - @teardown.setter - def teardown(self, teardown): - self._teardown = create_fixture(teardown, self, self.TEARDOWN) - - @property - def has_teardown(self): - """Check does a keyword have a teardown without creating a teardown object. - - A difference between using ``if kw.has_teardown:`` and ``if kw.teardown:`` - is that accessing the :attr:`teardown` attribute creates a :class:`Keyword` - object representing a teardown even when the keyword actually does not - have one. This typically does not matter, but with bigger suite structures - having lot of keywords it can have a considerable effect on memory usage. - - New in Robot Framework 4.1.2. - """ - return bool(self._teardown) - - @setter - def tags(self, tags): - """Keyword tags as a :class:`~.model.tags.Tags` object.""" - return Tags(tags) - def visit(self, visitor): """:mod:`Visitor interface <robot.model.visitor>` entry-point.""" if self: diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 8e1ff902b11..e681012a0e5 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -37,7 +37,7 @@ import warnings from robot import model -from robot.model import BodyItem, Keywords, TotalStatisticsBuilder +from robot.model import BodyItem, create_fixture, Keywords, Tags, TotalStatisticsBuilder from robot.utils import get_elapsed_time, setter from .configurer import SuiteConfigurer @@ -420,37 +420,39 @@ def doc(self): @Branches.register @Iterations.register class Keyword(model.Keyword, StatusMixin): - """Represents results of a single keyword. - - See the base class for documentation of attributes not documented here. - """ + """Represents an executed library or user keyword.""" body_class = Body - __slots__ = ['kwname', 'libname', 'status', 'starttime', 'endtime', 'message', - 'sourcename'] + __slots__ = ['kwname', 'libname', 'doc', 'timeout', 'status', '_teardown', + 'starttime', 'endtime', 'message', 'sourcename'] def __init__(self, kwname='', libname='', doc='', args=(), assign=(), tags=(), timeout=None, type=BodyItem.KEYWORD, status='FAIL', starttime=None, endtime=None, parent=None, sourcename=None): - super().__init__(None, doc, args, assign, tags, timeout, type, parent) + super().__init__(None, args, assign, type, parent) #: Name of the keyword without library or resource name. self.kwname = kwname #: Name of the library or resource containing this keyword. self.libname = libname - #: Execution status as a string. ``PASS``, ``FAIL``, ``SKIP`` or ``NOT RUN``. + self.doc = doc + self.tags = tags + self.timeout = timeout self.status = status - #: Keyword execution start time in format ``%Y%m%d %H:%M:%S.%f``. self.starttime = starttime - #: Keyword execution end time in format ``%Y%m%d %H:%M:%S.%f``. self.endtime = endtime #: Keyword status message. Used only if suite teardowns fails. self.message = '' #: Original name of keyword with embedded arguments. self.sourcename = sourcename + self._teardown = None self.body = None @setter def body(self, body): - """Child keywords and messages as a :class:`~.Body` object.""" + """Possible keyword body as a :class:`~.Body` object. + + Body can consist of child keywords, messages, and control structures + such as IF/ELSE. Library keywords typically have an empty body. + """ return self.body_class(self, body) @property @@ -509,6 +511,62 @@ def name(self, name): self.kwname = None self.libname = None + @property # Cannot use @setter because it would create teardowns recursively. + def teardown(self): + """Keyword teardown as a :class:`Keyword` object. + + Teardown can be modified by setting attributes directly:: + + keyword.teardown.name = 'Example' + keyword.teardown.args = ('First', 'Second') + + Alternatively the :meth:`config` method can be used to set multiple + attributes in one call:: + + keyword.teardown.config(name='Example', args=('First', 'Second')) + + The easiest way to reset the whole teardown is setting it to ``None``. + It will automatically recreate the underlying ``Keyword`` object:: + + keyword.teardown = None + + This attribute is a ``Keyword`` object also when a keyword has no teardown + but in that case its truth value is ``False``. If there is a need to just + check does a keyword have a teardown, using the :attr:`has_teardown` + attribute avoids creating the ``Keyword`` object and is thus more memory + efficient. + + New in Robot Framework 4.0. Earlier teardown was accessed like + ``keyword.keywords.teardown``. :attr:`has_teardown` is new in Robot + Framework 4.1.2. + """ + if self._teardown is None and self: + self._teardown = create_fixture(None, self, self.TEARDOWN) + return self._teardown + + @teardown.setter + def teardown(self, teardown): + self._teardown = create_fixture(teardown, self, self.TEARDOWN) + + @property + def has_teardown(self): + """Check does a keyword have a teardown without creating a teardown object. + + A difference between using ``if kw.has_teardown:`` and ``if kw.teardown:`` + is that accessing the :attr:`teardown` attribute creates a :class:`Keyword` + object representing a teardown even when the keyword actually does not + have one. This typically does not matter, but with bigger suite structures + having lots of keywords it can have a considerable effect on memory usage. + + New in Robot Framework 4.1.2. + """ + return bool(self._teardown) + + @setter + def tags(self, tags): + """Keyword tags as a :class:`~.model.tags.Tags` object.""" + return Tags(tags) + class TestCase(model.TestCase, StatusMixin): """Represents results of a single test case. diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 83a409d66ba..d67a001dccc 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -55,18 +55,22 @@ class Body(model.Body): @Body.register class Keyword(model.Keyword): - """Represents a single executable keyword. + """Represents an executable keyword call. - These keywords never have child keywords or messages. The actual keyword - that is executed depends on the context where this model is executed. + A keyword call consists only of a keyword name, arguments and possible + assignment in the data:: - See the base class for documentation of attributes not documented here. + Keyword arg + ${result} = Another Keyword arg1 arg2 + + The actual keyword that is executed depends on the context where this model + is executed. """ __slots__ = ['lineno'] - def __init__(self, name='', doc='', args=(), assign=(), tags=(), timeout=None, - type=BodyItem.KEYWORD, parent=None, lineno=None): - super().__init__(name, doc, args, assign, tags, timeout, type, parent) + def __init__(self, name='', args=(), assign=(), type=BodyItem.KEYWORD, parent=None, + lineno=None): + super().__init__(name, args, assign, type, parent) self.lineno = lineno @property diff --git a/utest/model/test_keyword.py b/utest/model/test_keyword.py index c415bd95aa6..160fa3ef0ea 100644 --- a/utest/model/test_keyword.py +++ b/utest/model/test_keyword.py @@ -35,21 +35,6 @@ def test_test_setup_and_teardown_id(self): assert_equal(test.setup.id, 's1-t1-k1') assert_equal(test.teardown.id, 's1-t1-k3') - def test_keyword_teardown(self): - kw = Keyword() - assert_true(not kw.has_teardown) - assert_true(not kw.teardown) - assert_equal(kw.teardown.name, None) - assert_equal(kw.teardown.type, 'TEARDOWN') - kw.teardown = Keyword() - assert_true(kw.has_teardown) - assert_true(kw.teardown) - assert_equal(kw.teardown.name, '') - assert_equal(kw.teardown.type, 'TEARDOWN') - kw.teardown = None - assert_true(not kw.has_teardown) - assert_true(not kw.teardown) - def test_test_body_id(self): kws = [Keyword(), Keyword(), Keyword()] TestSuite().tests.create().body.extend(kws) @@ -108,30 +93,29 @@ def test_slots(self): assert_raises(AttributeError, setattr, Keyword(), 'attr', 'value') def test_copy(self): - kw = Keyword(name='Keyword') + kw = Keyword(name='Keyword', args=['args']) copy = kw.copy() assert_equal(kw.name, copy.name) copy.name += ' copy' assert_not_equal(kw.name, copy.name) - assert_equal(id(kw.tags), id(copy.tags)) + assert_equal(id(kw.args), id(copy.args)) def test_copy_with_attributes(self): - kw = Keyword(name='Orig', doc='Orig', tags=['orig']) - copy = kw.copy(name='New', doc='New', tags=['new']) + kw = Keyword(name='Orig', args=['orig']) + copy = kw.copy(name='New', args=['new']) assert_equal(copy.name, 'New') - assert_equal(copy.doc, 'New') - assert_equal(list(copy.tags), ['new']) + assert_equal(copy.args, ['new']) def test_deepcopy(self): - kw = Keyword(name='Keyword') + kw = Keyword(name='Keyword', args=['a']) copy = kw.deepcopy() assert_equal(kw.name, copy.name) - assert_not_equal(id(kw.tags), id(copy.tags)) + assert_not_equal(id(kw.args), id(copy.args)) def test_deepcopy_with_attributes(self): - copy = Keyword(name='Orig').deepcopy(name='New', doc='New') + copy = Keyword(name='Orig').deepcopy(name='New', args=['New']) assert_equal(copy.name, 'New') - assert_equal(copy.doc, 'New') + assert_equal(copy.args, ['New']) class TestKeywords(unittest.TestCase): diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index a6c53317c97..8ecbf37f41e 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -265,6 +265,25 @@ def _verify_status_propertys(self, item, initial_status='FAIL'): assert_equal(item.status, 'NOT RUN') assert_raises(ValueError, setattr, item, 'not_run', False) + def test_keyword_teardown(self): + kw = Keyword() + assert_true(not kw.has_teardown) + assert_true(not kw.teardown) + assert_equal(kw.teardown.name, None) + assert_equal(kw.teardown.type, 'TEARDOWN') + assert_true(not kw.has_teardown) + assert_true(not kw.teardown) + kw.teardown = Keyword() + assert_true(kw.has_teardown) + assert_true(kw.teardown) + assert_equal(kw.teardown.name, '') + assert_equal(kw.teardown.type, 'TEARDOWN') + kw.teardown = None + assert_true(not kw.has_teardown) + assert_true(not kw.teardown) + assert_equal(kw.teardown.name, None) + assert_equal(kw.teardown.type, 'TEARDOWN') + def test_keywords_deprecation(self): kw = Keyword() kw.body = [Keyword(), Message(), Keyword(), Keyword(), Message()] diff --git a/utest/result/test_visitor.py b/utest/result/test_visitor.py index f66c7009833..639cc83a669 100644 --- a/utest/result/test_visitor.py +++ b/utest/result/test_visitor.py @@ -21,8 +21,7 @@ def setUp(self): test = suite.tests.create() test.setup.config(name='TS') test.teardown.config(name='TT') - kw = test.body.create_keyword() - kw.teardown.config(name='KT') + test.body.create_keyword() def test_abstract_visitor(self): RESULT.suite.visit(SuiteVisitor()) @@ -40,10 +39,22 @@ def test_start_keyword_can_stop_visiting(self): def test_visit_setups_and_teardowns(self): visitor = VisitSetupsAndTeardowns() self.suite.visit(visitor) + assert_equal(visitor.visited, ['SS', 'TS', 'TT', 'ST']) + + def test_visit_keyword_teardown(self): + suite = ResultSuite() + suite.setup.config(kwname='SS') + suite.teardown.config(kwname='ST') + test = suite.tests.create() + test.setup.config(kwname='TS') + test.teardown.config(kwname='TT') + test.body.create_keyword().teardown.config(kwname='KT') + visitor = VisitSetupsAndTeardowns() + suite.visit(visitor) assert_equal(visitor.visited, ['SS', 'TS', 'KT', 'TT', 'ST']) def test_dont_visit_inactive_setups_and_teardowns(self): - suite = TestSuite() + suite = ResultSuite() suite.tests.create().body.create_keyword() visitor = VisitSetupsAndTeardowns() suite.visit(visitor) From 9df3a6c438074d25ba962f5968360bbfb4b384e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 9 Jan 2023 16:31:41 +0200 Subject: [PATCH 0323/1592] Add `to_dict` to running side TestSuite structure. TestSuite.resource is not yet implemented and roundtrip with `from_dict` doesn't work properly yet. Also need to decide how to handle result side TestSuite structure. Part of #3902. --- src/robot/model/body.py | 5 +- src/robot/model/control.py | 46 +++++++++++ src/robot/model/itemlist.py | 3 + src/robot/model/keyword.py | 8 ++ src/robot/model/modelobject.py | 6 ++ src/robot/model/testcase.py | 17 ++++ src/robot/model/testsuite.py | 22 ++++- src/robot/running/model.py | 85 +++++++++++++++++++- utest/running/test_run_model.py | 137 ++++++++++++++++++++++++++++++-- 9 files changed, 319 insertions(+), 10 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 8593bff9749..02efa1ee7b8 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -75,11 +75,14 @@ def has_setup(self): def has_teardown(self): return False + def to_dict(self): + raise NotImplementedError + class BaseBody(ItemList): """Base class for Body and Branches objects.""" __slots__ = [] - # Set using 'Body.register' when these classes are created. + # Set using 'BaseBody.register' when these classes are created. keyword_class = None for_class = None if_class = None diff --git a/src/robot/model/control.py b/src/robot/model/control.py index ce20b859ca9..b6c57805698 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -54,6 +54,12 @@ def __str__(self): values = ' '.join(self.values) return 'FOR %s %s %s' % (variables, self.flavor, values) + def to_dict(self): + return {'type': self.type, + 'variables': list(self.variables), + 'flavor': self.flavor, + 'values': list(self.values)} + @Body.register class While(BodyItem): @@ -78,6 +84,12 @@ def visit(self, visitor): def __str__(self): return f'WHILE {self.condition}' + (f' {self.limit}' if self.limit else '') + def to_dict(self): + data = {'type': self.type, 'condition': self.condition} + if self.limit: + data['limit'] = self.limit + return data + class IfBranch(BodyItem): body_class = Body @@ -113,6 +125,14 @@ def __str__(self): def visit(self, visitor): visitor.visit_if_branch(self) + def to_dict(self): + data = {'type': self.type, + 'condition': self.condition, + 'body': self.body.to_dicts()} + if self.type == self.ELSE: + data.pop('condition') + return data + @Body.register class If(BodyItem): @@ -138,6 +158,9 @@ def id(self): def visit(self, visitor): visitor.visit_if(self) + def to_dict(self): + return {'type': self.type, 'body': self.body.to_dicts()} + class TryBranch(BodyItem): body_class = Body @@ -185,6 +208,17 @@ def __repr__(self): def visit(self, visitor): visitor.visit_try_branch(self) + def to_dict(self): + data = {'type': self.type} + if self.type == self.EXCEPT: + data['patterns'] = list(self.patterns) + if self.pattern_type: + data['pattern_type'] = self.pattern_type + if self.variable: + data['variable'] = self.variable + data['body'] = self.body.to_dicts() + return data + @Body.register class Try(BodyItem): @@ -233,6 +267,9 @@ def id(self): def visit(self, visitor): visitor.visit_try(self) + def to_dict(self): + return {'type': self.type, 'body': self.body.to_dicts()} + @Body.register class Return(BodyItem): @@ -247,6 +284,9 @@ def __init__(self, values=(), parent=None): def visit(self, visitor): visitor.visit_return(self) + def to_dict(self): + return {'type': self.type, 'values': list(self.values)} + @Body.register class Continue(BodyItem): @@ -259,6 +299,9 @@ def __init__(self, parent=None): def visit(self, visitor): visitor.visit_continue(self) + def to_dict(self): + return {'type': self.type} + @Body.register class Break(BodyItem): @@ -270,3 +313,6 @@ def __init__(self, parent=None): def visit(self, visitor): visitor.visit_break(self) + + def to_dict(self): + return {'type': self.type} diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index de89a7de945..98d890dc4a8 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -181,3 +181,6 @@ def __imul__(self, other): def __rmul__(self, other): return self * other + + def to_dicts(self): + return [item.to_dict() for item in self] diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index 685d29c3f3b..72c58447f29 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -56,6 +56,14 @@ def __str__(self): parts = list(self.assign) + [self.name] + list(self.args) return ' '.join(str(p) for p in parts) + def to_dict(self): + data = {'name': self.name} + if self.args: + data['args'] = list(self.args) + if self.assign: + data['assign'] = list(self.assign) + return data + class Keywords(ItemList): """A list-like object representing keywords in a suite, a test or a keyword. diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index f20f0333593..090b37b37c0 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -35,6 +35,12 @@ def from_dict(cls, data): def from_json(cls, data): return cls.from_dict(json.loads(data)) + def to_dict(self): + raise NotImplementedError + + def to_json(self): + return json.dumps(self.to_dict()) + def config(self, **attributes): """Configure model object with given attributes. diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index dabe0d0b103..2dc395e6257 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -169,6 +169,23 @@ def visit(self, visitor): def __str__(self): return self.name + def to_dict(self): + data = {'name': self.name} + if self.doc: + data['doc'] = self.doc + if self.tags: + data['tags'] = list(self.tags) + if self.timeout: + data['timeout'] = self.timeout + if self.lineno: + data['lineno'] = self.lineno + if self.has_setup: + data['setup'] = self.setup.to_dict() + if self.has_teardown: + data['teardown'] = self.teardown.to_dict() + data['body'] = self.body.to_dicts() + return data + class TestCases(ItemList): __slots__ = [] diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index c0044f15c25..6989c4dfaec 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -265,9 +265,29 @@ def visit(self, visitor): def __str__(self): return self.name + def to_dict(self): + data = {'name': self.name} + if self.doc: + data['doc'] = self.doc + if self.metadata: + data['metadata'] = dict(self.metadata) + if self.source: + data['source'] = self.source + if self.rpa: + data['rpa'] = self.rpa + if self.has_setup: + data['setup'] = self.setup.to_dict() + if self.has_teardown: + data['teardown'] = self.teardown.to_dict() + if self.tests: + data['tests'] = self.tests.to_dicts() + if self.suites: + data['suites'] = self.suites.to_dicts() + return data + class TestSuites(ItemList): __slots__ = [] def __init__(self, suite_class=TestSuite, parent=None, suites=None): - ItemList.__init__(self, suite_class, {'parent': parent}, suites) + super().__init__(suite_class, {'parent': parent}, suites) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index d67a001dccc..682e00661a6 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -77,6 +77,12 @@ def __init__(self, name='', args=(), assign=(), type=BodyItem.KEYWORD, parent=No def source(self): return self.parent.source if self.parent is not None else None + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + return data + def run(self, context, run=True, templated=None): return KeywordRunner(context, run).run(self) @@ -86,7 +92,8 @@ class For(model.For): __slots__ = ['lineno', 'error'] body_class = Body - def __init__(self, variables, flavor, values, parent=None, lineno=None, error=None): + def __init__(self, variables=(), flavor='IN', values=(), parent=None, + lineno=None, error=None): super().__init__(variables, flavor, values, parent) self.lineno = lineno self.error = error @@ -95,6 +102,14 @@ def __init__(self, variables, flavor, values, parent=None, lineno=None, error=No def source(self): return self.parent.source if self.parent is not None else None + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + def run(self, context, run=True, templated=False): return ForRunner(context, self.flavor, run, templated).run(self) @@ -113,6 +128,14 @@ def __init__(self, condition=None, limit=None, parent=None, lineno=None, error=N def source(self): return self.parent.source if self.parent is not None else None + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + def run(self, context, run=True, templated=False): return WhileRunner(context, run, templated).run(self) @@ -129,6 +152,12 @@ def __init__(self, type=BodyItem.IF, condition=None, parent=None, lineno=None): def source(self): return self.parent.source if self.parent is not None else None + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + return data + @Body.register class If(model.If): @@ -147,6 +176,14 @@ def source(self): def run(self, context, run=True, templated=False): return IfRunner(context, run, templated).run(self) + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + class TryBranch(model.TryBranch): __slots__ = ['lineno'] @@ -161,6 +198,12 @@ def __init__(self, type=BodyItem.TRY, patterns=(), pattern_type=None, def source(self): return self.parent.source if self.parent is not None else None + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + return data + @Body.register class Try(model.Try): @@ -179,6 +222,14 @@ def source(self): def run(self, context, run=True, templated=False): return TryRunner(context, run, templated).run(self) + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + @Body.register class Return(model.Return): @@ -201,6 +252,14 @@ def run(self, context, run=True, templated=False): if not context.dry_run: raise ReturnFromKeyword(self.values) + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + @Body.register class Continue(model.Continue): @@ -223,6 +282,14 @@ def run(self, context, run=True, templated=False): if not context.dry_run: raise ContinueLoop() + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + @Body.register class Break(model.Break): @@ -245,6 +312,14 @@ def run(self, context, run=True, templated=False): if not context.dry_run: raise BreakLoop() + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + class TestCase(model.TestCase): """Represents a single executable test case. @@ -266,6 +341,12 @@ def __init__(self, name='', doc='', tags=None, timeout=None, template=None, def source(self): return self.parent.source if self.parent is not None else None + def to_dict(self): + data = super().to_dict() + if self.template: + data['template'] = self.template + return data + class TestSuite(model.TestSuite): """Represents a single executable test suite. @@ -344,7 +425,7 @@ def randomize(self, suites=True, tests=True, seed=None): self.visit(Randomizer(suites, tests, seed)) def run(self, settings=None, **options): - """Executes the suite based based the given ``settings`` or ``options``. + """Executes the suite based on the given ``settings`` or ``options``. :param settings: :class:`~robot.conf.settings.RobotSettings` object to configure test execution. diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 387f65e77c9..48470778095 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -1,25 +1,25 @@ import copy import os -from os.path import abspath, normpath, join import tempfile import unittest import warnings +from os.path import abspath, join, normpath from robot import api, model from robot.model.modelobject import ModelObject -from robot.running.model import TestSuite, TestCase, Keyword, UserKeyword from robot.running import TestSuiteBuilder -from robot.utils.asserts import (assert_equal, assert_not_equal, assert_false, +from robot.running.model import (Break, Continue, For, If, IfBranch, Keyword, Return, + TestCase, TestSuite, Try, TryBranch, UserKeyword, While) +from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, assert_raises, assert_true) - MISC_DIR = normpath(join(abspath(__file__), '..', '..', '..', 'atest', 'testdata', 'misc')) class TestModelTypes(unittest.TestCase): - def test_suite_setup(self): + def test_suite_setup_and_teardown(self): suite = TestSuite() assert_equal(type(suite.setup), Keyword) assert_equal(type(suite.teardown), Keyword) @@ -189,10 +189,10 @@ def cannot_differ(self, value1, value2): class TestLineNumberAndSource(unittest.TestCase): + source = join(MISC_DIR, 'pass_and_fail.robot') @classmethod def setUpClass(cls): - cls.source = join(MISC_DIR, 'pass_and_fail.robot') cls.suite = TestSuite.from_file_system(cls.source) def test_suite(self): @@ -220,5 +220,130 @@ def _assert_lineno_and_source(self, item, lineno): assert_equal(item.lineno, lineno) +class TestToDict(unittest.TestCase): + + def test_keyword(self): + self._verify(Keyword(), name='') + self._verify(Keyword('Name'), name='Name') + self._verify(Keyword('N', tuple('args'), ('${result}',)), + name='N', args=list('args'), assign=['${result}']) + self._verify(Keyword('Setup', type=Keyword.SETUP, lineno=1), + name='Setup', lineno=1) + + def test_for(self): + self._verify(For(), type='FOR', variables=[], flavor='IN', values=[]) + self._verify(For(['${i}'], 'IN RANGE', ['10'], lineno=2), type='FOR', + variables=['${i}'], flavor='IN RANGE', values=['10'], lineno=2) + + def test_while(self): + self._verify(While(), type='WHILE', condition=None) + self._verify(While('1 > 0', '1 min'), + type='WHILE', condition='1 > 0', limit='1 min') + self._verify(While('True', lineno=3, error='x'), + type='WHILE', condition='True', lineno=3, error='x') + + def test_if(self): + self._verify(If(), type='IF/ELSE ROOT', body=[]) + self._verify(If(lineno=4, error='E'), + type='IF/ELSE ROOT', body=[], lineno=4, error='E') + + def test_if_branch(self): + self._verify(IfBranch(), type='IF', condition=None, body=[]) + self._verify(IfBranch(If.ELSE_IF, '1 > 0'), + type='ELSE IF', condition='1 > 0', body=[]) + self._verify(IfBranch(If.ELSE, lineno=5), + type='ELSE', body=[], lineno=5) + + def test_if_structure(self): + root = If() + root.body.create_branch(If.IF, '$c').body.create_keyword('K1') + root.body.create_branch(If.ELSE).body.create_keyword('K2', ['a']) + self._verify(root, + type='IF/ELSE ROOT', + body=[{'type': 'IF', 'condition': '$c', 'body': [{'name': 'K1'}]}, + {'type': 'ELSE', 'body': [{'name': 'K2', 'args': ['a']}]}]) + + def test_try(self): + self._verify(Try(), type='TRY/EXCEPT ROOT', body=[]) + self._verify(Try(lineno=6, error='E'), + type='TRY/EXCEPT ROOT', body=[], lineno=6, error='E') + + def test_try_branch(self): + self._verify(TryBranch(), type='TRY', body=[]) + self._verify(TryBranch(Try.EXCEPT), type='EXCEPT', patterns=[], body=[]) + self._verify(TryBranch(Try.EXCEPT, ['Pa*'], 'glob', '${err}'), type='EXCEPT', + patterns=['Pa*'], pattern_type='glob', variable='${err}', body=[]) + self._verify(TryBranch(Try.ELSE, lineno=7), type='ELSE', body=[], lineno=7) + self._verify(TryBranch(Try.FINALLY, lineno=8), type='FINALLY', body=[], lineno=8) + + def test_try_structure(self): + root = Try() + root.body.create_branch(Try.TRY).body.create_keyword('K1') + root.body.create_branch(Try.EXCEPT).body.create_keyword('K2') + root.body.create_branch(Try.ELSE).body.create_keyword('K3') + root.body.create_branch(Try.FINALLY).body.create_keyword('K4') + self._verify(root, + type='TRY/EXCEPT ROOT', + body=[{'type': 'TRY', 'body': [{'name': 'K1'}]}, + {'type': 'EXCEPT', 'patterns': [], 'body': [{'name': 'K2'}]}, + {'type': 'ELSE', 'body': [{'name': 'K3'}]}, + {'type': 'FINALLY', 'body': [{'name': 'K4'}]}]) + + def test_return_continue_break(self): + self._verify(Return(), type='RETURN', values=[]) + self._verify(Return(('x', 'y'), lineno=9, error='E'), + type='RETURN', values=['x', 'y'], lineno=9, error='E') + self._verify(Continue(), type='CONTINUE') + self._verify(Continue(lineno=10, error='E'), + type='CONTINUE', lineno=10, error='E') + self._verify(Break(), type='BREAK') + self._verify(Break(lineno=11, error='E'), + type='BREAK', lineno=11, error='E') + + def test_test(self): + self._verify(TestCase(), name='', body=[]) + self._verify(TestCase('N', 'D', 'T', '1s', lineno=12), + name='N', doc='D', tags=['T'], timeout='1s', lineno=12, body=[]) + self._verify(TestCase(template='K'), name='', body=[], template='K') + + def test_test_structure(self): + test = TestCase('TC') + test.setup.config(name='Setup') + test.teardown.config(name='Teardown', args='a') + test.body.create_keyword('K1') + test.body.create_if().body.create_branch().body.create_keyword('K2') + self._verify(test, + name='TC', + setup={'name': 'Setup'}, + teardown={'name': 'Teardown', 'args': ['a']}, + body=[{'name': 'K1'}, + {'type': 'IF/ELSE ROOT', + 'body': [{'type': 'IF', 'condition': None, + 'body': [{'name': 'K2'}]}]}]) + + def test_suite(self): + self._verify(TestSuite(), name='') + self._verify(TestSuite('N', 'D', {'M': 'V'}, 'x', rpa=True), + name='N', doc='D', metadata={'M': 'V'}, source='x', rpa=True) + + def test_suite_structure(self): + suite = TestSuite('Root') + suite.setup.config(name='Setup') + suite.teardown.config(name='Teardown', args='a') + suite.tests.create('T1').body.create_keyword('K') + suite.suites.create('Child').tests.create('T2') + self._verify(suite, + name='Root', + setup={'name': 'Setup'}, + teardown={'name': 'Teardown', 'args': ['a']}, + tests=[{'name': 'T1', 'body': [{'name': 'K'}]}], + suites=[{'name': 'Child', + 'tests': [{'name': 'T2', 'body': []}]}]) + + def _verify(self, obj, **expected): + assert_equal(obj.to_dict(), expected) + assert_equal(list(obj.to_dict()), list(expected)) + + if __name__ == '__main__': unittest.main() From 40e622c624a3f1c88acfdc368e86325898b979b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 9 Jan 2023 18:50:25 +0200 Subject: [PATCH 0324/1592] Fix from_json with control structures. #3902 Also add tests for to/from_json roundtrip. --- src/robot/model/body.py | 18 ++++++++++++++---- src/robot/model/fixture.py | 19 ++++++++++--------- src/robot/model/modelobject.py | 12 ++++++++++-- utest/model/test_fixture.py | 7 +++++-- utest/model/test_modelobject.py | 10 +++------- utest/running/test_run_model.py | 19 +++++++++++++------ 6 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 02efa1ee7b8..f9e5b353596 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -85,9 +85,9 @@ class BaseBody(ItemList): # Set using 'BaseBody.register' when these classes are created. keyword_class = None for_class = None + while_class = None if_class = None try_class = None - while_class = None return_class = None continue_class = None break_class = None @@ -97,9 +97,16 @@ def __init__(self, parent=None, items=None): super().__init__(BodyItem, {'parent': parent}, items) def _item_from_dict(self, data): - # FIXME: This doesn't work with all objects! - class_name = data.get('type', BodyItem.KEYWORD).lower() + '_class' - return getattr(self, class_name).from_dict(data) + item_type = data.get('type', None) + if not item_type: + item_class = self.keyword_class + elif item_type == BodyItem.IF_ELSE_ROOT: + item_class = self.if_class + elif item_type == BodyItem.TRY_EXCEPT_ROOT: + item_class = self.try_class + else: + item_class = getattr(self, item_type.lower() + '_class') + return item_class.from_dict(data) @classmethod def register(cls, item_class): @@ -225,5 +232,8 @@ def __init__(self, branch_class, parent=None, items=None): self.branch_class = branch_class super().__init__(parent, items) + def _item_from_dict(self, data): + return self.branch_class.from_dict(data) + def create_branch(self, *args, **kwargs): return self.append(self.branch_class(*args, **kwargs)) diff --git a/src/robot/model/fixture.py b/src/robot/model/fixture.py index 32535383c94..a401033412a 100644 --- a/src/robot/model/fixture.py +++ b/src/robot/model/fixture.py @@ -13,15 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -def create_fixture(fixture, parent, type): +from collections.abc import Mapping + + +def create_fixture(fixture, parent, fixture_type): # TestCase and TestSuite have 'fixture_class', Keyword doesn't. fixture_class = getattr(parent, 'fixture_class', parent.__class__) + if isinstance(fixture, fixture_class): + return fixture.config(parent=parent, type=fixture_type) + if isinstance(fixture, Mapping): + return fixture_class.from_dict(fixture).config(parent=parent, type=fixture_type) if fixture is None: - fixture = fixture_class(None, parent=parent, type=type) - elif isinstance(fixture, fixture_class): - fixture.parent = parent - fixture.type = type - else: - raise TypeError("Only %s objects accepted, got %s." - % (fixture_class.__name__, fixture.__class__.__name__)) - return fixture + return fixture_class(None, parent=parent, type=fixture_type) + raise TypeError(f"Invalid fixture type '{type(fixture).__name__}'.") diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index 090b37b37c0..169073b86d6 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -29,7 +29,7 @@ def from_dict(cls, data): return cls().config(**data) except AttributeError as err: raise ValueError(f"Creating '{full_name(cls)}' object from dictionary " - f"failed: {err}\nDictionary:\n{data}") + f"failed: {err}") @classmethod def from_json(cls, data): @@ -50,7 +50,15 @@ def config(self, **attributes): New in Robot Framework 4.0. """ for name in attributes: - setattr(self, name, attributes[name]) + try: + setattr(self, name, attributes[name]) + except AttributeError: + # Ignore error setting attribute if the object already has it. + # Avoids problems with `to/from_dict` roundtrip with body items + # having unsettable `type` attribute that is needed in dict data. + if getattr(self, name, object()) == attributes[name]: + continue + raise AttributeError return self def copy(self, **attributes): diff --git a/utest/model/test_fixture.py b/utest/model/test_fixture.py index 483aa4cc5dc..91e15b2f88e 100644 --- a/utest/model/test_fixture.py +++ b/utest/model/test_fixture.py @@ -1,6 +1,6 @@ import unittest -from robot.utils.asserts import assert_equal, assert_raises +from robot.utils.asserts import assert_equal, assert_raises_with_msg from robot.model import TestSuite, Keyword from robot.model.fixture import create_fixture @@ -21,7 +21,10 @@ def test_sets_parent_and_type_correctly(self): def test_raises_type_error_when_wrong_fixture_type(self): suite = TestSuite() wrong_kw = object() - assert_raises(TypeError, create_fixture, wrong_kw, suite, Keyword.TEARDOWN) + assert_raises_with_msg( + TypeError, "Invalid fixture type 'object'.", + create_fixture, wrong_kw, suite, Keyword.TEARDOWN + ) def _assert_fixture(self, fixture, exp_parent, exp_type, exp_class=TestSuite.fixture_class): diff --git a/utest/model/test_modelobject.py b/utest/model/test_modelobject.py index 26206bf1517..75eba1ce6dd 100644 --- a/utest/model/test_modelobject.py +++ b/utest/model/test_modelobject.py @@ -1,4 +1,3 @@ -import re import unittest from robot.model.modelobject import ModelObject @@ -49,12 +48,9 @@ def test_not_accepted_attribute(self): class X(ModelObject): __slots__ = ['a'] assert_equal(X.from_dict({'a': 1}).a, 1) - err = assert_raises(ValueError, X.from_dict, {'b': 'bad'}) - expected = (f"Creating '{__name__}.X' object from dictionary failed: .*\n" - f"Dictionary:\n{{'b': 'bad'}}") - if not re.fullmatch(expected, str(err)): - raise AssertionError(f'Unexpected error message. Expected:\n{expected}\n\n' - f'Actual:\n{err}') + error = assert_raises(ValueError, X.from_dict, {'b': 'bad'}) + assert_equal(str(error).split(':')[0], + f"Creating '{__name__}.X' object from dictionary failed") if __name__ == '__main__': diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 48470778095..ef50f71353b 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -7,7 +7,6 @@ from robot import api, model from robot.model.modelobject import ModelObject -from robot.running import TestSuiteBuilder from robot.running.model import (Break, Continue, For, If, IfBranch, Keyword, Return, TestCase, TestSuite, Try, TryBranch, UserKeyword, While) from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, @@ -129,8 +128,9 @@ def _verify_suite(self, suite, name='Test Run Model', rpa=False): class TestCopy(unittest.TestCase): - def setUp(self): - self.suite = TestSuiteBuilder().build(MISC_DIR) + @classmethod + def setUpClass(cls): + cls.suite = TestSuite.from_file_system(MISC_DIR) def test_copy(self): self.assert_copy(self.suite, self.suite.copy()) @@ -220,7 +220,7 @@ def _assert_lineno_and_source(self, item, lineno): assert_equal(item.lineno, lineno) -class TestToDict(unittest.TestCase): +class TestToFromDict(unittest.TestCase): def test_keyword(self): self._verify(Keyword(), name='') @@ -340,9 +340,16 @@ def test_suite_structure(self): suites=[{'name': 'Child', 'tests': [{'name': 'T2', 'body': []}]}]) + def test_bigger_suite_structure(self): + suite = TestSuite.from_file_system(MISC_DIR) + self._verify(suite, **suite.to_dict()) + def _verify(self, obj, **expected): - assert_equal(obj.to_dict(), expected) - assert_equal(list(obj.to_dict()), list(expected)) + data = obj.to_dict() + assert_equal(data, expected) + assert_equal(list(data), list(expected)) + roundtrip = type(obj).from_dict(data).to_dict() + assert_equal(roundtrip, expected) if __name__ == '__main__': From 63162438bd492b89557b305813462f33f4a65b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sat, 7 Jan 2023 16:11:06 +0200 Subject: [PATCH 0325/1592] parsing: suppport __init__ file for multisoure suite Fixes #4015 --- atest/robot/cli/runner/multisource.robot | 18 ++++++++ .../CreatingTestData/CreatingTestSuites.rst | 5 +++ .../src/ExecutingTestCases/BasicUsage.rst | 8 ++++ src/robot/parsing/suitestructure.py | 42 ++++++++++++++++++- src/robot/running/builder/builders.py | 7 +++- src/robot/running/builder/parsers.py | 37 ++++------------ 6 files changed, 84 insertions(+), 33 deletions(-) diff --git a/atest/robot/cli/runner/multisource.robot b/atest/robot/cli/runner/multisource.robot index 2f06a21617a..86fa75c373d 100644 --- a/atest/robot/cli/runner/multisource.robot +++ b/atest/robot/cli/runner/multisource.robot @@ -45,6 +45,24 @@ Wildcards Should Contain Tests ${SUITE.suites[2]} Suite3 First Check Names ${SUITE.suites[2].tests[0]} Suite3 First Tsuite1 & Tsuite2 & Tsuite3.Tsuite3. +With Init File Included + Run Tests ${EMPTY} misc/suites/tsuite1.robot misc/suites/tsuite2.robot misc/suites/__init__.robot + Check Names ${SUITE} Tsuite1 & Tsuite2 + Should Contain Suites ${SUITE} Tsuite1 Tsuite2 + Check Keyword Data ${SUITE.teardown} BuiltIn.Log args=\${SUITE_TEARDOWN_ARG} type=TEARDOWN + Check Names ${SUITE.suites[0]} Tsuite1 Tsuite1 & Tsuite2. + Should Contain Tests ${SUITE.suites[0]} Suite1 First Suite1 Second Third In Suite1 + Check Names ${SUITE.suites[0].tests[0]} Suite1 First Tsuite1 & Tsuite2.Tsuite1. + Check Names ${SUITE.suites[0].tests[1]} Suite1 Second Tsuite1 & Tsuite2.Tsuite1. + Check Names ${SUITE.suites[0].tests[2]} Third In Suite1 Tsuite1 & Tsuite2.Tsuite1. + Check Names ${SUITE.suites[1]} Tsuite2 Tsuite1 & Tsuite2. + Should Contain Tests ${SUITE.suites[1]} Suite2 First + Check Names ${SUITE.suites[1].tests[0]} Suite2 First Tsuite1 & Tsuite2.Tsuite2. + +Multiple Init Files Not Allowed + Run Tests Without Processing Output ${EMPTY} misc/suites/tsuite1.robot misc/suites/__init__.robot misc/suites/__init__.robot + Stderr Should Contain [ ERROR ] Multiple init files not allowed. + Failure When Parsing Any Data Source Fails Run Tests Without Processing Output ${EMPTY} nönex misc/pass_and_fail.robot ${nönex} = Normalize Path ${DATADIR}/nönex diff --git a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst index be456179979..b60fb249980 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst @@ -72,6 +72,10 @@ file formats`_ (typically :file:`__init__.robot`). The name format is borrowed from Python, where files named in this manner denote that a directory is a module. +Starting from Robot Framework 6.1, it is also possible to define a suite +initialization file for automatically created suite when starting the test +execution by giving multiple paths__. + Initialization files have the same structure and syntax as test case files, except that they cannot have test case sections and not all settings are supported. Variables and keywords created or imported in initialization files @@ -119,6 +123,7 @@ initialization files is explained below. Some Keyword ${arg} Another Keyword +__ `Specifying test data to be executed`_ __ `Test case related settings in the Setting section`_ Test suite name and documentation diff --git a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst index b7c7dd7e282..52129db468c 100644 --- a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst +++ b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst @@ -80,11 +80,19 @@ example below:: robot my_tests.robot your_tests.robot robot --name Example path/to/tests/pattern_*.robot +Starting from Robot Framework 6.1, it is also possible to define a +`test suite initialisation file`__ for the automatically created top-level +suite. The path to the init file is given similarly to the +test case files:: + + robot __init__.robot my_tests.robot other_tests.robot + __ `Test case files`_ __ `Test suite directories`_ __ `Setting the name`_ __ `Test suite name and documentation`_ __ `Test suite directories`_ +__ `Suite initialization files`_ Using command line options -------------------------- diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index f422855e1e5..85cca0682c6 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -25,6 +25,7 @@ class SuiteStructure: def __init__(self, source=None, init_file=None, children=None): self.source = source + self.name = self._format_name(source) self.init_file = init_file self.children = children self.extension = self._get_extension(source, init_file) @@ -45,6 +46,27 @@ def visit(self, visitor): else: visitor.visit_directory(self) + def _format_name(self, source): + def strip_possible_prefix_from_name(name): + result = name.split('__', 1)[-1] + if result: + return result + return name + + def format_name(name): + name = strip_possible_prefix_from_name(name) + name = name.replace('_', ' ').strip() + return name.title() if name.islower() else name + + if source is None: + return None + if os.path.isdir(source): + basename = os.path.basename(source) + else: + basename = os.path.splitext(os.path.basename(source))[0] + return format_name(basename) + + class SuiteStructureBuilder: ignored_prefixes = ('_', '.') @@ -58,8 +80,22 @@ def build(self, paths): paths = list(self._normalize_paths(paths)) if len(paths) == 1: return self._build(paths[0], self.included_suites) - children = [self._build(p, self.included_suites) for p in paths] - return SuiteStructure(children=children) + sources, init_file = self._get_sources(paths) + return SuiteStructure(children=sources, init_file=init_file) + + def _get_sources(self, paths): + init_file = None + sources = [] + for p in paths: + base, ext = os.path.splitext(os.path.basename(p)) + ext = ext[1:].lower() + if self._is_init_file(p, base, ext): + if init_file: + raise DataError("Multiple init files not allowed.") + init_file = p + else: + sources.append(self._build(p, self.included_suites)) + return sources, init_file def _normalize_paths(self, paths): if not paths: @@ -171,3 +207,5 @@ def start_directory(self, structure): def end_directory(self, structure): pass + + diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index cdd696432bf..bd84aeccf4d 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -169,9 +169,12 @@ def _build_suite(self, structure): parser = self._get_parser(structure.extension) try: if structure.is_directory: - suite = parser.parse_init_file(structure.init_file or source, defaults) + suite = parser.parse_init_file(structure.init_file or source, + structure.name, defaults) + if structure.source is None: + suite.name = None else: - suite = parser.parse_suite_file(source, defaults) + suite = parser.parse_suite_file(source, structure.name, defaults) if not suite.tests: LOGGER.info(f"Data source '{source}' has no tests or tasks.") self._validate_execution_mode(suite) diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index b50856ac062..fb3c0b14d14 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -28,10 +28,10 @@ class BaseParser: - def parse_init_file(self, source, defaults=None): + def parse_init_file(self, source, name, defaults=None): raise NotImplementedError - def parse_suite_file(self, source, defaults=None): + def parse_suite_file(self, source, name, defaults=None): raise NotImplementedError def parse_resource_file(self, source): @@ -44,13 +44,13 @@ def __init__(self, lang=None, process_curdir=True): self.lang = lang self.process_curdir = process_curdir - def parse_init_file(self, source, defaults=None): + def parse_init_file(self, source, name, defaults=None): directory = os.path.dirname(source) - suite = TestSuite(name=format_name(directory), source=directory) + suite = TestSuite(name=name, source=directory) return self._build(suite, source, defaults, get_model=get_init_model) - def parse_suite_file(self, source, defaults=None): - suite = TestSuite(name=format_name(source), source=source) + def parse_suite_file(self, source, name, defaults=None): + suite = TestSuite(name=name, source=source) return self._build(suite, source, defaults) def build_suite(self, model, name=None, defaults=None): @@ -104,29 +104,8 @@ def _get_source(self, source): class NoInitFileDirectoryParser(BaseParser): - def parse_init_file(self, source, defaults=None): - return TestSuite(name=format_name(source), source=source) - - -def format_name(source): - def strip_possible_prefix_from_name(name): - result = name.split('__', 1)[-1] - if result: - return result - return name - - def format_name(name): - name = strip_possible_prefix_from_name(name) - name = name.replace('_', ' ').strip() - return name.title() if name.islower() else name - - if source is None: - return None - if os.path.isdir(source): - basename = os.path.basename(source) - else: - basename = os.path.splitext(os.path.basename(source))[0] - return format_name(basename) + def parse_init_file(self, source, name=None, defaults=None): + return TestSuite(name=name, source=source) class ErrorReporter(NodeVisitor): From d81386e1e519789850a6b1b4458c91631919abfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 10 Jan 2023 14:11:22 +0200 Subject: [PATCH 0326/1592] JSON serialization support to resource files. This required some changes to the resource file model: - Add ModelObject base class to ResourceFile, UserKeyword, Import and Variable model objects. This adds generic JSON serialization methods and brings some nice features like better repr() as a bonus. - Change import types from 'Library', 'Resource' and 'Variables' to 'LIBRARY', 'RESOURCE' and 'VARIABLES', respectively. We've used upper case types also elsewhere. Also add matching class attributes to allow using constants instead of stringly typing. - Fix code expecting old style type names and clean up related code in general. Part of #3902. --- src/robot/model/__init__.py | 1 + src/robot/running/builder/transformers.py | 24 +-- src/robot/running/model.py | 202 ++++++++++++++++++---- src/robot/running/namespace.py | 24 +-- src/robot/utils/robotpath.py | 6 +- utest/running/test_builder.py | 2 +- utest/running/test_imports.py | 34 ++-- utest/running/test_run_model.py | 66 ++++++- 8 files changed, 275 insertions(+), 84 deletions(-) diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index d2ebfed327b..8e6c07fa979 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -32,6 +32,7 @@ from .itemlist import ItemList from .keyword import Keyword, Keywords from .message import Message, Messages +from .modelobject import ModelObject from .modifier import ModelModifier from .namepatterns import SuiteNamePatterns, TestNamePatterns from .statistics import Statistics diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 26bc90d82de..613c0a6b462 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -66,18 +66,14 @@ def visit_KeywordTags(self, node): def visit_TestTemplate(self, node): self.defaults.template = node.value - def visit_ResourceImport(self, node): - self.suite.resource.imports.create(type='Resource', name=node.name, - lineno=node.lineno) - def visit_LibraryImport(self, node): - self.suite.resource.imports.create(type='Library', name=node.name, - args=node.args, alias=node.alias, - lineno=node.lineno) + self.suite.resource.imports.library(node.name, node.args, node.alias, node.lineno) + + def visit_ResourceImport(self, node): + self.suite.resource.imports.resource(node.name, node.lineno) def visit_VariablesImport(self, node): - self.suite.resource.imports.create(type='Variables', name=node.name, - args=node.args, lineno=node.lineno) + self.suite.resource.imports.variables(node.name, node.args, node.lineno) def visit_VariableSection(self, node): pass @@ -124,17 +120,13 @@ def visit_KeywordTags(self, node): self.defaults.keyword_tags = node.values def visit_LibraryImport(self, node): - self.resource.imports.create(type='Library', name=node.name, - args=node.args, alias=node.alias, - lineno=node.lineno) + self.resource.imports.library(node.name, node.args, node.alias, node.lineno) def visit_ResourceImport(self, node): - self.resource.imports.create(type='Resource', name=node.name, - lineno=node.lineno) + self.resource.imports.resource(node.name, node.lineno) def visit_VariablesImport(self, node): - self.resource.imports.create(type='Variables', name=node.name, - args=node.args, lineno=node.lineno) + self.resource.imports.variables(node.name, node.args, node.lineno) def visit_Variable(self, node): self.resource.variables.create(name=node.name, diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 682e00661a6..84e849b7010 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -37,12 +37,12 @@ from robot import model from robot.conf import RobotSettings -from robot.errors import BreakLoop, ContinueLoop, ReturnFromKeyword, DataError -from robot.model import Keywords, BodyItem +from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword +from robot.model import BodyItem, create_fixture, Keywords, ModelObject from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, Return as ReturnResult) -from robot.utils import seq2str, setter +from robot.utils import setter from .bodyrunner import ForRunner, IfRunner, KeywordRunner, TryRunner, WhileRunner from .randomizer import Randomizer @@ -353,7 +353,7 @@ class TestSuite(model.TestSuite): See the base class for documentation of attributes not documented here. """ - __slots__ = ['resource'] + __slots__ = [] test_class = TestCase #: Internal usage only. fixture_class = Keyword #: Internal usage only. @@ -362,7 +362,13 @@ def __init__(self, name='', doc='', metadata=None, source=None, rpa=None): #: :class:`ResourceFile` instance containing imports, variables and #: keywords the suite owns. When data is parsed from the file system, #: this data comes from the same test case file that creates the suite. - self.resource = ResourceFile(source=source) + self.resource = ResourceFile(source) + + @setter + def resource(self, resource): + if isinstance(resource, dict): + resource = ResourceFile.from_dict(resource) + return resource @classmethod def from_file_system(cls, *paths, **config): @@ -495,48 +501,90 @@ def run(self, settings=None, **options): output.close(runner.result) return runner.result + def to_dict(self): + data = super().to_dict() + data['resource'] = self.resource.to_dict() + return data + -class Variable: +class Variable(ModelObject): + repr_args = ('name', 'value') - def __init__(self, name, value, source=None, lineno=None, error=None): + def __init__(self, name, value, parent=None, lineno=None, error=None): self.name = name self.value = value - self.source = source + self.parent = parent self.lineno = lineno self.error = error + @property + def source(self): + return self.parent.source if self.parent is not None else None + def report_invalid_syntax(self, message, level='ERROR'): source = self.source or '<unknown>' line = f' on line {self.lineno}' if self.lineno else '' LOGGER.write(f"Error in file '{source}'{line}: " f"Setting variable '{self.name}' failed: {message}", level) + @classmethod + def from_dict(cls, data): + return cls(**data) -class ResourceFile: + def to_dict(self): + data = {'name': self.name, 'value': self.value} + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data - def __init__(self, doc='', source=None): - self.doc = doc + +class ResourceFile(ModelObject): + repr_args = ('source',) + __slots__ = ('source', 'doc') + + def __init__(self, source=None, doc=''): self.source = source + self.doc = doc self.imports = [] - self.keywords = [] self.variables = [] + self.keywords = [] @setter def imports(self, imports): - return Imports(self.source, imports) + return Imports(self, imports) + + @setter + def variables(self, variables): + return model.ItemList(Variable, {'parent': self}, items=variables) @setter def keywords(self, keywords): return model.ItemList(UserKeyword, {'parent': self}, items=keywords) - @setter - def variables(self, variables): - return model.ItemList(Variable, {'source': self.source}, items=variables) + def to_dict(self): + data = {} + if self.source: + data['source'] = self.source + if self.doc: + data['doc'] = self.doc + if self.imports: + data['imports'] = self.imports.to_dicts() + if self.variables: + data['variables'] = self.variables.to_dicts() + if self.keywords: + data['keywords'] = self.keywords.to_dicts() + return data -class UserKeyword: +class UserKeyword(ModelObject): + repr_args = ('name', 'args') + fixture_class = Keyword + __slots__ = ['name', 'args', 'doc', 'return_', 'timeout', 'lineno', 'parent', + 'error', '_teardown'] - def __init__(self, name, args=(), doc='', tags=(), return_=None, + def __init__(self, name='', args=(), doc='', tags=(), return_=None, timeout=None, lineno=None, parent=None, error=None): self.name = name self.args = args @@ -573,9 +621,26 @@ def keywords(self, keywords): @property def teardown(self): if self._teardown is None: - self._teardown = Keyword(None, parent=self, type=Keyword.TEARDOWN) + self._teardown = create_fixture(None, self, Keyword.TEARDOWN) return self._teardown + @teardown.setter + def teardown(self, teardown): + self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) + + @property + def has_teardown(self): + """Check does a keyword have a teardown without creating a teardown object. + + A difference between using ``if uk.has_teardown:`` and ``if uk.teardown:`` + is that accessing the :attr:`teardown` attribute creates a :class:`Keyword` + object representing the teardown even when the user keyword actually does + not have one. This can have an effect on memory usage. + + New in Robot Framework 6.1. + """ + return bool(self._teardown) + @setter def tags(self, tags): return model.Tags(tags) @@ -584,21 +649,53 @@ def tags(self, tags): def source(self): return self.parent.source if self.parent is not None else None + def to_dict(self): + data = {'name': self.name} + if self.args: + data['args'] = list(self.args) + if self.doc: + data['doc'] = self.doc + if self.tags: + data['tags'] = list(self.tags) + if self.return_: + data['return_'] = self.return_ + if self.timeout: + data['timeout'] = self.timeout + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + data['body'] = self.body.to_dicts() + if self.has_teardown: + data['teardown'] = self.teardown.to_dict() + return data + -class Import: - ALLOWED_TYPES = ('Library', 'Resource', 'Variables') +class Import(ModelObject): + repr_args = ('type', 'name', 'args', 'alias') + LIBRARY = 'LIBRARY' + RESOURCE = 'RESOURCE' + VARIABLES = 'VARIABLES' - def __init__(self, type, name, args=(), alias=None, source=None, lineno=None): - if type not in self.ALLOWED_TYPES: - raise ValueError(f"Invalid import type '{type}'. Should be one of " - f"{seq2str(self.ALLOWED_TYPES, lastsep=' or ')}.") + def __init__(self, type, name, args=(), alias=None, parent=None, lineno=None): + if type not in (self.LIBRARY, self.RESOURCE, self.VARIABLES): + raise ValueError(f"Invalid import type: Expected '{self.LIBRARY}', " + f"'{self.RESOURCE}' or '{self.VARIABLES}', got '{type}'.") self.type = type self.name = name self.args = args self.alias = alias - self.source = source + self.parent = parent self.lineno = lineno + def _repr(self, repr_args): + repr_args = [a for a in repr_args if a in ('type', 'name') or getattr(self, a)] + return super()._repr(repr_args) + + @property + def source(self): + return self.parent.source if self.parent is not None else None + @property def directory(self): if not self.source: @@ -607,22 +704,61 @@ def directory(self): return self.source return os.path.dirname(self.source) + @property + def setting_name(self): + return self.type.title() + + def select(self, library, resource, variables): + return {self.LIBRARY: library, + self.RESOURCE: resource, + self.VARIABLES: variables}[self.type] + def report_invalid_syntax(self, message, level='ERROR'): source = self.source or '<unknown>' line = f' on line {self.lineno}' if self.lineno else '' LOGGER.write(f"Error in file '{source}'{line}: {message}", level) + @classmethod + def from_dict(cls, data): + return cls(**data) + + def to_dict(self): + data = {'type': self.type, 'name': self.name} + if self.args: + data['args'] = list(self.args) + if self.alias: + data['alias'] = self.alias + if self.lineno: + data['lineno'] = self.lineno + return data + class Imports(model.ItemList): - def __init__(self, source, imports=None): - super().__init__(Import, {'source': source}, items=imports) + def __init__(self, parent, imports=None): + super().__init__(Import, {'parent': parent}, items=imports) def library(self, name, args=(), alias=None, lineno=None): - self.create('Library', name, args, alias, lineno) + """Create library import.""" + self.create(Import.LIBRARY, name, args, alias, lineno=lineno) + + def resource(self, name, lineno=None): + """Create resource import.""" + self.create(Import.RESOURCE, name, lineno=lineno) - def resource(self, path, lineno=None): - self.create('Resource', path, lineno) + def variables(self, name, args=(), lineno=None): + """Create variables import.""" + self.create(Import.VARIABLES, name, args, lineno=lineno) - def variables(self, path, args=(), lineno=None): - self.create('Variables', path, args, lineno) + def create(self, *args, **kwargs): + """Generic method for creating imports. + + Import type specific methods :meth:`library`, :meth:`resource` and + :meth:`variables` are recommended over this method. + """ + # RF 6.1 changed types to upper case. Code below adds backwards compatibility. + if args: + args = (args[0].upper(),) + args[1:] + elif 'type' in kwargs: + kwargs['type'] = kwargs['type'].upper() + return super().create(*args, **kwargs) diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 83a9348323f..8b9b3cb55fa 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -65,19 +65,19 @@ def _handle_imports(self, import_settings): for item in import_settings: try: if not item.name: - raise DataError(f'{item.type} setting requires value.') + raise DataError(f'{item.setting_name} setting requires value.') self._import(item) except DataError as err: item.report_invalid_syntax(err.message) def _import(self, import_setting): - action = {'Library': self._import_library, - 'Resource': self._import_resource, - 'Variables': self._import_variables}[import_setting.type] + action = import_setting.select(self._import_library, + self._import_resource, + self._import_variables) action(import_setting) def import_resource(self, name, overwrite=True): - self._import_resource(Import('Resource', name), overwrite=overwrite) + self._import_resource(Import(Import.RESOURCE, name), overwrite=overwrite) def _import_resource(self, import_setting, overwrite=False): path = self._resolve_name(import_setting) @@ -102,7 +102,7 @@ def _validate_not_importing_init_file(self, path): f"a resource file.") def import_variables(self, name, args, overwrite=False): - self._import_variables(Import('Variables', name, args), overwrite) + self._import_variables(Import(Import.VARIABLES, name, args), overwrite) def _import_variables(self, import_setting, overwrite=False): path = self._resolve_name(import_setting) @@ -121,8 +121,7 @@ def _import_variables(self, import_setting, overwrite=False): 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('Library', name, args, alias), - notify=notify) + self._import_library(Import(Import.LIBRARY, name, args, alias), notify=notify) def _import_library(self, import_setting, notify=True): name = self._resolve_name(import_setting) @@ -150,17 +149,18 @@ def _resolve_name(self, setting): except DataError as err: self._raise_replacing_vars_failed(setting, err) if self._is_import_by_path(setting.type, name): - return find_file(name, setting.directory, file_type=setting.type) + file_type = setting.select('Library', 'Resource file', 'Variable file') + return find_file(name, setting.directory, file_type=file_type) return name def _raise_replacing_vars_failed(self, setting, error): - raise DataError(f"Replacing variables from setting '{setting.type}' " + raise DataError(f"Replacing variables from setting '{setting.setting_name}' " f"failed: {error}") def _is_import_by_path(self, import_type, path): - if import_type == 'Library': + if import_type == Import.LIBRARY: return path.lower().endswith(self._library_import_by_path_ends) - if import_type == 'Variables': + if import_type == Import.VARIABLES: return path.lower().endswith(self._variables_import_by_path_ends) return True diff --git a/src/robot/utils/robotpath.py b/src/robot/utils/robotpath.py index 1b45f0a9abb..5c10ef580cf 100644 --- a/src/robot/utils/robotpath.py +++ b/src/robot/utils/robotpath.py @@ -135,11 +135,7 @@ def find_file(path, basedir='.', file_type=None): ret = _find_relative_path(path, basedir) if ret: return ret - default = file_type or 'File' - file_type = {'Library': 'Library', - 'Variables': 'Variable file', - 'Resource': 'Resource file'}.get(file_type, default) - raise DataError("%s '%s' does not exist." % (file_type, path)) + raise DataError(f"{file_type or 'File'} '{path}' does not exist.") def _find_absolute_path(path): diff --git a/utest/running/test_builder.py b/utest/running/test_builder.py index 0a7956ce486..8d7f6d14c04 100644 --- a/utest/running/test_builder.py +++ b/utest/running/test_builder.py @@ -35,7 +35,7 @@ def test_suite_data(self): def test_imports(self): imp = build('dummy_lib_test.robot').resource.imports[0] - assert_equal(imp.type, 'Library') + assert_equal(imp.type, 'LIBRARY') assert_equal(imp.name, 'DummyLib') assert_equal(imp.args, ()) diff --git a/utest/running/test_imports.py b/utest/running/test_imports.py index 9e42e1abc5d..7defc927f2f 100644 --- a/utest/running/test_imports.py +++ b/utest/running/test_imports.py @@ -1,7 +1,7 @@ from io import StringIO import unittest -from robot.running import TestSuite +from robot.running.model import TestSuite, Import from robot.utils.asserts import assert_equal, assert_raises_with_msg @@ -27,16 +27,20 @@ def assert_test(test, name, status, tags=(), msg=''): class TestImports(unittest.TestCase): - def test_imports(self): + def test_create(self): suite = TestSuite(name='Suite') suite.resource.imports.create('Library', 'OperatingSystem') - suite.tests.create(name='Test').body.create_keyword('Directory Should Exist', - args=['.']) + suite.resource.imports.create('RESOURCE', 'test_resource.txt') + suite.resource.imports.create(type='LibRary', name='String') + test = suite.tests.create(name='Test') + test.body.create_keyword('Directory Should Exist', args=['.']) + test.body.create_keyword('My Test Keyword') + test.body.create_keyword('Convert To Lower Case', args=['ROBOT']) result = run(suite) assert_suite(result, 'Suite', 'PASS') assert_test(result.tests[0], 'Test', 'PASS') - def test_library_imports(self): + def test_library(self): suite = TestSuite(name='Suite') suite.resource.imports.library('OperatingSystem') suite.tests.create(name='Test').body.create_keyword('Directory Should Exist', @@ -45,7 +49,7 @@ def test_library_imports(self): assert_suite(result, 'Suite', 'PASS') assert_test(result.tests[0], 'Test', 'PASS') - def test_resource_imports(self): + def test_resource(self): suite = TestSuite(name='Suite') suite.resource.imports.resource('test_resource.txt') suite.tests.create(name='Test').body.create_keyword('My Test Keyword') @@ -54,7 +58,7 @@ def test_resource_imports(self): assert_suite(result, 'Suite', 'PASS') assert_test(result.tests[0], 'Test', 'PASS') - def test_variable_imports(self): + def test_variables(self): suite = TestSuite(name='Suite') suite.resource.imports.variables('variables_file.py') suite.tests.create(name='Test').body.create_keyword( @@ -65,13 +69,23 @@ def test_variable_imports(self): assert_suite(result, 'Suite', 'PASS') assert_test(result.tests[0], 'Test', 'PASS') - def test_invalid_import_type(self): + def test_invalid_type(self): assert_raises_with_msg(ValueError, - "Invalid import type 'InvalidType'. Should be " - "one of 'Library', 'Resource' or 'Variables'.", + "Invalid import type: Expected 'LIBRARY', 'RESOURCE' " + "or 'VARIABLES', got 'INVALIDTYPE'.", TestSuite().resource.imports.create, 'InvalidType', 'Name') + def test_repr(self): + assert_equal(repr(Import(Import.LIBRARY, 'X')), + "robot.running.Import(type='LIBRARY', name='X')") + assert_equal(repr(Import(Import.LIBRARY, 'X', ['a'], 'A')), + "robot.running.Import(type='LIBRARY', name='X', args=['a'], alias='A')") + assert_equal(repr(Import(Import.RESOURCE, 'X')), + "robot.running.Import(type='RESOURCE', name='X')") + assert_equal(repr(Import(Import.VARIABLES, '')), + "robot.running.Import(type='VARIABLES', name='')") + if __name__ == '__main__': unittest.main() diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index ef50f71353b..37088a7d637 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -7,8 +7,9 @@ from robot import api, model from robot.model.modelobject import ModelObject -from robot.running.model import (Break, Continue, For, If, IfBranch, Keyword, Return, - TestCase, TestSuite, Try, TryBranch, UserKeyword, While) +from robot.running.model import (Break, Continue, For, If, IfBranch, Keyword, + ResourceFile, Return, TestCase, TestSuite, Try, + TryBranch, UserKeyword, While) from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, assert_raises, assert_true) @@ -114,7 +115,7 @@ def _verify_suite(self, suite, name='Test Run Model', rpa=False): assert_equal(suite.name, name) assert_equal(suite.doc, 'Some text.') assert_equal(suite.rpa, rpa) - assert_equal(suite.resource.imports[0].type, 'Library') + assert_equal(suite.resource.imports[0].type, 'LIBRARY') assert_equal(suite.resource.imports[0].name, 'ExampleLibrary') assert_equal(suite.resource.variables[0].name, '${VAR}') assert_equal(suite.resource.variables[0].value, ('Value',)) @@ -322,9 +323,10 @@ def test_test_structure(self): 'body': [{'name': 'K2'}]}]}]) def test_suite(self): - self._verify(TestSuite(), name='') - self._verify(TestSuite('N', 'D', {'M': 'V'}, 'x', rpa=True), - name='N', doc='D', metadata={'M': 'V'}, source='x', rpa=True) + self._verify(TestSuite(), name='', resource={}) + self._verify(TestSuite('N', 'D', {'M': 'V'}, 'x.robot', rpa=True), + name='N', doc='D', metadata={'M': 'V'}, source='x.robot', rpa=True, + resource={'source': 'x.robot'}) def test_suite_structure(self): suite = TestSuite('Root') @@ -338,7 +340,57 @@ def test_suite_structure(self): teardown={'name': 'Teardown', 'args': ['a']}, tests=[{'name': 'T1', 'body': [{'name': 'K'}]}], suites=[{'name': 'Child', - 'tests': [{'name': 'T2', 'body': []}]}]) + 'tests': [{'name': 'T2', 'body': []}], + 'resource': {}}], + resource={}) + + def test_user_keyword(self): + self._verify(UserKeyword(), name='', body=[]) + self._verify(UserKeyword('N', 'a', 'd', 't', 'r', 't', 1, error='E'), + name='N', + args=['a'], + doc='d', + tags=['t'], + return_='r', + timeout='t', + lineno=1, + error='E', + body=[]) + + def test_user_keyword_structure(self): + uk = UserKeyword('UK') + uk.body.create_keyword('K1') + uk.body.create_if().body.create_branch(condition='$c').body.create_keyword('K2') + uk.teardown.config(name='Teardown') + self._verify(uk, name='UK', + body=[{'name': 'K1'}, + {'type': 'IF/ELSE ROOT', + 'body': [{'type': 'IF', 'condition': '$c', + 'body': [{'name': 'K2'}]}]}], + teardown={'name': 'Teardown'}) + + def test_resource_file(self): + self._verify(ResourceFile()) + resource = ResourceFile('x.resource', 'doc') + resource.imports.library('L', 'a', 'A', 1) + resource.imports.resource('R', 2) + resource.imports.variables('V', 'a', 3) + resource.variables.create('${x}', 'value') + resource.variables.create('@{y}', ['v1', 'v2'], lineno=4) + resource.variables.create('&{z}', ['k=v'], error='E') + resource.keywords.create('UK').body.create_keyword('K') + self._verify(resource, + source='x.resource', + doc='doc', + imports=[{'type': 'LIBRARY', 'name': 'L', 'args': ['a'], + 'alias': 'A', 'lineno': 1}, + {'type': 'RESOURCE', 'name': 'R', 'lineno': 2}, + {'type': 'VARIABLES', 'name': 'V', 'args': ['a'], + 'lineno': 3}], + variables=[{'name': '${x}', 'value': 'value'}, + {'name': '@{y}', 'value': ['v1', 'v2'], 'lineno': 4}, + {'name': '&{z}', 'value': ['k=v'], 'error': 'E'}], + keywords=[{'name': 'UK', 'body': [{'name': 'K'}]}]) def test_bigger_suite_structure(self): suite = TestSuite.from_file_system(MISC_DIR) From ddfb05b0ed762468059623e1a037436fe24f12b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 10 Jan 2023 18:52:32 +0200 Subject: [PATCH 0327/1592] Enhance to/from_json support. #3902 - Support loading JSON using open file or file path. - Support serializing to open file or file path. - Customizable JSON formatting. Defaults differ from what ``json`` uses by default. --- src/robot/model/modelobject.py | 107 ++++++++++++++++++++++++++--- utest/model/test_modelobject.py | 116 +++++++++++++++++++++++++++++--- 2 files changed, 206 insertions(+), 17 deletions(-) diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index 169073b86d6..0fc8997e2b1 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -15,8 +15,10 @@ import copy import json +import os +import pathlib -from robot.utils import SetterAwareType +from robot.utils import SetterAwareType, type_name class ModelObject(metaclass=SetterAwareType): @@ -25,6 +27,10 @@ class ModelObject(metaclass=SetterAwareType): @classmethod def from_dict(cls, data): + """Create this object based on data in a dictionary. + + Data can be got from the :meth:`to_dict` method or created externally. + """ try: return cls().config(**data) except AttributeError as err: @@ -32,14 +38,53 @@ def from_dict(cls, data): f"failed: {err}") @classmethod - def from_json(cls, data): - return cls.from_dict(json.loads(data)) + def from_json(cls, source): + """Create this object based on JSON data. + + The data is given as the ``source`` parameter. It can be + - a string (or bytes) containing the data directly, + - an open file object where to read the data, or + - a path (string or ``pathlib.Path``) to a UTF-8 encoded file to read. + + The JSON data is first converted to a Python dictionary and the object + created using the :meth:`from_dict` method. + + Notice that ``source`` is considered to be JSON data if it is a string + and contains ``{``. If you need to use ``{`` in a file path, pass it in + as a ``pathlib.Path`` instance. + """ + try: + data = JsonLoader().load(source) + except ValueError as err: + raise ValueError(f'Loading JSON data failed: {err}') + return cls.from_dict(data) def to_dict(self): + """Serialize this object into a dictionary. + + The object can be later restored by using the :meth:`from_dict` method. + """ raise NotImplementedError - def to_json(self): - return json.dumps(self.to_dict()) + def to_json(self, file=None, *, ensure_ascii=False, indent=0, + separators=(',', ':')): + """Serialize this object into JSON. + + The object is first converted to a Python dictionary using the + :meth:`to_dict` method and then the dictionary is converted to JSON. + + The ``file`` parameter controls what to do with the resulting JSON data. + It can be + - ``None`` (default) to return the data as a string, + - an open file object where to write the data, or + - a path to a file where to write the data using UTF-8 encoding. + + JSON formatting can be configured using optional parameters that + are passed directly to the underlying ``json`` module. Notice that + the defaults differ from what ``json`` uses. + """ + return JsonDumper(ensure_ascii=ensure_ascii, indent=indent, + separators=separators).dump(self.to_dict(), file) def config(self, **attributes): """Configure model object with given attributes. @@ -52,13 +97,12 @@ def config(self, **attributes): for name in attributes: try: setattr(self, name, attributes[name]) - except AttributeError: + except AttributeError as err: # Ignore error setting attribute if the object already has it. # Avoids problems with `to/from_dict` roundtrip with body items # having unsettable `type` attribute that is needed in dict data. - if getattr(self, name, object()) == attributes[name]: - continue - raise AttributeError + if getattr(self, name, object()) != attributes[name]: + raise AttributeError(f"Setting attribute '{name}' failed: {err}") return self def copy(self, **attributes): @@ -105,3 +149,48 @@ def full_name(obj): if len(parts) > 1 and parts[0] == 'robot': parts[2:-1] = [] return '.'.join(parts) + + +class JsonLoader: + + def load(self, source): + try: + data = self._load(source) + except (json.JSONDecodeError, TypeError) as err: + raise ValueError(f'Invalid JSON data: {err}') + if not isinstance(data, dict): + raise ValueError(f"Expected dictionary, got {type_name(data)}.") + return data + + def _load(self, source): + if self._is_path(source): + with open(source, encoding='UTF-8') as file: + return json.load(file) + if hasattr(source, 'read'): + return json.load(source) + return json.loads(source) + + def _is_path(self, source): + if isinstance(source, os.PathLike): + return True + if not isinstance(source, str) or '{' in source: + return False + return os.path.isfile(source) + + +class JsonDumper: + + def __init__(self, **config): + self.config = config + + def dump(self, data, output=None): + if not output: + return json.dumps(data, **self.config) + elif isinstance(output, (str, pathlib.Path)): + with open(output, 'w', encoding='UTF-8') as file: + json.dump(data, file, **self.config) + elif hasattr(output, 'write'): + json.dump(data, output, **self.config) + else: + raise TypeError(f"Output should be None, open file or path, " + f"got {type_name(output)}.") diff --git a/utest/model/test_modelobject.py b/utest/model/test_modelobject.py index 75eba1ce6dd..2918cadf06e 100644 --- a/utest/model/test_modelobject.py +++ b/utest/model/test_modelobject.py @@ -1,7 +1,21 @@ +import io +import json +import os +import pathlib import unittest +import tempfile from robot.model.modelobject import ModelObject -from robot.utils.asserts import assert_equal, assert_raises +from robot.utils.asserts import assert_equal, assert_raises, assert_raises_with_msg + + +class Example(ModelObject): + + def __init__(self, **attrs): + self.__dict__.update(attrs) + + def to_dict(self): + return self.__dict__ class TestRepr(unittest.TestCase): @@ -36,13 +50,11 @@ def __init__(self, a=1, b=2): assert_equal(x.b, True) def test_other_attributes(self): - class X(ModelObject): - pass - x = X.from_dict({'a': 1}) - assert_equal(x.a, 1) - x = X.from_json('{"a": null, "b": 42}') - assert_equal(x.a, None) - assert_equal(x.b, 42) + obj = Example.from_dict({'a': 1}) + assert_equal(obj.a, 1) + obj = Example.from_json('{"a": null, "b": 42}') + assert_equal(obj.a, None) + assert_equal(obj.b, 42) def test_not_accepted_attribute(self): class X(ModelObject): @@ -52,6 +64,94 @@ class X(ModelObject): assert_equal(str(error).split(':')[0], f"Creating '{__name__}.X' object from dictionary failed") + def test_json_as_bytes(self): + obj = Example.from_json(b'{"a": null, "b": 42}') + assert_equal(obj.a, None) + assert_equal(obj.b, 42) + + def test_json_as_open_file(self): + obj = Example.from_json(io.StringIO('{"a": null, "b": 42, "c": "åäö"}')) + assert_equal(obj.a, None) + assert_equal(obj.b, 42) + assert_equal(obj.c, "åäö") + + def test_json_as_path(self): + with tempfile.NamedTemporaryFile('w', delete=False) as file: + file.write('{"a": null, "b": 42, "c": "åäö"}') + try: + for path in file.name, pathlib.Path(file.name): + obj = Example.from_json(path) + assert_equal(obj.a, None) + assert_equal(obj.b, 42) + assert_equal(obj.c, "åäö") + finally: + os.remove(file.name) + + def test_invalid_json_type(self): + error = self._get_json_load_error(None) + assert_raises_with_msg( + ValueError, f"Loading JSON data failed: Invalid JSON data: {error}", + ModelObject.from_json, None + ) + + def test_invalid_json_syntax(self): + error = self._get_json_load_error('bad') + assert_raises_with_msg( + ValueError, f"Loading JSON data failed: Invalid JSON data: {error}", + ModelObject.from_json, 'bad' + ) + + def test_invalid_json_content(self): + assert_raises_with_msg( + ValueError, "Loading JSON data failed: Expected dictionary, got list.", + ModelObject.from_json, '["bad"]' + ) + + def _get_json_load_error(self, value): + try: + json.loads(value) + except (json.JSONDecodeError, TypeError) as err: + return str(err) + + +class TestToJson(unittest.TestCase): + data = {'a': 1, 'b': [True, False], 'c': 'nön-äscii'} + default_config = {'ensure_ascii': False, 'indent': 0, 'separators': (',', ':')} + custom_config = {'indent': None, 'separators': (', ', ': '), 'ensure_ascii': True} + + def test_default_config(self): + assert_equal(Example(**self.data).to_json(), + json.dumps(self.data, **self.default_config)) + + def test_custom_config(self): + assert_equal(Example(**self.data).to_json(**self.custom_config), + json.dumps(self.data, **self.custom_config)) + + def test_write_to_open_file(self): + for config in {}, self.custom_config: + output = io.StringIO() + Example(**self.data).to_json(output, **config) + expected = json.dumps(self.data, **(config or self.default_config)) + assert_equal(output.getvalue(), expected) + + def test_write_to_path(self): + with tempfile.NamedTemporaryFile(delete=False) as file: + pass + try: + for path in file.name, pathlib.Path(file.name): + for config in {}, self.custom_config: + Example(**self.data).to_json(path, **config) + expected = json.dumps(self.data, **(config or self.default_config)) + with open(path) as file: + assert_equal(file.read(), expected) + finally: + os.remove(file.name) + + def test_invalid_output(self): + assert_raises_with_msg(TypeError, + "Output should be None, open file or path, got integer.", + Example().to_json, 42) + if __name__ == '__main__': unittest.main() From 92148c8566a3f39ca098e0da38b0e1eac5619d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 13 Jan 2023 16:47:18 +0200 Subject: [PATCH 0328/1592] Make TestSuite.source a pathlib.Path instance Also use pathlib.Path elsewhere. Fixes #4596. --- .../output/source_and_lineno_output.robot | 10 +++++--- .../paths_are_not_case_normalized.robot | 2 +- src/robot/libdocpkg/model.py | 2 +- src/robot/libdocpkg/xmlwriter.py | 2 +- src/robot/model/testsuite.py | 18 ++++++++----- src/robot/output/listenerarguments.py | 25 +++++++++++-------- src/robot/output/xmllogger.py | 4 ++- src/robot/reporting/jsbuildingcontext.py | 23 +++++++++++------ src/robot/running/context.py | 2 +- src/robot/running/namespace.py | 6 ++--- src/robot/testdoc.py | 6 ++--- src/robot/utils/markupwriters.py | 2 +- utest/model/test_testcase.py | 3 ++- utest/reporting/test_jsmodelbuilders.py | 12 ++++----- utest/result/test_resultbuilder.py | 17 +++++-------- utest/running/test_builder.py | 9 +++---- utest/running/test_run_model.py | 9 +++---- utest/testdoc/test_jsonconverter.py | 24 ++++++++---------- 18 files changed, 94 insertions(+), 82 deletions(-) diff --git a/atest/robot/output/source_and_lineno_output.robot b/atest/robot/output/source_and_lineno_output.robot index 17c77812fae..b0cccedec3e 100644 --- a/atest/robot/output/source_and_lineno_output.robot +++ b/atest/robot/output/source_and_lineno_output.robot @@ -2,6 +2,9 @@ Resource atest_resource.robot Suite Setup Run Tests ${EMPTY} misc/suites/subsuites2 +*** Variables *** +${SOURCE} ${{pathlib.Path('${DATADIR}/misc/suites/subsuites2')}} + *** Test Cases *** Suite source and test lineno in output after execution Source info should be correct @@ -13,10 +16,9 @@ Suite source and test lineno in output after Rebot *** Keywords *** Source info should be correct - ${source} = Normalize Path ${DATADIR}/misc/suites/subsuites2 - Should Be Equal ${SUITE.source} ${source} - Should Be Equal ${SUITE.suites[0].source} ${source}${/}sub.suite.4.robot + Should Be Equal ${SUITE.source} ${SOURCE} + Should Be Equal ${SUITE.suites[0].source} ${SOURCE / 'sub.suite.4.robot'} Should Be Equal ${SUITE.suites[0].tests[0].lineno} ${2} - Should Be Equal ${SUITE.suites[1].source} ${source}${/}subsuite3.robot + Should Be Equal ${SUITE.suites[1].source} ${SOURCE / 'subsuite3.robot'} Should Be Equal ${SUITE.suites[1].tests[0].lineno} ${8} Should Be Equal ${SUITE.suites[1].tests[1].lineno} ${13} diff --git a/atest/robot/parsing/paths_are_not_case_normalized.robot b/atest/robot/parsing/paths_are_not_case_normalized.robot index b47e380b3dd..82a599a17ad 100644 --- a/atest/robot/parsing/paths_are_not_case_normalized.robot +++ b/atest/robot/parsing/paths_are_not_case_normalized.robot @@ -7,7 +7,7 @@ Suite name is not case normalized Should Be Equal ${SUITE.name} suiTe 8 Suite source should not be case normalized - Should End With ${SUITE.source} multiple_suites${/}suiTe_8.robot + Should Be True str($SUITE.source).endswith(r'multiple_suites${/}suiTe_8.robot') Outputs are not case normalized Stdout Should Contain ${/}LOG.html diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 2b7c4a12288..c483df08d68 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -121,7 +121,7 @@ def to_dictionary(self, include_private=False, theme=None): 'type': self.type, 'scope': self.scope, 'docFormat': self.doc_format, - 'source': self.source, + 'source': str(self.source) if self.source else '', 'lineno': self.lineno, 'tags': list(self.all_tags), 'inits': [init.to_dictionary() for init in self.inits], diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index 5e0c352266b..3bdfe53bf2a 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -46,7 +46,7 @@ def _write_start(self, libdoc, writer): def _add_source_info(self, attrs, item, lib_source=None): if item.source and item.source != lib_source: - attrs['source'] = item.source + attrs['source'] = str(item.source) if item.lineno and item.lineno > 0: attrs['lineno'] = str(item.lineno) diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 6989c4dfaec..3e9e8dba444 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path + from robot.utils import setter from .configurer import SuiteConfigurer @@ -35,16 +37,16 @@ class TestSuite(ModelObject): test_class = TestCase #: Internal usage only. fixture_class = Keyword #: Internal usage only. repr_args = ('name',) - __slots__ = ['parent', 'source', '_name', 'doc', '_setup', '_teardown', 'rpa', + __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', '_my_visitors'] - def __init__(self, name='', doc='', metadata=None, source=None, rpa=False, - parent=None): + def __init__(self, name: str = '', doc: str = '', metadata: dict = None, + source: Path = None, rpa: bool = False, parent: 'TestSuite' = None): self._name = name self.doc = doc self.metadata = metadata - self.source = source #: Path to the source file or directory. - self.parent = parent #: Parent suite. ``None`` with the root suite. + self.source = source + self.parent = parent self.rpa = rpa #: ``True`` when RPA mode is enabled. self.suites = None self.tests = None @@ -66,6 +68,10 @@ def name(self): def name(self, name): self._name = name + @setter + def source(self, source): + return source if isinstance(source, (Path, type(None))) else Path(source) + @property def longname(self): """Suite name prefixed with the long name of the parent suite.""" @@ -272,7 +278,7 @@ def to_dict(self): if self.metadata: data['metadata'] = dict(self.metadata) if self.source: - data['source'] = self.source + data['source'] = str(self.source) if self.rpa: data['rpa'] = self.rpa if self.has_setup: diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index f2ee7402c0d..8b4035d4cce 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -100,7 +100,7 @@ def _get_extra_attributes(self, suite): return {'tests': [t.name for t in suite.tests], 'suites': [s.name for s in suite.suites], 'totaltests': suite.test_count, - 'source': suite.source or ''} + 'source': str(suite.source or '')} class EndSuiteArguments(StartSuiteArguments): @@ -108,27 +108,27 @@ class EndSuiteArguments(StartSuiteArguments): 'endtime', 'elapsedtime', 'status', 'message') def _get_extra_attributes(self, suite): - attrs = StartSuiteArguments._get_extra_attributes(self, suite) + attrs = super()._get_extra_attributes(suite) attrs['statistics'] = suite.stat_message return attrs class StartTestArguments(_ListenerArgumentsFromItem): - _attribute_names = ('id', 'longname', 'doc', 'tags', 'lineno', 'source', 'starttime') + _attribute_names = ('id', 'longname', 'doc', 'tags', 'lineno', 'starttime') def _get_extra_attributes(self, test): - return {'template': test.template or '', + return {'source': str(test.source or ''), + 'template': test.template or '', 'originalname': test.data.name} class EndTestArguments(StartTestArguments): - _attribute_names = ('id', 'longname', 'doc', 'tags', 'lineno', 'source', 'starttime', + _attribute_names = ('id', 'longname', 'doc', 'tags', 'lineno', 'starttime', 'endtime', 'elapsedtime', 'status', 'message') class StartKeywordArguments(_ListenerArgumentsFromItem): - _attribute_names = ('doc', 'assign', 'tags', 'lineno', 'source', 'type', 'status', - 'starttime') + _attribute_names = ('doc', 'assign', 'tags', 'lineno', 'type', 'status', 'starttime') _type_attributes = { BodyItem.FOR: ('variables', 'flavor', 'values'), BodyItem.IF: ('condition',), @@ -136,11 +136,14 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): BodyItem.EXCEPT: ('patterns', 'pattern_type', 'variable'), BodyItem.WHILE: ('condition', 'limit'), BodyItem.RETURN: ('values',), - BodyItem.ITERATION: ('variables',)} + BodyItem.ITERATION: ('variables',) + } def _get_extra_attributes(self, kw): - args = [a if is_string(a) else safe_str(a) for a in kw.args] - attrs = {'kwname': kw.kwname or '', 'libname': kw.libname or '', 'args': args} + attrs = {'kwname': kw.kwname or '', + 'libname': kw.libname or '', + 'args': [a if is_string(a) else safe_str(a) for a in kw.args], + 'source': str(kw.source or '')} if kw.type in self._type_attributes: attrs.update({name: self._get_attribute_value(kw, name) for name in self._type_attributes[kw.type] @@ -149,5 +152,5 @@ def _get_extra_attributes(self, kw): class EndKeywordArguments(StartKeywordArguments): - _attribute_names = ('doc', 'assign', 'tags', 'lineno', 'source', 'type', 'status', + _attribute_names = ('doc', 'assign', 'tags', 'lineno', 'type', 'status', 'starttime', 'endtime', 'elapsedtime') diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index d88fbb2f924..bc3120be2f9 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -197,7 +197,9 @@ def end_test(self, test): self._writer.end('test') def start_suite(self, suite): - attrs = {'id': suite.id, 'name': suite.name, 'source': suite.source} + attrs = {'id': suite.id, 'name': suite.name} + if suite.source: + attrs['source'] = str(suite.source) self._writer.start('suite', attrs) def end_suite(self, suite): diff --git a/src/robot/reporting/jsbuildingcontext.py b/src/robot/reporting/jsbuildingcontext.py index a08231f6ef8..689a8055cb1 100644 --- a/src/robot/reporting/jsbuildingcontext.py +++ b/src/robot/reporting/jsbuildingcontext.py @@ -14,11 +14,11 @@ # limitations under the License. from contextlib import contextmanager -from os.path import exists, dirname +from pathlib import Path from robot.output.loggerhelper import LEVELS -from robot.utils import (attribute_escape, get_link_path, html_escape, is_string, - safe_str, timestamp_to_secs) +from robot.utils import (attribute_escape, get_link_path, html_escape, safe_str, + timestamp_to_secs) from .expandkeywordmatcher import ExpandKeywordMatcher from .stringcache import StringCache @@ -28,8 +28,7 @@ class JsBuildingContext: def __init__(self, log_path=None, split_log=False, expand_keywords=None, prune_input=False): - # log_path can be a custom object in unit tests - self._log_dir = dirname(log_path) if is_string(log_path) else None + self._log_dir = self._get_log_dir(log_path) self._split_log = split_log self._prune_input = prune_input self._strings = self._top_level_strings = StringCache() @@ -40,9 +39,17 @@ def __init__(self, log_path=None, split_log=False, expand_keywords=None, self._expand_matcher = ExpandKeywordMatcher(expand_keywords) \ if expand_keywords else None + def _get_log_dir(self, log_path): + # log_path can be a custom object in unit tests + if isinstance(log_path, Path): + return log_path.parent + if isinstance(log_path, str): + return Path(log_path).parent + return None + def string(self, string, escape=True, attr=False): if escape and string: - if not is_string(string): + if not isinstance(string, str): string = safe_str(string) string = (html_escape if not attr else attribute_escape)(string) return self._strings.add(string) @@ -51,8 +58,10 @@ def html(self, string): return self._strings.add(string, html=True) def relative_source(self, source): + if isinstance(source, str): + source = Path(source) rel_source = get_link_path(source, self._log_dir) \ - if self._log_dir and source and exists(source) else '' + if self._log_dir and source and source.exists() else '' return self.string(rel_source) def timestamp(self, time): diff --git a/src/robot/running/context.py b/src/robot/running/context.py index f0104669a3d..8df225f545f 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -163,7 +163,7 @@ def end_suite(self, suite): def set_suite_variables(self, suite): self.variables['${SUITE_NAME}'] = suite.longname - self.variables['${SUITE_SOURCE}'] = suite.source or '' + self.variables['${SUITE_SOURCE}'] = str(suite.source or '') self.variables['${SUITE_DOCUMENTATION}'] = suite.doc self.variables['${SUITE_METADATA}'] = suite.metadata.copy() diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 8b9b3cb55fa..65df6e0f28b 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -89,7 +89,7 @@ def _import_resource(self, import_setting, overwrite=False): self._kw_store.resources[path] = user_library self._handle_imports(resource.imports) LOGGER.imported("Resource", user_library.name, - importer=import_setting.source, + importer=str(import_setting.source), source=path) else: LOGGER.info(f"Resource file '{path}' already imported by " @@ -112,7 +112,7 @@ def _import_variables(self, import_setting, overwrite=False): self.variables.set_from_file(path, args, overwrite) LOGGER.imported("Variables", os.path.basename(path), args=list(args), - importer=import_setting.source, + importer=str(import_setting.source), source=path) else: msg = f"Variable file '{path}'" @@ -135,7 +135,7 @@ def _import_library(self, import_setting, notify=True): LOGGER.imported("Library", lib.name, args=list(import_setting.args), originalname=lib.orig_name, - importer=import_setting.source, + importer=str(import_setting.source), source=lib.source) self._kw_store.libraries[lib.name] = lib lib.start_suite() diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index 0f62d7b4a25..8d0a52e355b 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -42,7 +42,7 @@ from robot.htmldata import HtmlFileWriter, ModelWriter, JsonWriter, TESTDOC from robot.running import TestSuiteBuilder from robot.utils import (abspath, Application, file_writer, get_link_path, - html_escape, html_format, is_string, secs_to_timestr, + html_escape, html_format, is_list_like, secs_to_timestr, seq2str2, timestr_to_secs, unescape) @@ -130,7 +130,7 @@ def _write_test_doc(self, suite, outfile, title): def TestSuiteFactory(datasources, **options): settings = RobotSettings(options) - if is_string(datasources): + if not is_list_like(datasources): datasources = [datasources] suite = TestSuiteBuilder(process_curdir=False).build(*datasources) suite.configure(**settings.suite_config) @@ -169,7 +169,7 @@ def convert(self, suite): def _convert_suite(self, suite): return { - 'source': suite.source or '', + 'source': str(suite.source or ''), 'relativeSource': self._get_relative_source(suite.source), 'id': suite.id, 'name': self._escape(suite.name), diff --git a/src/robot/utils/markupwriters.py b/src/robot/utils/markupwriters.py index b4f5af5741e..cd962fdc703 100644 --- a/src/robot/utils/markupwriters.py +++ b/src/robot/utils/markupwriters.py @@ -100,7 +100,7 @@ def _escape(self, text): def element(self, name, content=None, attrs=None, escape=True, newline=True): if content: - _MarkupWriter.element(self, name, content, attrs, escape, newline) + super().element(name, content, attrs, escape, newline) else: self._self_closing_element(name, attrs, newline) diff --git a/utest/model/test_testcase.py b/utest/model/test_testcase.py index d054fb1b829..4f39089d9dc 100644 --- a/utest/model/test_testcase.py +++ b/utest/model/test_testcase.py @@ -1,5 +1,6 @@ import unittest import warnings +from pathlib import Path from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, assert_raises, assert_raises_with_msg, assert_true) @@ -31,7 +32,7 @@ def test_source(self): assert_equal(test.source, None) suite.tests.append(test) suite.source = '/unit/tests' - assert_equal(test.source, '/unit/tests') + assert_equal(test.source, Path('/unit/tests')) def test_setup(self): assert_equal(self.test.setup.__class__, Keyword) diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index 93837269d2d..470b53d3794 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -1,7 +1,7 @@ import base64 import unittest import zlib -from os.path import abspath, basename, dirname, join +from pathlib import Path from robot.utils.asserts import assert_equal, assert_true from robot.result import Keyword, Message, TestCase, TestSuite @@ -14,7 +14,7 @@ from robot.reporting.stringcache import StringIndex -CURDIR = dirname(abspath(__file__)) +CURDIR = Path(__file__).resolve().parent def decode_string(string): @@ -48,9 +48,9 @@ def test_suite_with_values(self): def test_relative_source(self): self._verify_suite(TestSuite(source='non-existing'), source='non-existing') - source = join(CURDIR, 'test_jsmodelbuilders.py') - self._verify_suite(TestSuite(source=source), source=source, - relsource=basename(source)) + source = CURDIR / 'test_jsmodelbuilders.py' + self._verify_suite(TestSuite(name='x', source=source), + name='x', source=str(source), relsource=str(source.name)) def test_suite_html_formatting(self): self._verify_suite(TestSuite(name='*xxx*', doc='*bold* <&>', @@ -233,7 +233,7 @@ def _verify_min_message_level(self, expected): assert_equal(self.context.min_level, expected) def _build_and_verify(self, builder_class, item, *expected): - self.context = JsBuildingContext(log_path=join(CURDIR, 'log.html')) + self.context = JsBuildingContext(log_path=CURDIR / 'log.html') model = builder_class(self.context).build(item) self._verify_mapped(model, self.context.strings, expected) return expected diff --git a/utest/result/test_resultbuilder.py b/utest/result/test_resultbuilder.py index 34c87752287..7c2c3f277bb 100644 --- a/utest/result/test_resultbuilder.py +++ b/utest/result/test_resultbuilder.py @@ -2,7 +2,6 @@ import unittest import tempfile from io import StringIO -from os.path import join, dirname from pathlib import Path from robot.errors import DataError @@ -10,14 +9,10 @@ from robot.utils.asserts import assert_equal, assert_false, assert_true, assert_raises -def _read_file(name): - with open(join(dirname(__file__), name)) as f: - return f.read() - - -GOLDEN_XML = _read_file('golden.xml') -GOLDEN_XML_TWICE = _read_file('goldenTwice.xml') -SUITE_TEARDOWN_FAILED = _read_file('suite_teardown_failed.xml') +CURDIR = Path(__file__).resolve().parent +GOLDEN_XML = (CURDIR / 'golden.xml').read_text() +GOLDEN_XML_TWICE = (CURDIR / 'goldenTwice.xml').read_text() +SUITE_TEARDOWN_FAILED = (CURDIR / 'suite_teardown_failed.xml').read_text() class TestBuildingSuiteExecutionResult(unittest.TestCase): @@ -28,7 +23,7 @@ def setUp(self): self.test = self.suite.tests[0] def test_suite_is_built(self): - assert_equal(self.suite.source, 'normal.html') + assert_equal(self.suite.source, Path('normal.html')) assert_equal(self.suite.name, 'Normal') assert_equal(self.suite.doc, 'Normal test cases') assert_equal(self.suite.metadata, {'Something': 'My Value'}) @@ -352,7 +347,7 @@ def setUp(self): def test_suite_is_built(self, suite=None): suite = suite or self.result.suite - assert_equal(suite.source, 'normal.html') + assert_equal(suite.source, Path('normal.html')) assert_equal(suite.name, 'Normal') assert_equal(suite.doc, 'Normal test cases') assert_equal(suite.metadata, {'Something': 'My Value'}) diff --git a/utest/running/test_builder.py b/utest/running/test_builder.py index 8d7f6d14c04..36ef81b3269 100644 --- a/utest/running/test_builder.py +++ b/utest/running/test_builder.py @@ -1,17 +1,16 @@ import unittest -from os.path import abspath, dirname, normpath, join +from pathlib import Path from robot.errors import DataError from robot.utils.asserts import assert_equal, assert_raises, assert_true from robot.running import TestSuite, TestSuiteBuilder -CURDIR = dirname(abspath(__file__)) -DATADIR = join(CURDIR, '..', '..', 'atest', 'testdata', 'misc') +DATADIR = (Path(__file__).parent / '../../atest/testdata/misc').resolve() def build(*paths, **config): - paths = [normpath(join(DATADIR, p)) for p in paths] + paths = [Path(DATADIR, p).resolve() for p in paths] suite = TestSuiteBuilder(**config).build(*paths) assert_true(isinstance(suite, TestSuite)) assert_equal(suite.source, paths[0] if len(paths) == 1 else None) @@ -95,8 +94,6 @@ def test_test_setup_and_teardown(self): test = build('setups_and_teardowns.robot').tests[0] assert_keyword(test.setup, name='${TEST SETUP}', type='SETUP') assert_keyword(test.teardown, name='${TEST TEARDOWN}', type='TEARDOWN') - assert_equal([kw.name for kw in test.body], - ['Keyword']) assert_equal([kw.name for kw in test.body], ['Keyword']) def test_test_timeout(self): diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 37088a7d637..ade67cd24fc 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -3,7 +3,7 @@ import tempfile import unittest import warnings -from os.path import abspath, join, normpath +from pathlib import Path from robot import api, model from robot.model.modelobject import ModelObject @@ -13,8 +13,7 @@ from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, assert_raises, assert_true) -MISC_DIR = normpath(join(abspath(__file__), '..', '..', '..', - 'atest', 'testdata', 'misc')) +MISC_DIR = (Path(__file__).parent / '../../atest/testdata/misc').resolve() class TestModelTypes(unittest.TestCase): @@ -52,7 +51,7 @@ def test_keywords_deprecation(self): class TestSuiteFromSources(unittest.TestCase): - path = join(os.getenv('TEMPDIR') or tempfile.gettempdir(), + path = Path(os.getenv('TEMPDIR') or tempfile.gettempdir(), 'test_run_model.robot') data = ''' *** Settings *** @@ -190,7 +189,7 @@ def cannot_differ(self, value1, value2): class TestLineNumberAndSource(unittest.TestCase): - source = join(MISC_DIR, 'pass_and_fail.robot') + source = MISC_DIR / 'pass_and_fail.robot' @classmethod def setUpClass(cls): diff --git a/utest/testdoc/test_jsonconverter.py b/utest/testdoc/test_jsonconverter.py index 7676b7ae2c0..24c0726c5b1 100644 --- a/utest/testdoc/test_jsonconverter.py +++ b/utest/testdoc/test_jsonconverter.py @@ -1,10 +1,10 @@ import unittest -from os.path import abspath, dirname, join, normpath +from pathlib import Path from robot.utils.asserts import assert_equal from robot.testdoc import JsonConverter, TestSuiteFactory -DATADIR = join(dirname(abspath(__file__)), '..', '..', 'atest', 'testdata', 'misc') +DATADIR = (Path(__file__).parent / '../../atest/testdata/misc').resolve() def test_convert(item, **expected): @@ -17,12 +17,11 @@ class TestJsonConverter(unittest.TestCase): @classmethod def setUpClass(cls): suite = TestSuiteFactory(DATADIR, doc='My doc', metadata=['abc:123', '1:2']) - output = join(DATADIR, '..', 'output.html') - cls.suite = JsonConverter(output).convert(suite) + cls.suite = JsonConverter(DATADIR / '../output.html').convert(suite) def test_suite(self): test_convert(self.suite, - source=normpath(DATADIR), + source=str(DATADIR), relativeSource='misc', id='s1', name='Misc', @@ -33,7 +32,7 @@ def test_suite(self): tests=[], keywords=[]) test_convert(self.suite['suites'][0], - source=join(normpath(DATADIR), 'dummy_lib_test.robot'), + source=str(DATADIR / 'dummy_lib_test.robot'), relativeSource='misc/dummy_lib_test.robot', id='s1-s1', name='Dummy Lib Test', @@ -44,8 +43,7 @@ def test_suite(self): suites=[], keywords=[]) test_convert(self.suite['suites'][5]['suites'][1]['suites'][-1], - source=join(normpath(DATADIR), 'multiple_suites', - '02__sub.suite.1', 'second__.Sui.te.2..robot'), + source=str(DATADIR / 'multiple_suites/02__sub.suite.1/second__.Sui.te.2..robot'), relativeSource='misc/multiple_suites/02__sub.suite.1/second__.Sui.te.2..robot', id='s1-s6-s2-s2', name='.Sui.te.2.', @@ -57,8 +55,8 @@ def test_suite(self): keywords=[]) def test_multi_suite(self): - data = TestSuiteFactory([join(DATADIR, 'normal.robot'), - join(DATADIR, 'pass_and_fail.robot')]) + data = TestSuiteFactory([DATADIR / 'normal.robot', + DATADIR / 'pass_and_fail.robot']) suite = JsonConverter().convert(data) test_convert(suite, source='', @@ -72,7 +70,7 @@ def test_multi_suite(self): keywords=[], tests=[]) test_convert(suite['suites'][0], - source=normpath(join(DATADIR, 'normal.robot')), + source=str(DATADIR / 'normal.robot'), relativeSource='', id='s1-s1', name='Normal', @@ -81,7 +79,7 @@ def test_multi_suite(self): metadata=[('Something', '<p>My Value</p>')], numberOfTests=2) test_convert(suite['suites'][1], - source=normpath(join(DATADIR, 'pass_and_fail.robot')), + source=str(DATADIR / 'pass_and_fail.robot'), relativeSource='', id='s1-s2', name='Pass And Fail', @@ -177,7 +175,7 @@ class TestFormattingAndEscaping(unittest.TestCase): def setUp(self): if not self.suite: - suite = TestSuiteFactory(join(DATADIR, 'formatting_and_escaping.robot'), + suite = TestSuiteFactory(DATADIR / 'formatting_and_escaping.robot', name='<suite>', metadata=['CLI>:*bold*']) self.__class__.suite = JsonConverter().convert(suite) From 26cee92cc5d611f89a070b4bab039c439f9435b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 13 Jan 2023 17:01:07 +0200 Subject: [PATCH 0329/1592] Add TestSuite.name_from_source This is a public API that external tools, including forthcoming external parsers (#1283), can use to create suite names in consistent format. Also helps fixing regression reported in #4593. --- src/robot/model/testsuite.py | 22 ++++++++++++++++++++-- utest/model/test_testsuite.py | 25 ++++++++++++++++++++++--- utest/reporting/test_jsmodelbuilders.py | 3 ++- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 3e9e8dba444..5b32ca03bb9 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -54,6 +54,18 @@ def __init__(self, name: str = '', doc: str = '', metadata: dict = None, self._teardown = None self._my_visitors = [] + @staticmethod + def name_from_source(source: Path): + if not source: + return '' + if not isinstance(source, Path): + source = Path(source) + name = source.name if source.is_dir() else source.stem + if '__' in name: + name = name.split('__', 1)[1] or name + name = name.replace('_', ' ').strip() + return name.title() if name.islower() else name + @property def _visitors(self): parent_visitors = self.parent._visitors if self.parent else [] @@ -61,8 +73,14 @@ def _visitors(self): @property def name(self): - """Test suite name. If not set, constructed from child suite names.""" - return self._name or ' & '.join(s.name for s in self.suites) + """Test suite name. + + If name is not set, it is constructed from source. If source is not set, + name is constructed from child suite names or. + """ + return (self._name + or self.name_from_source(self.source) + or ' & '.join(s.name for s in self.suites)) @name.setter def name(self, name): diff --git a/utest/model/test_testsuite.py b/utest/model/test_testsuite.py index 49adfd95470..de6ee8e0363 100644 --- a/utest/model/test_testsuite.py +++ b/utest/model/test_testsuite.py @@ -1,11 +1,12 @@ import unittest import warnings -from robot.utils.asserts import (assert_equal, assert_true, assert_raises, - assert_raises_with_msg) +from pathlib import Path from robot.model import TestSuite from robot.running import TestSuite as RunningTestSuite from robot.result import TestSuite as ResultTestSuite +from robot.utils.asserts import (assert_equal, assert_true, assert_raises, + assert_raises_with_msg) class TestTestSuite(unittest.TestCase): @@ -39,7 +40,25 @@ def test_reset_suites(self): assert_true(s2.parent is self.suite) assert_equal(list(self.suite.suites), [s1, s2]) - def test_suite_name(self): + def test_name_from_source(self): + for inp, exp in [(None, ''), ('', ''), ('name', 'Name'), ('name.robot', 'Name'), + ('naMe', 'naMe'), ('na_me', 'Na Me'), ('na_M_e_', 'na M e'), + ('prefix__name', 'Name'), ('__n', 'N'), ('naMe__', 'naMe')]: + assert_equal(TestSuite(source=inp).name, exp) + if inp: + assert_equal(TestSuite(source=Path(inp)).name, exp) + assert_equal(TestSuite(source=Path(inp).resolve()).name, exp) + + + def test_suite_name_from_source(self): + suite = TestSuite(source='example.robot') + assert_equal(suite.name, 'Example') + suite.suites.create(name='child') + assert_equal(suite.name, 'Example') + suite.name = 'new name' + assert_equal(suite.name, 'new name') + + def test_suite_name_from_child_suites(self): suite = TestSuite() assert_equal(suite.name, '') assert_equal(suite.suites.create(name='foo').name, 'foo') diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index 470b53d3794..03b45a52bf1 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -47,7 +47,8 @@ def test_suite_with_values(self): message='Message', start=0, elapsed=42001) def test_relative_source(self): - self._verify_suite(TestSuite(source='non-existing'), source='non-existing') + self._verify_suite(TestSuite(source='non-existing'), + name='Non-Existing', source='non-existing') source = CURDIR / 'test_jsmodelbuilders.py' self._verify_suite(TestSuite(name='x', source=source), name='x', source=str(source), relsource=str(source.name)) From 004081ce3a3c3bd8deb35e568fbf5f9245f14143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 13 Jan 2023 17:05:57 +0200 Subject: [PATCH 0330/1592] Fix regression causes by 63162438bd492b89557b305813462f33f4a65b7c Fixes #4593. --- src/robot/running/builder/parsers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index fb3c0b14d14..5ddb3289e42 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -55,7 +55,8 @@ def parse_suite_file(self, source, name, defaults=None): def build_suite(self, model, name=None, defaults=None): source = model.source - suite = TestSuite(name=name or format_name(source), source=source) + name = name or TestSuite.name_from_source(source) + suite = TestSuite(name=name, source=source) return self._build(suite, source, defaults, model) def _build(self, suite, source, defaults, model=None, get_model=get_model): From 7342d646a72b6238bd734c8ef9e80a4ad87a7a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 13 Jan 2023 17:56:26 +0200 Subject: [PATCH 0331/1592] Refactor constructing suite structure. Includes using pathlib.Path over os.path functions that's related to #4596. Source passed to parsers will also be pathlib.Path. Name isn't anymore passed to parsers, they can use new TestSuite.name_from_source if they want to. These changes are related to #1283. --- src/robot/conf/settings.py | 4 +- src/robot/parsing/suitestructure.py | 246 ++++++++++---------------- src/robot/running/builder/builders.py | 15 +- src/robot/running/builder/parsers.py | 18 +- 4 files changed, 118 insertions(+), 165 deletions(-) diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index fb127afa195..c77de89d194 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -138,7 +138,7 @@ def _process_value(self, name, value): if name == 'ExpandKeywords': self._validate_expandkeywords(value) if name == 'Extension': - return tuple(ext.lower().lstrip('.') for ext in value.split(':')) + return tuple('.' + ext.lower().lstrip('.') for ext in value.split(':')) return value def _process_doc(self, value): @@ -453,7 +453,7 @@ def rpa(self, value): class RobotSettings(_BaseSettings): - _extra_cli_opts = {'Extension' : ('extension', ('robot',)), + _extra_cli_opts = {'Extension' : ('extension', ('.robot',)), 'Output' : ('output', 'output.xml'), 'LogLevel' : ('loglevel', 'INFO'), 'MaxErrorLines' : ('maxerrorlines', 40), diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 85cca0682c6..c908eb8f4fa 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -13,32 +13,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os.path +from pathlib import Path +from typing import List from robot.errors import DataError from robot.model import SuiteNamePatterns from robot.output import LOGGER -from robot.utils import abspath, get_error_message, safe_str +from robot.utils import get_error_message class SuiteStructure: - def __init__(self, source=None, init_file=None, children=None): + def __init__(self, source: Path = None, init_file: Path = None, + children: List['SuiteStructure'] = None): self.source = source - self.name = self._format_name(source) self.init_file = init_file self.children = children - self.extension = self._get_extension(source, init_file) - def _get_extension(self, source, init_file): - if self.is_directory and not init_file: - return None - source = init_file or source - return os.path.splitext(source)[1][1:].lower() + @property + def extension(self): + source = self.source if self.is_file else self.init_file + return source.suffix[1:].lower() if source else None @property - def is_directory(self): - return self.children is not None + def is_file(self): + return self.children is None + + def add(self, child: 'SuiteStructure'): + self.children.append(child) def visit(self, visitor): if self.children is None: @@ -46,166 +48,114 @@ def visit(self, visitor): else: visitor.visit_directory(self) - def _format_name(self, source): - def strip_possible_prefix_from_name(name): - result = name.split('__', 1)[-1] - if result: - return result - return name - - def format_name(name): - name = strip_possible_prefix_from_name(name) - name = name.replace('_', ' ').strip() - return name.title() if name.islower() else name - - if source is None: - return None - if os.path.isdir(source): - basename = os.path.basename(source) - else: - basename = os.path.splitext(os.path.basename(source))[0] - return format_name(basename) +class SuiteStructureVisitor: + + def visit_file(self, structure): + pass + + def visit_directory(self, structure): + self.start_directory(structure) + for child in structure.children: + child.visit(self) + self.end_directory(structure) + + def start_directory(self, structure): + pass + + def end_directory(self, structure): + pass class SuiteStructureBuilder: ignored_prefixes = ('_', '.') ignored_dirs = ('CVS',) - def __init__(self, included_extensions=('robot',), included_suites=None): + def __init__(self, included_extensions=('.robot',), included_suites=None): self.included_extensions = included_extensions - self.included_suites = included_suites + self.included_suites = None if not included_suites else \ + SuiteNamePatterns(self._create_included_suites(included_suites)) + + def _create_included_suites(self, included_suites): + for suite in included_suites: + yield suite + while '.' in suite: + suite = suite.split('.', 1)[1] + yield suite def build(self, paths): paths = list(self._normalize_paths(paths)) if len(paths) == 1: return self._build(paths[0], self.included_suites) - sources, init_file = self._get_sources(paths) - return SuiteStructure(children=sources, init_file=init_file) - - def _get_sources(self, paths): - init_file = None - sources = [] - for p in paths: - base, ext = os.path.splitext(os.path.basename(p)) - ext = ext[1:].lower() - if self._is_init_file(p, base, ext): - if init_file: - raise DataError("Multiple init files not allowed.") - init_file = p - else: - sources.append(self._build(p, self.included_suites)) - return sources, init_file + return self._build_multi_source(paths) def _normalize_paths(self, paths): if not paths: raise DataError('One or more source paths required.') - for path in paths: - path = os.path.normpath(path) - if not os.path.exists(path): - raise DataError("Parsing '%s' failed: File or directory to " - "execute does not exist." % path) - yield abspath(path) - - def _build(self, path, include_suites): - if os.path.isfile(path): - return SuiteStructure(path) - include_suites = self._get_include_suites(path, include_suites) - init_file, paths = self._get_child_paths(path, include_suites) - children = [self._build(p, include_suites) for p in paths] - return SuiteStructure(path, init_file, children) - - def _get_include_suites(self, path, incl_suites): - if not incl_suites: - return None - if not isinstance(incl_suites, SuiteNamePatterns): - incl_suites = SuiteNamePatterns( - self._create_included_suites(incl_suites)) - # If a directory is included, also all its children should be included. - if self._is_in_included_suites(os.path.basename(path), incl_suites): - return None - return incl_suites - - def _create_included_suites(self, incl_suites): - for suite in incl_suites: - yield suite - while '.' in suite: - suite = suite.split('.', 1)[1] - yield suite + try: + return [Path(p).resolve(strict=True) for p in paths] + except OSError as err: + raise DataError(f"Parsing '{err.filename}' failed: " + f"File or directory to execute does not exist.") - def _get_child_paths(self, dirpath, incl_suites=None): - init_file = None - paths = [] - for path, is_init_file in self._list_dir(dirpath, incl_suites): - if is_init_file: - if not init_file: - init_file = path + def _build(self, path, included_suites): + if path.is_file(): + return SuiteStructure(path) + return self._build_directory(path, included_suites) + + def _build_directory(self, dir_path, included_suites): + structure = SuiteStructure(dir_path, children=[]) + # If a directory is included, also its children are included. + if self._is_suite_included(dir_path.name, included_suites): + included_suites = None + for path in self._list_dir(dir_path): + if self._is_init_file(path): + if structure.init_file: + LOGGER.error(f"Ignoring second test suite init file '{path}'.") else: - LOGGER.error("Ignoring second test suite init file '%s'." - % path) + structure.init_file = path + elif self._is_included(path, included_suites): + structure.add(self._build(path, included_suites)) else: - paths.append(path) - return init_file, paths + LOGGER.info(f"Ignoring file or directory '{path}'.") + return structure + + def _is_suite_included(self, name, included_suites): + if not included_suites: + return True + if '__' in name: + name = name.split('__', 1)[1] or name + return included_suites.match(name) - def _list_dir(self, dir_path, incl_suites): + def _list_dir(self, path): try: - names = os.listdir(dir_path) - except: - raise DataError("Reading directory '%s' failed: %s" - % (dir_path, get_error_message())) - for name in sorted(names, key=lambda item: item.lower()): - name = safe_str(name) # Handles NFC normalization on OSX - path = os.path.join(dir_path, name) - base, ext = os.path.splitext(name) - ext = ext[1:].lower() - if self._is_init_file(path, base, ext): - yield path, True - elif self._is_included(path, base, ext, incl_suites): - yield path, False - else: - LOGGER.info("Ignoring file or directory '%s'." % path) + return sorted(path.iterdir(), key=lambda p: p.name.lower()) + except OSError: + raise DataError(f"Reading directory '{path}' failed: {get_error_message()}") - def _is_init_file(self, path, base, ext): - return (base.lower() == '__init__' - and ext in self.included_extensions - and os.path.isfile(path)) + def _is_init_file(self, path: Path): + return (path.stem.lower() == '__init__' + and path.suffix.lower() in self.included_extensions + and path.is_file()) - def _is_included(self, path, base, ext, incl_suites): - if base.startswith(self.ignored_prefixes): + def _is_included(self, path: Path, included_suites): + if path.name.startswith(self.ignored_prefixes): return False - if os.path.isdir(path): - return base not in self.ignored_dirs or ext - if ext not in self.included_extensions: + if path.is_dir(): + return path.name not in self.ignored_dirs + if not path.is_file(): return False - return self._is_in_included_suites(base, incl_suites) - - def _is_in_included_suites(self, name, incl_suites): - if not incl_suites: - return True - return incl_suites.match(self._split_prefix(name)) - - def _split_prefix(self, name): - result = name.split('__', 1)[-1] - if result: - return result - return name - - -class SuiteStructureVisitor: - - def visit_file(self, structure): - pass - - def visit_directory(self, structure): - self.start_directory(structure) - for child in structure.children: - child.visit(self) - self.end_directory(structure) - - def start_directory(self, structure): - pass - - def end_directory(self, structure): - pass - + if path.suffix.lower() not in self.included_extensions: + return False + return self._is_suite_included(path.stem, included_suites) + def _build_multi_source(self, paths: List[Path]): + structure = SuiteStructure(children=[]) + for path in paths: + if self._is_init_file(path): + if structure.init_file: + raise DataError("Multiple init files not allowed.") + structure.init_file = path + else: + structure.add(self._build(path, self.included_suites)) + return structure diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index bd84aeccf4d..083c769a429 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -46,7 +46,7 @@ class TestSuiteBuilder: :mod:`robot.api` package. """ - def __init__(self, included_suites=None, included_extensions=('robot',), + def __init__(self, included_suites=None, included_extensions=('.robot',), rpa=None, lang=None, allow_empty_suite=False, process_curdir=True): """ @@ -168,15 +168,14 @@ def _build_suite(self, structure): defaults = Defaults(parent_defaults) parser = self._get_parser(structure.extension) try: - if structure.is_directory: - suite = parser.parse_init_file(structure.init_file or source, - structure.name, defaults) - if structure.source is None: - suite.name = None - else: - suite = parser.parse_suite_file(source, structure.name, defaults) + if structure.is_file: + suite = parser.parse_suite_file(source, defaults) if not suite.tests: LOGGER.info(f"Data source '{source}' has no tests or tasks.") + else: + suite = parser.parse_init_file(structure.init_file or source, defaults) + if not source: + suite.config(name='', source=None) self._validate_execution_mode(suite) except DataError as err: raise DataError(f"Parsing '{source}' failed: {err.message}") diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 5ddb3289e42..169a035a3f3 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -15,6 +15,7 @@ import os from ast import NodeVisitor +from pathlib import Path from robot.errors import DataError from robot.output import LOGGER @@ -28,13 +29,13 @@ class BaseParser: - def parse_init_file(self, source, name, defaults=None): + def parse_init_file(self, source: Path, defaults: Defaults = None): raise NotImplementedError - def parse_suite_file(self, source, name, defaults=None): + def parse_suite_file(self, source: Path, defaults: Defaults = None): raise NotImplementedError - def parse_resource_file(self, source): + def parse_resource_file(self, source: Path): raise NotImplementedError @@ -44,12 +45,14 @@ def __init__(self, lang=None, process_curdir=True): self.lang = lang self.process_curdir = process_curdir - def parse_init_file(self, source, name, defaults=None): - directory = os.path.dirname(source) + def parse_init_file(self, source, defaults=None): + directory = source.parent + name = TestSuite.name_from_source(directory) suite = TestSuite(name=name, source=directory) return self._build(suite, source, defaults, get_model=get_init_model) - def parse_suite_file(self, source, name, defaults=None): + def parse_suite_file(self, source, defaults=None): + name = TestSuite.name_from_source(source) suite = TestSuite(name=name, source=source) return self._build(suite, source, defaults) @@ -105,7 +108,8 @@ def _get_source(self, source): class NoInitFileDirectoryParser(BaseParser): - def parse_init_file(self, source, name=None, defaults=None): + def parse_init_file(self, source, defaults=None): + name = TestSuite.name_from_source(source) return TestSuite(name=name, source=source) From 1a00e9571a00c21c6ebdcaa709b6ba9763f1cdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 12:14:38 +0200 Subject: [PATCH 0332/1592] Report non-exising files consistently. Interestingly `OSError.filename` seems to be absolute in newer Python versions but relative in others. Possibly there are subtle differences in behavior with `Path.resolve(strict=True)` fails. Anyway, now we consistenly use absolute paths which ought to fix tests on CI. --- atest/robot/cli/runner/cli_resource.robot | 8 ++++++-- atest/robot/cli/runner/invalid_usage.robot | 12 ++++++------ atest/robot/testdoc/invalid_usage.robot | 2 +- src/robot/parsing/suitestructure.py | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/atest/robot/cli/runner/cli_resource.robot b/atest/robot/cli/runner/cli_resource.robot index b538b718a50..06a5328c2bc 100644 --- a/atest/robot/cli/runner/cli_resource.robot +++ b/atest/robot/cli/runner/cli_resource.robot @@ -36,8 +36,12 @@ Tests Should Pass Without Errors [Return] ${result} Run Should Fail - [Arguments] ${options} ${error} + [Arguments] ${options} ${error} ${regexp}=False ${result} = Run Tests ${options} default options= output= Should Be Equal As Integers ${result.rc} 252 Should Be Empty ${result.stdout} - Should Match Regexp ${result.stderr} ^\\[ .*ERROR.* \\] ${error}${USAGETIP}$ + IF ${regexp} + Should Match Regexp ${result.stderr} ^\\[ ERROR \\] ${error}${USAGETIP}$ + ELSE + Should Be Equal ${result.stderr} [ ERROR ] ${error}${USAGETIP} + END diff --git a/atest/robot/cli/runner/invalid_usage.robot b/atest/robot/cli/runner/invalid_usage.robot index b23b44cfa2a..f8124a0aca4 100644 --- a/atest/robot/cli/runner/invalid_usage.robot +++ b/atest/robot/cli/runner/invalid_usage.robot @@ -5,23 +5,23 @@ Test Template Run Should Fail *** Test Cases *** No Input - ${EMPTY} Expected at least 1 argument, got 0\\. + ${EMPTY} Expected at least 1 argument, got 0. Argument File Option Without Value As Last Argument --argumentfile option --argumentfile requires argument Non-Existing Input - nonexisting.robot Parsing 'nonexisting\\.robot' failed: File or directory to execute does not exist\\. + nonexisting.robot Parsing '${EXECDIR}${/}nonexisting.robot' failed: File or directory to execute does not exist. Non-Existing Input With Non-Ascii Characters - eitäällä.robot Parsing 'eitäällä\\.robot' failed: File or directory to execute does not exist\\. + eitäällä.robot Parsing '${EXECDIR}${/}eitäällä.robot' failed: File or directory to execute does not exist. Invalid Output Directory [Setup] Create File %{TEMPDIR}/not-dir -d %{TEMPDIR}/not-dir/dir ${DATADIR}/${TEST FILE} - ... Creating output file directory '.*not-dir.dir' failed: .* + ... Creating output file directory '.*not-dir.dir' failed: .* regexp=True -d %{TEMPDIR}/not-dir/dir -o %{TEMPDIR}/out.xml ${DATADIR}/${TEST FILE} - ... Creating report file directory '.*not-dir.dir' failed: .* + ... Creating report file directory '.*not-dir.dir' failed: .* regexp=True Invalid Options --invalid option option --invalid not recognized @@ -37,7 +37,7 @@ Invalid --TagStatLink Invalid --RemoveKeywords --removekeywords wuks --removek name:xxx --RemoveKeywords Invalid tests.robot - ... Invalid value for option '--removekeywords'. Expected 'ALL', 'PASSED', 'NAME:<pattern>', 'TAG:<pattern>', 'FOR' or 'WUKS', got 'Invalid'. + ... Invalid value for option '--removekeywords': Expected 'ALL', 'PASSED', 'NAME:<pattern>', 'TAG:<pattern>', 'FOR' or 'WUKS', got 'Invalid'. Invalid --loglevel --loglevel bad tests.robot diff --git a/atest/robot/testdoc/invalid_usage.robot b/atest/robot/testdoc/invalid_usage.robot index 36fe575be0c..e4fc3088d2e 100644 --- a/atest/robot/testdoc/invalid_usage.robot +++ b/atest/robot/testdoc/invalid_usage.robot @@ -7,7 +7,7 @@ Invalid usage Expected at least 2 arguments, got 1. Non-existing input - Parsing 'nonex.robot' failed: File or directory to execute does not exist. + Parsing '${EXECDIR}${/}nonex.robot' failed: File or directory to execute does not exist. ... nonex.robot Invalid input diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index c908eb8f4fa..c0cb348f628 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -95,7 +95,7 @@ def _normalize_paths(self, paths): try: return [Path(p).resolve(strict=True) for p in paths] except OSError as err: - raise DataError(f"Parsing '{err.filename}' failed: " + raise DataError(f"Parsing '{Path(err.filename).resolve()}' failed: " f"File or directory to execute does not exist.") def _build(self, path, included_suites): From 19a8f7b72a6d62850b63ad98cfdfe0ef20c2bfa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 12:18:50 +0200 Subject: [PATCH 0333/1592] Refactor building suites from parsing model. Also some more pathlib.Path usage. --- src/robot/libdocpkg/builder.py | 6 +++ src/robot/libdocpkg/model.py | 4 +- src/robot/parsing/__init__.py | 2 +- src/robot/running/builder/builders.py | 8 ++-- src/robot/running/builder/parsers.py | 52 ++++------------------- src/robot/running/builder/transformers.py | 50 +++++++++++++++++++--- src/robot/running/model.py | 5 ++- utest/libdoc/test_libdoc.py | 24 +++++------ 8 files changed, 82 insertions(+), 69 deletions(-) diff --git a/src/robot/libdocpkg/builder.py b/src/robot/libdocpkg/builder.py index 16574dae6a0..6ffe5b59253 100644 --- a/src/robot/libdocpkg/builder.py +++ b/src/robot/libdocpkg/builder.py @@ -14,6 +14,7 @@ # limitations under the License. import os +from pathlib import Path from robot.errors import DataError from robot.utils import get_error_message @@ -79,6 +80,11 @@ def __init__(self, library_or_resource=None): pass def build(self, source): + # Source can contain arguments separated with `::` so we cannot convert + # it to Path and instead need to make sure it's a string. It would be + # better to separate arguments earlier, or latest here, and use Path. + if isinstance(source, Path): + source = str(source) builder = self._get_builder(source) return self._build(builder, source) diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index c483df08d68..69c9f73344e 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -121,7 +121,7 @@ def to_dictionary(self, include_private=False, theme=None): 'type': self.type, 'scope': self.scope, 'docFormat': self.doc_format, - 'source': str(self.source) if self.source else '', + 'source': str(self.source) if self.source else None, 'lineno': self.lineno, 'tags': list(self.all_tags), 'inits': [init.to_dictionary() for init in self.inits], @@ -192,7 +192,7 @@ def to_dictionary(self): 'doc': self.doc, 'shortdoc': self.shortdoc, 'tags': list(self.tags), - 'source': self.source, + 'source': str(self.source) if self.source else None, 'lineno': self.lineno } if self.private: diff --git a/src/robot/parsing/__init__.py b/src/robot/parsing/__init__.py index 09589f41ab9..ff5930ac743 100644 --- a/src/robot/parsing/__init__.py +++ b/src/robot/parsing/__init__.py @@ -22,6 +22,6 @@ """ from .lexer import get_tokens, get_resource_tokens, get_init_tokens, Token -from .model import ModelTransformer, ModelVisitor +from .model import File, ModelTransformer, ModelVisitor from .parser import get_model, get_resource_model, get_init_model from .suitestructure import SuiteStructureBuilder, SuiteStructureVisitor diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 083c769a429..74ceb72b337 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os +from pathlib import Path from robot.errors import DataError from robot.output import LOGGER @@ -202,7 +202,9 @@ def __init__(self, lang=None, process_curdir=True): self.lang = lang self.process_curdir = process_curdir - def build(self, source): + def build(self, source: Path): + if not isinstance(source, Path): + source = Path(source) LOGGER.info(f"Parsing resource file '{source}'.") resource = self._parse(source) if resource.imports or resource.variables or resource.keywords: @@ -213,6 +215,6 @@ def build(self, source): return resource def _parse(self, source): - if os.path.splitext(source)[1].lower() in ('.rst', '.rest'): + if source.suffix.lower() in ('.rst', '.rest'): return RestParser(self.lang, self.process_curdir).parse_resource_file(source) return RobotParser(self.lang, self.process_curdir).parse_resource_file(source) diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 169a035a3f3..7f2029ef4b6 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -13,18 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os -from ast import NodeVisitor from pathlib import Path -from robot.errors import DataError -from robot.output import LOGGER -from robot.parsing import get_model, get_resource_model, get_init_model, Token +from robot.parsing import get_init_model, get_model, get_resource_model from robot.utils import FileReader, read_rest_data from .settings import Defaults -from .transformers import SuiteBuilder, SettingsBuilder, ResourceBuilder -from ..model import TestSuite, ResourceFile +from .transformers import ResourceBuilder, SuiteBuilder +from ..model import ResourceFile, TestSuite class BaseParser: @@ -56,28 +52,21 @@ def parse_suite_file(self, source, defaults=None): suite = TestSuite(name=name, source=source) return self._build(suite, source, defaults) - def build_suite(self, model, name=None, defaults=None): + def parse_model(self, model, defaults=None): source = model.source - name = name or TestSuite.name_from_source(source) + name = TestSuite.name_from_source(source) suite = TestSuite(name=name, source=source) return self._build(suite, source, defaults, model) def _build(self, suite, source, defaults, model=None, get_model=get_model): - if defaults is None: - defaults = Defaults() if model is None: model = get_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) - ErrorReporter(source).visit(model) - SettingsBuilder(suite, defaults).visit(model) - SuiteBuilder(suite, defaults).visit(model) - suite.rpa = self._get_rpa_mode(model) + SuiteBuilder(suite, defaults).build(model) return suite def _get_curdir(self, source): - if not self.process_curdir: - return None - return os.path.dirname(source).replace('\\', '\\\\') + return str(source.parent).replace('\\', '\\\\') if self.process_curdir else None def _get_source(self, source): return source @@ -86,18 +75,9 @@ def parse_resource_file(self, source): model = get_resource_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) resource = ResourceFile(source=source) - ErrorReporter(source).visit(model) - ResourceBuilder(resource).visit(model) + ResourceBuilder(resource).build(model) return resource - def _get_rpa_mode(self, data): - if not data: - return None - tasks = [s.tasks for s in data.sections if hasattr(s, 'tasks')] - if all(tasks) or not any(tasks): - return tasks[0] if tasks else None - raise DataError('One file cannot have both tests and tasks.') - class RestParser(RobotParser): @@ -111,19 +91,3 @@ class NoInitFileDirectoryParser(BaseParser): def parse_init_file(self, source, defaults=None): name = TestSuite.name_from_source(source) return TestSuite(name=name, source=source) - - -class ErrorReporter(NodeVisitor): - - def __init__(self, source): - self.source = source - - def visit_Error(self, node): - fatal = node.get_token(Token.FATAL_ERROR) - if fatal: - raise DataError(self._format_message(fatal)) - for error in node.get_tokens(Token.ERROR): - LOGGER.error(self._format_message(error)) - - def _format_message(self, token): - return f"Error in file '{self.source}' on line {token.lineno}: {token.error}" diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 613c0a6b462..33c600e84fc 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -15,15 +15,18 @@ from ast import NodeVisitor +from robot.errors import DataError from robot.output import LOGGER +from robot.parsing import File, Token from robot.variables import VariableIterator from .settings import Defaults, TestSettings +from ..model import ResourceFile, TestSuite class SettingsBuilder(NodeVisitor): - def __init__(self, suite, defaults): + def __init__(self, suite: TestSuite, defaults: Defaults): self.suite = suite self.defaults = defaults @@ -87,9 +90,17 @@ def visit_KeywordSection(self, node): class SuiteBuilder(NodeVisitor): - def __init__(self, suite, defaults): + def __init__(self, suite: TestSuite, defaults: Defaults = None): self.suite = suite - self.defaults = defaults + self.defaults = defaults or Defaults() + self.rpa = None + + def build(self, model: File): + ErrorReporter(model.source).visit(model) + SettingsBuilder(self.suite, self.defaults).visit(model) + self.visit(model) + if self.rpa is not None: + self.suite.rpa = self.rpa def visit_SettingSection(self, node): pass @@ -100,6 +111,13 @@ def visit_Variable(self, node): lineno=node.lineno, error=format_error(node.errors)) + def visit_TestCaseSection(self, node): + if self.rpa is None: + self.rpa = node.tasks + elif self.rpa != node.tasks: + raise DataError('One file cannot have both tests and tasks.') + self.generic_visit(node) + def visit_TestCase(self, node): TestCaseBuilder(self.suite, self.defaults).visit(node) @@ -109,10 +127,14 @@ def visit_Keyword(self, node): class ResourceBuilder(NodeVisitor): - def __init__(self, resource): + def __init__(self, resource: ResourceFile): self.resource = resource self.defaults = Defaults() + def build(self, model: File): + ErrorReporter(model.source).visit(model) + self.visit(model) + def visit_Documentation(self, node): self.resource.doc = node.value @@ -140,7 +162,7 @@ def visit_Keyword(self, node): class TestCaseBuilder(NodeVisitor): - def __init__(self, suite, defaults): + def __init__(self, suite: TestSuite, defaults: Defaults): self.suite = suite self.settings = TestSettings(defaults) self.test = None @@ -243,7 +265,7 @@ def visit_Break(self, node): class KeywordBuilder(NodeVisitor): - def __init__(self, resource, defaults): + def __init__(self, resource: ResourceFile, defaults: Defaults): self.resource = resource self.defaults = defaults self.kw = None @@ -562,3 +584,19 @@ def deprecate_tags_starting_with_hyphen(node, source): f"for removing tags. Escape '{tag}' like '\\{tag}' to use the " f"literal value and to avoid this warning." ) + + +class ErrorReporter(NodeVisitor): + + def __init__(self, source): + self.source = source + + def visit_Error(self, node): + fatal = node.get_token(Token.FATAL_ERROR) + if fatal: + raise DataError(self._format_message(fatal)) + for error in node.get_tokens(Token.ERROR): + LOGGER.error(self._format_message(error)) + + def _format_message(self, token): + return f"Error in file '{self.source}' on line {token.lineno}: {token.error}" diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 84e849b7010..4d11c8d2578 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -395,7 +395,10 @@ def from_model(cls, model, name=None): New in Robot Framework 3.2. """ from .builder import RobotParser - return RobotParser().build_suite(model, name) + suite = RobotParser().parse_model(model) + if name is not None: + suite.name = name + return suite def configure(self, randomize_suites=False, randomize_tests=False, randomize_seed=None, **options): diff --git a/utest/libdoc/test_libdoc.py b/utest/libdoc/test_libdoc.py index 3109c1b6c36..689c0d37a27 100644 --- a/utest/libdoc/test_libdoc.py +++ b/utest/libdoc/test_libdoc.py @@ -1,8 +1,8 @@ import json import os -from os.path import dirname, join, normpath -import unittest import tempfile +import unittest +from pathlib import Path from jsonschema import validate @@ -15,9 +15,9 @@ get_shortdoc = HtmlToText().get_shortdoc_from_html get_text = HtmlToText().html_to_plain_text -CURDIR = dirname(__file__) -DATADIR = normpath(join(CURDIR, '../../atest/testdata/libdoc/')) -TEMPDIR = os.getenv('TEMPDIR') or tempfile.gettempdir() +CURDIR = Path(__file__).resolve().parent +DATADIR = (CURDIR / '../../atest/testdata/libdoc/').resolve() +TEMPDIR = Path(os.getenv('TEMPDIR') or tempfile.gettempdir()) try: from typing_extensions import TypedDict @@ -39,10 +39,10 @@ def verify_keyword_shortdoc(doc_format, doc_input, expected): def run_libdoc_and_validate_json(filename): - library = join(DATADIR, filename) + library = DATADIR / filename json_spec = LibraryDocumentation(library).to_json() - with open(join(CURDIR, '../../doc/schema/libdoc.json')) as f: - schema = json.load(f) + with open(CURDIR / '../../doc/schema/libdoc.json') as file: + schema = json.load(file) validate(instance=json.loads(json_spec), schema=schema) @@ -233,7 +233,7 @@ def test_roundtrip_with_datatypes(self): self._test('DataTypesLibrary.json') def _test(self, lib): - path = join(DATADIR, lib) + path = DATADIR / lib spec = LibraryDocumentation(path).to_json() data = json.loads(spec) with open(path) as f: @@ -252,8 +252,8 @@ def test_roundtrip_with_datatypes(self): self._test('DataTypesLibrary.json') def _test(self, lib): - path = join(TEMPDIR, 'libdoc-utest-spec.xml') - orig_lib = LibraryDocumentation(join(DATADIR, lib)) + path = TEMPDIR / 'libdoc-utest-spec.xml' + orig_lib = LibraryDocumentation(DATADIR / lib) orig_lib.save(path, format='XML') spec_lib = LibraryDocumentation(path) orig_data = orig_lib.to_dictionary() @@ -266,7 +266,7 @@ def _test(self, lib): class TestLibdocTypedDictKeys(unittest.TestCase): def test_typed_dict_keys(self): - library = join(DATADIR, 'DataTypesLibrary.py') + library = DATADIR / 'DataTypesLibrary.py' spec = LibraryDocumentation(library).to_json() current_items = json.loads(spec)['dataTypes']['typedDicts'][0]['items'] expected_items = [ From 3d73ed2ace4eefd0d9586d2d7d26199e0d53d2d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 13:25:12 +0200 Subject: [PATCH 0334/1592] pathlib.Path FTW! --- src/robot/running/model.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 4d11c8d2578..5a839d30069 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -33,7 +33,7 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface """ -import os +from pathlib import Path from robot import model from robot.conf import RobotSettings @@ -696,16 +696,13 @@ def _repr(self, repr_args): return super()._repr(repr_args) @property - def source(self): + def source(self) -> Path: return self.parent.source if self.parent is not None else None @property - def directory(self): - if not self.source: - return None - if os.path.isdir(self.source): - return self.source - return os.path.dirname(self.source) + def directory(self) -> Path: + source = self.source + return source.parent if source and source.is_file() else source @property def setting_name(self): From b5592f45d00cb8b45fc1ff388592950377ee532c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 13:54:43 +0200 Subject: [PATCH 0335/1592] Make deprecation warning more visible. --- src/robot/utils/argumentparser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/robot/utils/argumentparser.py b/src/robot/utils/argumentparser.py index b73a67f40e7..f309229d711 100644 --- a/src/robot/utils/argumentparser.py +++ b/src/robot/utils/argumentparser.py @@ -71,12 +71,11 @@ def __init__(self, usage, name=None, version=None, arg_limits=None, self._validator = validator self._auto_help = auto_help self._auto_version = auto_version - # TODO: Change DeprecationWarning to more loud UserWarning in RF 6.1. if auto_pythonpath == 'DEPRECATED': auto_pythonpath = False else: warnings.warn("ArgumentParser option 'auto_pythonpath' is deprecated " - "since Robot Framework 5.0.", DeprecationWarning) + "since Robot Framework 5.0.") self._auto_pythonpath = auto_pythonpath self._auto_argumentfile = auto_argumentfile self._env_options = env_options From 679a3aec2f4c7c73cc8576092351d45b15511b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 14:11:39 +0200 Subject: [PATCH 0336/1592] Deprecate TestSuite.from_model's name argument. Fixes #4598. --- src/robot/running/model.py | 11 +++++++++++ utest/running/test_run_model.py | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 5a839d30069..1d4839861f3 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -33,6 +33,7 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface """ +import warnings from pathlib import Path from robot import model @@ -392,11 +393,21 @@ def from_model(cls, model, name=None): :func:`~robot.parsing.parser.parser.get_model` function and possibly modified by other tooling in the :mod:`robot.parsing` module. + The ``name`` argument is deprecated since Robot Framework 6.1. Users + should set the name and possible other attributes to the returned suite + separately. One easy way is using the :meth:`config` method like this:: + + suite = TestSuite.from_model(model).config(name='X', doc='Example') + New in Robot Framework 3.2. """ from .builder import RobotParser suite = RobotParser().parse_model(model) if name is not None: + # TODO: Change DeprecationWarning to more visible UserWarning in RF 6.2. + warnings.warn("'name' argument of 'TestSuite.from_model' is deprecated. " + "Set the name to the returned suite separately.", + DeprecationWarning) suite.name = name return suite diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index ade67cd24fc..21837df0c97 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -107,7 +107,11 @@ def test_from_model_containing_source(self): def test_from_model_with_custom_name(self): for source in [self.data, self.path]: model = api.get_model(source) - suite = TestSuite.from_model(model, name='Custom name') + with warnings.catch_warnings(record=True) as w: + suite = TestSuite.from_model(model, name='Custom name') + assert_equal(str(w[0].message), + "'name' argument of 'TestSuite.from_model' is deprecated. " + "Set the name to the returned suite separately.") self._verify_suite(suite, 'Custom name') def _verify_suite(self, suite, name='Test Run Model', rpa=False): From e17170afc6985da722cd9ecd631cc581b6dd0b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 14:33:04 +0200 Subject: [PATCH 0337/1592] Add TestSuite.from_string. Fixes #4601. --- src/robot/running/model.py | 14 ++++++++++++++ utest/running/test_run_model.py | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 1d4839861f3..054fa1e7a09 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -411,6 +411,20 @@ def from_model(cls, model, name=None): suite.name = name return suite + @classmethod + def from_string(cls, string, **config): + """Create a :class:`TestSuite` object based on the given ``string``. + + The string is internally parsed into a model by using the + :func:`~robot.parsing.parser.parser.get_model` function and ``config`` + can be used to configure it. The model is then converted into a suite + by using :meth:`from_model`. + + New in Robot Framework 6.1. + """ + from robot.parsing import get_model + return cls.from_model(get_model(string, data_only=True, **config)) + def configure(self, randomize_suites=False, randomize_tests=False, randomize_seed=None, **options): """A shortcut to configure a suite using one method call. diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 21837df0c97..bc7d7a4248b 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -114,6 +114,15 @@ def test_from_model_with_custom_name(self): "Set the name to the returned suite separately.") self._verify_suite(suite, 'Custom name') + def test_from_string(self): + suite = TestSuite.from_string(self.data) + self._verify_suite(suite, name='') + + def test_from_string_config(self): + suite = TestSuite.from_string(self.data.replace('Test Cases', 'Testit'), + lang='Finnish', curdir='.') + self._verify_suite(suite, name='') + def _verify_suite(self, suite, name='Test Run Model', rpa=False): assert_equal(suite.name, name) assert_equal(suite.doc, 'Some text.') From 2a931bd316a4df374a18bd78c5d50764e2036805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 14:56:09 +0200 Subject: [PATCH 0338/1592] Add tests and docs to ItemList.to_dicts. This method was added earlier when implementing `to_json` functionality. Now it also works with items not having `to_dict` (assuming they suppor `vars()`). --- src/robot/model/itemlist.py | 18 ++++++++++++++---- utest/model/test_itemlist.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index 98d890dc4a8..1aef82b95d1 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -28,10 +28,10 @@ class ItemList(MutableSequence): In addition to the common type, items can have certain common and automatically assigned attributes. - Starting from RF 6.1, items can be added as dictionaries and actual items - are generated based on them automatically. If the type has a ``from_dict`` - classmethod, it is used, and otherwise dictionary data is passed to - the type as keyword arguments. + Starting from Robot Framework 6.1, items can be added as dictionaries and + actual items are generated based on them automatically. If the type has + a ``from_dict`` class method, it is used, and otherwise dictionary data is + passed to the type as keyword arguments. """ __slots__ = ['_item_class', '_common_attrs', '_items'] @@ -44,6 +44,7 @@ def __init__(self, item_class, common_attrs=None, items=None): self.extend(items) def create(self, *args, **kwargs): + """Create a new item using the provided arguments.""" return self.append(self._item_class(*args, **kwargs)) def append(self, item): @@ -183,4 +184,13 @@ def __rmul__(self, other): return self * other def to_dicts(self): + """Return list of items converted to dictionaries. + + Items are converted to dictionaries using the ``to_dict`` method, if + they have it, or the built-in ``vars()``. + + New in Robot Framework 6.1. + """ + if not hasattr(self._item_class, 'to_dict'): + return [vars(item) for item in self] return [item.to_dict() for item in self] diff --git a/utest/model/test_itemlist.py b/utest/model/test_itemlist.py index 531d16dc539..a4af8ae1e50 100644 --- a/utest/model/test_itemlist.py +++ b/utest/model/test_itemlist.py @@ -11,6 +11,9 @@ class Object: def __init__(self, id=None): self.id = id + def __eq__(self, other): + return isinstance(other, Object) and self.id == other.id + class CustomItems(ItemList): pass @@ -412,6 +415,20 @@ def from_dict(cls, data): assert_equal(items[1].attr, 1) assert_equal(items[2].new, 3) + def test_to_dicts_without_to_dict(self): + items = ItemList(Object, items=[Object(1), Object(2)]) + dicts = items.to_dicts() + assert_equal(dicts, [{'id': 1}, {'id': 2}]) + assert_equal(ItemList(Object, items=dicts), items) + + def test_to_dicts_with_to_dict(self): + class ObjectWithToDict(Object): + def to_dict(self): + return {'id': self.id, 'x': 42} + + items = ItemList(ObjectWithToDict, items=[ObjectWithToDict(1)]) + assert_equal(items.to_dicts(), [{'id': 1, 'x': 42}]) + if __name__ == '__main__': unittest.main() From c14f80b2118f0641ff9f0b7af2f6302c8589a8d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 20:38:39 +0200 Subject: [PATCH 0339/1592] Avoid using Path.resolve(). It resolves all symlinks which isn't desired. Fixes #4600. --- atest/robot/cli/runner/invalid_usage.robot | 11 ++++++++--- atest/robot/cli/runner/multisource.robot | 2 +- src/robot/parsing/suitestructure.py | 16 +++++++++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/atest/robot/cli/runner/invalid_usage.robot b/atest/robot/cli/runner/invalid_usage.robot index f8124a0aca4..38e76f41c35 100644 --- a/atest/robot/cli/runner/invalid_usage.robot +++ b/atest/robot/cli/runner/invalid_usage.robot @@ -3,6 +3,9 @@ Test Setup Create Output Directory Resource cli_resource.robot Test Template Run Should Fail +*** Variables *** +${VALID} ${DATA DIR}/${TEST FILE} + *** Test Cases *** No Input ${EMPTY} Expected at least 1 argument, got 0. @@ -14,14 +17,16 @@ Non-Existing Input nonexisting.robot Parsing '${EXECDIR}${/}nonexisting.robot' failed: File or directory to execute does not exist. Non-Existing Input With Non-Ascii Characters - eitäällä.robot Parsing '${EXECDIR}${/}eitäällä.robot' failed: File or directory to execute does not exist. + nö.röböt ${VALID} bäd + ... Parsing '${EXECDIR}${/}nö.röböt' and '${EXECDIR}${/}bäd' failed: File or directory to execute does not exist. Invalid Output Directory [Setup] Create File %{TEMPDIR}/not-dir - -d %{TEMPDIR}/not-dir/dir ${DATADIR}/${TEST FILE} + -d %{TEMPDIR}/not-dir/dir ${VALID} ... Creating output file directory '.*not-dir.dir' failed: .* regexp=True - -d %{TEMPDIR}/not-dir/dir -o %{TEMPDIR}/out.xml ${DATADIR}/${TEST FILE} + -d %{TEMPDIR}/not-dir/dir -o %{TEMPDIR}/out.xml ${VALID} ... Creating report file directory '.*not-dir.dir' failed: .* regexp=True + [Teardown] Remove File %{TEMPDIR}/not-dir Invalid Options --invalid option option --invalid not recognized diff --git a/atest/robot/cli/runner/multisource.robot b/atest/robot/cli/runner/multisource.robot index 86fa75c373d..6781c3b9a3e 100644 --- a/atest/robot/cli/runner/multisource.robot +++ b/atest/robot/cli/runner/multisource.robot @@ -72,4 +72,4 @@ Failure When Parsing Any Data Source Fails Warnings And Error When Parsing All Data Sources Fail Run Tests Without Processing Output ${EMPTY} nönex1 nönex2 ${nönex} = Normalize Path ${DATADIR}/nönex - Stderr Should Contain [ ERROR ] Parsing '${nönex}1' failed: File or directory to execute does not exist. + Stderr Should Contain [ ERROR ] Parsing '${nönex}1' and '${nönex}2' failed: File or directory to execute does not exist. diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index c0cb348f628..c12953c369d 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -13,13 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +from os.path import normpath from pathlib import Path from typing import List from robot.errors import DataError from robot.model import SuiteNamePatterns from robot.output import LOGGER -from robot.utils import get_error_message +from robot.utils import get_error_message, seq2str class SuiteStructure: @@ -92,11 +93,16 @@ def build(self, paths): def _normalize_paths(self, paths): if not paths: raise DataError('One or more source paths required.') - try: - return [Path(p).resolve(strict=True) for p in paths] - except OSError as err: - raise DataError(f"Parsing '{Path(err.filename).resolve()}' failed: " + # Cannot use `Path.resolve()` here because it resolves all symlinks which + # isn't desired. `Path` doesn't have any methods for normalizing paths + # so need to use `os.path.normpath()`. Also that _may_ resolve symlinks, + # but we need to do it for backwards compatibility. + paths = [Path(normpath(p)).absolute() for p in paths] + non_existing = [p for p in paths if not p.exists()] + if non_existing: + raise DataError(f"Parsing {seq2str(non_existing)} failed: " f"File or directory to execute does not exist.") + return paths def _build(self, path, included_suites): if path.is_file(): From 60232cba96d4d3bdf299b5e0116a8a8cf07d6189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 14 Jan 2023 21:28:29 +0200 Subject: [PATCH 0340/1592] Windows test fixes --- atest/robot/output/source_and_lineno_output.robot | 2 +- utest/model/test_modelobject.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/atest/robot/output/source_and_lineno_output.robot b/atest/robot/output/source_and_lineno_output.robot index b0cccedec3e..2f05bf68c58 100644 --- a/atest/robot/output/source_and_lineno_output.robot +++ b/atest/robot/output/source_and_lineno_output.robot @@ -3,7 +3,7 @@ Resource atest_resource.robot Suite Setup Run Tests ${EMPTY} misc/suites/subsuites2 *** Variables *** -${SOURCE} ${{pathlib.Path('${DATADIR}/misc/suites/subsuites2')}} +${SOURCE} ${{pathlib.Path(r'${DATADIR}/misc/suites/subsuites2')}} *** Test Cases *** Suite source and test lineno in output after execution diff --git a/utest/model/test_modelobject.py b/utest/model/test_modelobject.py index 2918cadf06e..218c5ea4ba9 100644 --- a/utest/model/test_modelobject.py +++ b/utest/model/test_modelobject.py @@ -76,7 +76,7 @@ def test_json_as_open_file(self): assert_equal(obj.c, "åäö") def test_json_as_path(self): - with tempfile.NamedTemporaryFile('w', delete=False) as file: + with tempfile.NamedTemporaryFile('w', encoding='UTF-8', delete=False) as file: file.write('{"a": null, "b": 42, "c": "åäö"}') try: for path in file.name, pathlib.Path(file.name): @@ -142,7 +142,7 @@ def test_write_to_path(self): for config in {}, self.custom_config: Example(**self.data).to_json(path, **config) expected = json.dumps(self.data, **(config or self.default_config)) - with open(path) as file: + with open(path, encoding='UTF-8') as file: assert_equal(file.read(), expected) finally: os.remove(file.name) From e1b19a9c8ddefec9987f336a73c015dbd2adf300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 15 Jan 2023 02:58:32 +0200 Subject: [PATCH 0341/1592] Fine tune JSON serialization. #3902 --- src/robot/running/model.py | 32 ++++++++++++++++++++++++-------- utest/running/test_run_model.py | 18 ++++++++++-------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 054fa1e7a09..7a48eec6a53 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -363,12 +363,13 @@ def __init__(self, name='', doc='', metadata=None, source=None, rpa=None): #: :class:`ResourceFile` instance containing imports, variables and #: keywords the suite owns. When data is parsed from the file system, #: this data comes from the same test case file that creates the suite. - self.resource = ResourceFile(source) + self.resource = ResourceFile(parent=self) @setter def resource(self, resource): if isinstance(resource, dict): resource = ResourceFile.from_dict(resource) + resource.parent = self return resource @classmethod @@ -538,7 +539,7 @@ def to_dict(self): class Variable(ModelObject): repr_args = ('name', 'value') - def __init__(self, name, value, parent=None, lineno=None, error=None): + def __init__(self, name, value=(), parent=None, lineno=None, error=None): self.name = name self.value = value self.parent = parent @@ -560,7 +561,7 @@ def from_dict(cls, data): return cls(**data) def to_dict(self): - data = {'name': self.name, 'value': self.value} + data = {'name': self.name, 'value': list(self.value)} if self.lineno: data['lineno'] = self.lineno if self.error: @@ -570,15 +571,30 @@ def to_dict(self): class ResourceFile(ModelObject): repr_args = ('source',) - __slots__ = ('source', 'doc') + __slots__ = ('_source', 'parent', 'doc') - def __init__(self, source=None, doc=''): - self.source = source + def __init__(self, source=None, parent=None, doc=''): + self._source = source + self.parent = parent self.doc = doc self.imports = [] self.variables = [] self.keywords = [] + @property + def source(self): + if self._source: + return self._source + if self.parent: + return self.parent.source + return None + + @source.setter + def source(self, source): + if not isinstance(source, (Path, type(None))): + source = Path(source) + self._source = source + @setter def imports(self, imports): return Imports(self, imports) @@ -593,8 +609,8 @@ def keywords(self, keywords): def to_dict(self): data = {} - if self.source: - data['source'] = self.source + if self._source: + data['source'] = str(self.source) if self.doc: data['doc'] = self.doc if self.imports: diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index bc7d7a4248b..d1c8b91806f 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -338,7 +338,7 @@ def test_suite(self): self._verify(TestSuite(), name='', resource={}) self._verify(TestSuite('N', 'D', {'M': 'V'}, 'x.robot', rpa=True), name='N', doc='D', metadata={'M': 'V'}, source='x.robot', rpa=True, - resource={'source': 'x.robot'}) + resource={}) def test_suite_structure(self): suite = TestSuite('Root') @@ -383,12 +383,12 @@ def test_user_keyword_structure(self): def test_resource_file(self): self._verify(ResourceFile()) - resource = ResourceFile('x.resource', 'doc') + resource = ResourceFile('x.resource', doc='doc') resource.imports.library('L', 'a', 'A', 1) resource.imports.resource('R', 2) resource.imports.variables('V', 'a', 3) - resource.variables.create('${x}', 'value') - resource.variables.create('@{y}', ['v1', 'v2'], lineno=4) + resource.variables.create('${x}', ('value',)) + resource.variables.create('@{y}', ('v1', 'v2'), lineno=4) resource.variables.create('&{z}', ['k=v'], error='E') resource.keywords.create('UK').body.create_keyword('K') self._verify(resource, @@ -399,7 +399,7 @@ def test_resource_file(self): {'type': 'RESOURCE', 'name': 'R', 'lineno': 2}, {'type': 'VARIABLES', 'name': 'V', 'args': ['a'], 'lineno': 3}], - variables=[{'name': '${x}', 'value': 'value'}, + variables=[{'name': '${x}', 'value': ['value']}, {'name': '@{y}', 'value': ['v1', 'v2'], 'lineno': 4}, {'name': '&{z}', 'value': ['k=v'], 'error': 'E'}], keywords=[{'name': 'UK', 'body': [{'name': 'K'}]}]) @@ -410,10 +410,12 @@ def test_bigger_suite_structure(self): def _verify(self, obj, **expected): data = obj.to_dict() - assert_equal(data, expected) - assert_equal(list(data), list(expected)) + self.assertListEqual(list(data), list(expected)) + self.assertDictEqual(data, expected) roundtrip = type(obj).from_dict(data).to_dict() - assert_equal(roundtrip, expected) + self.assertDictEqual(roundtrip, expected) + roundtrip = type(obj).from_json(obj.to_json()).to_dict() + self.assertDictEqual(roundtrip, expected) if __name__ == '__main__': From 8fb2d0edfd350cce2f83400c350a492ce97c8372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 15 Jan 2023 13:58:14 +0200 Subject: [PATCH 0342/1592] Initial support to parse files in JSON format. #3902 --- src/robot/conf/settings.py | 2 +- src/robot/parsing/suitestructure.py | 2 +- src/robot/running/builder/builders.py | 12 +++++++----- src/robot/running/builder/parsers.py | 12 ++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index c77de89d194..3f08a586a04 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -453,7 +453,7 @@ def rpa(self, value): class RobotSettings(_BaseSettings): - _extra_cli_opts = {'Extension' : ('extension', ('.robot',)), + _extra_cli_opts = {'Extension' : ('extension', ('.robot', '.rbt')), 'Output' : ('output', 'output.xml'), 'LogLevel' : ('loglevel', 'INFO'), 'MaxErrorLines' : ('maxerrorlines', 40), diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index c12953c369d..356156ea3c0 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -72,7 +72,7 @@ class SuiteStructureBuilder: ignored_prefixes = ('_', '.') ignored_dirs = ('CVS',) - def __init__(self, included_extensions=('.robot',), included_suites=None): + def __init__(self, included_extensions=('.robot', '.rbt'), included_suites=None): self.included_extensions = included_extensions self.included_suites = None if not included_suites else \ SuiteNamePatterns(self._create_included_suites(included_suites)) diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 74ceb72b337..a7cf5a8810e 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -19,7 +19,7 @@ from robot.output import LOGGER from robot.parsing import SuiteStructureBuilder, SuiteStructureVisitor -from .parsers import RobotParser, NoInitFileDirectoryParser, RestParser +from .parsers import JsonParser, RobotParser, NoInitFileDirectoryParser, RestParser from .settings import Defaults @@ -46,9 +46,8 @@ class TestSuiteBuilder: :mod:`robot.api` package. """ - def __init__(self, included_suites=None, included_extensions=('.robot',), - rpa=None, lang=None, allow_empty_suite=False, - process_curdir=True): + def __init__(self, included_suites=None, included_extensions=('.robot', '.rbt'), + rpa=None, lang=None, allow_empty_suite=False, process_curdir=True): """ :param include_suites: List of suite names to include. If ``None`` or an empty list, all @@ -117,11 +116,14 @@ def __init__(self, included_extensions, rpa=None, lang=None, process_curdir=True def _get_parsers(self, extensions, lang, process_curdir): robot_parser = RobotParser(lang, process_curdir) rest_parser = RestParser(lang, process_curdir) + json_parser = JsonParser() parsers = { None: NoInitFileDirectoryParser(), 'robot': robot_parser, 'rst': rest_parser, - 'rest': rest_parser + 'rest': rest_parser, + 'rbt': json_parser, + 'json': json_parser } for ext in extensions: if ext not in parsers: diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 7f2029ef4b6..6c7a86617fd 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -86,6 +86,18 @@ def _get_source(self, source): return read_rest_data(reader) +class JsonParser(BaseParser): + + def parse_suite_file(self, source: Path, defaults: Defaults = None): + return TestSuite.from_json(source) + + def parse_init_file(self, source: Path, defaults: Defaults = None): + return TestSuite.from_json(source) + + def parse_resource_file(self, source: Path): + return ResourceFile.from_json(source) + + class NoInitFileDirectoryParser(BaseParser): def parse_init_file(self, source, defaults=None): From 291b79b941794cd9b62ebf8026b6febc8f5897b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 15 Jan 2023 18:48:23 +0200 Subject: [PATCH 0343/1592] Fix passing source info to start/end_keyword w/ Run Keyword. Fixes #4604. --- .../lineno_and_source.robot | 128 ++++++++++++------ .../listener_interface/LinenoAndSource.py | 1 + .../lineno_and_source.resource | 4 + .../lineno_and_source.robot | 19 +++ src/robot/libraries/BuiltIn.py | 13 +- src/robot/running/context.py | 8 +- 6 files changed, 124 insertions(+), 49 deletions(-) diff --git a/atest/robot/output/listener_interface/lineno_and_source.robot b/atest/robot/output/listener_interface/lineno_and_source.robot index 2214c99a763..fe0558f4c5a 100644 --- a/atest/robot/output/listener_interface/lineno_and_source.robot +++ b/atest/robot/output/listener_interface/lineno_and_source.robot @@ -15,12 +15,12 @@ Keyword END KEYWORD No Operation 6 PASS User keyword - START KEYWORD User Keyword 9 NOT SET - START KEYWORD No Operation 85 NOT SET - END KEYWORD No Operation 85 PASS - START RETURN ${EMPTY} 86 NOT SET - END RETURN ${EMPTY} 86 PASS - END KEYWORD User Keyword 9 PASS + START KEYWORD User Keyword 9 NOT SET + START KEYWORD No Operation 101 NOT SET + END KEYWORD No Operation 101 PASS + START RETURN ${EMPTY} 102 NOT SET + END RETURN ${EMPTY} 102 PASS + END KEYWORD User Keyword 9 PASS User keyword in resource START KEYWORD User Keyword In Resource 12 NOT SET @@ -49,14 +49,14 @@ FOR END FOR \${x} IN [ first | second ] 21 PASS FOR in keyword - START KEYWORD FOR In Keyword 26 NOT SET - START FOR \${x} IN [ once ] 89 NOT SET - START ITERATION \${x} = once 89 NOT SET - START KEYWORD No Operation 90 NOT SET - END KEYWORD No Operation 90 PASS - END ITERATION \${x} = once 89 PASS - END FOR \${x} IN [ once ] 89 PASS - END KEYWORD FOR In Keyword 26 PASS + START KEYWORD FOR In Keyword 26 NOT SET + START FOR \${x} IN [ once ] 105 NOT SET + START ITERATION \${x} = once 105 NOT SET + START KEYWORD No Operation 106 NOT SET + END KEYWORD No Operation 106 PASS + END ITERATION \${x} = once 105 PASS + END FOR \${x} IN [ once ] 105 PASS + END KEYWORD FOR In Keyword 26 PASS FOR in IF START IF True 29 NOT SET @@ -93,14 +93,14 @@ IF END ELSE ${EMPTY} 43 NOT RUN IF in keyword - START KEYWORD IF In Keyword 48 NOT SET - START IF True 94 NOT SET - START KEYWORD No Operation 95 NOT SET - END KEYWORD No Operation 95 PASS - START RETURN ${EMPTY} 96 NOT SET - END RETURN ${EMPTY} 96 PASS - END IF True 94 PASS - END KEYWORD IF In Keyword 48 PASS + START KEYWORD IF In Keyword 48 NOT SET + START IF True 110 NOT SET + START KEYWORD No Operation 111 NOT SET + END KEYWORD No Operation 111 PASS + START RETURN ${EMPTY} 112 NOT SET + END RETURN ${EMPTY} 112 PASS + END IF True 110 PASS + END KEYWORD IF In Keyword 48 PASS IF in FOR START FOR \${x} IN [ 1 | 2 ] 52 NOT SET @@ -156,24 +156,24 @@ TRY TRY in keyword START KEYWORD TRY In Keyword 78 NOT SET - START TRY ${EMPTY} 100 NOT SET - START RETURN ${EMPTY} 101 NOT SET - END RETURN ${EMPTY} 101 PASS - START KEYWORD Fail 102 NOT RUN - END KEYWORD Fail 102 NOT RUN - END TRY ${EMPTY} 100 PASS - START EXCEPT No match AS \${var} 103 NOT RUN - START KEYWORD Fail 104 NOT RUN - END KEYWORD Fail 104 NOT RUN - END EXCEPT No match AS \${var} 103 NOT RUN - START EXCEPT No | Match | 2 AS \${x} 105 NOT RUN - START KEYWORD Fail 106 NOT RUN - END KEYWORD Fail 106 NOT RUN - END EXCEPT No | Match | 2 AS \${x} 105 NOT RUN - START EXCEPT ${EMPTY} 107 NOT RUN - START KEYWORD Fail 108 NOT RUN - END KEYWORD Fail 108 NOT RUN - END EXCEPT ${EMPTY} 107 NOT RUN + START TRY ${EMPTY} 116 NOT SET + START RETURN ${EMPTY} 117 NOT SET + END RETURN ${EMPTY} 117 PASS + START KEYWORD Fail 118 NOT RUN + END KEYWORD Fail 118 NOT RUN + END TRY ${EMPTY} 116 PASS + START EXCEPT No match AS \${var} 119 NOT RUN + START KEYWORD Fail 120 NOT RUN + END KEYWORD Fail 120 NOT RUN + END EXCEPT No match AS \${var} 119 NOT RUN + START EXCEPT No | Match | 2 AS \${x} 121 NOT RUN + START KEYWORD Fail 122 NOT RUN + END KEYWORD Fail 122 NOT RUN + END EXCEPT No | Match | 2 AS \${x} 121 NOT RUN + START EXCEPT ${EMPTY} 123 NOT RUN + START KEYWORD Fail 124 NOT RUN + END KEYWORD Fail 124 NOT RUN + END EXCEPT ${EMPTY} 123 NOT RUN END KEYWORD TRY In Keyword 78 PASS TRY in resource @@ -188,6 +188,50 @@ TRY in resource END FINALLY ${EMPTY} 18 PASS source=${RESOURCE FILE} END KEYWORD TRY In Resource 81 PASS +Run Keyword + START KEYWORD Run Keyword 84 NOT SET + START KEYWORD Log 84 NOT SET + END KEYWORD Log 84 PASS + END KEYWORD Run Keyword 84 PASS + START KEYWORD Run Keyword If 85 NOT SET + START KEYWORD User Keyword 85 NOT SET + START KEYWORD No Operation 101 NOT SET + END KEYWORD No Operation 101 PASS + START RETURN ${EMPTY} 102 NOT SET + END RETURN ${EMPTY} 102 PASS + END KEYWORD User Keyword 85 PASS + END KEYWORD Run Keyword If 85 PASS + +Run Keyword in keyword + START KEYWORD Run Keyword in keyword 89 NOT SET + START KEYWORD Run Keyword 128 NOT SET + START KEYWORD No Operation 128 NOT SET + END KEYWORD No Operation 128 PASS + END KEYWORD Run Keyword 128 PASS + END KEYWORD Run Keyword in keyword 89 PASS + +Run Keyword in resource + START KEYWORD Run Keyword in resource 92 NOT SET + START KEYWORD Run Keyword 23 NOT SET source=${RESOURCE FILE} + START KEYWORD Log 23 NOT SET source=${RESOURCE FILE} + END KEYWORD Log 23 PASS source=${RESOURCE FILE} + END KEYWORD Run Keyword 23 PASS source=${RESOURCE FILE} + END KEYWORD Run Keyword in resource 92 PASS + +In setup and teardown + START SETUP User Keyword 95 NOT SET + START KEYWORD No Operation 101 NOT SET + END KEYWORD No Operation 101 PASS + START RETURN ${EMPTY} 102 NOT SET + END RETURN ${EMPTY} 102 PASS + END SETUP User Keyword 95 PASS + START KEYWORD No Operation 96 NOT SET + END KEYWORD No Operation 96 PASS + START TEARDOWN Run Keyword 97 NOT SET + START KEYWORD Log 97 NOT SET + END KEYWORD Log 97 PASS + END TEARDOWN Run Keyword 97 PASS + Test [Template] Expect test Keyword 5 @@ -205,6 +249,10 @@ Test \TRY 63 FAIL TRY in keyword 77 TRY in resource 80 + Run Keyword 83 + Run Keyword in keyword 88 + Run Keyword in resource 91 + In setup and teardown 94 [Teardown] Validate tests Suite diff --git a/atest/testdata/output/listener_interface/LinenoAndSource.py b/atest/testdata/output/listener_interface/LinenoAndSource.py index bf54b761cb6..bacc82738cb 100644 --- a/atest/testdata/output/listener_interface/LinenoAndSource.py +++ b/atest/testdata/output/listener_interface/LinenoAndSource.py @@ -30,6 +30,7 @@ def end_test(self, name, attrs): self.output.close() self.output = self.test_output self.report('END', type='TEST', name=name, **attrs) + self.output = self.suite_output def start_keyword(self, name, attrs): self.report('START', **attrs) diff --git a/atest/testdata/output/listener_interface/lineno_and_source.resource b/atest/testdata/output/listener_interface/lineno_and_source.resource index edb06bdd18e..ca18d6fa36d 100644 --- a/atest/testdata/output/listener_interface/lineno_and_source.resource +++ b/atest/testdata/output/listener_interface/lineno_and_source.resource @@ -18,3 +18,7 @@ TRY In Resource FINALLY Log Nothing interesting here either... END + +Run Keyword in resource + Run Keyword + ... Log resource diff --git a/atest/testdata/output/listener_interface/lineno_and_source.robot b/atest/testdata/output/listener_interface/lineno_and_source.robot index 4538ce4b200..4a3bd71882a 100644 --- a/atest/testdata/output/listener_interface/lineno_and_source.robot +++ b/atest/testdata/output/listener_interface/lineno_and_source.robot @@ -80,6 +80,22 @@ TRY in keyword TRY in resource TRY in resource +Run Keyword + Run Keyword Log Hello + Run Keyword If True + ... User Keyword + +Run Keyword in keyword + Run Keyword in keyword + +Run Keyword in resource + Run Keyword in resource + +In setup and teardown + [Setup] User Keyword + No operation + [Teardown] Run Keyword Log Hello! + *** Keywords *** User Keyword No Operation @@ -107,3 +123,6 @@ TRY In Keyword EXCEPT Fail Not executed! END + +Run Keyword in keyword + Run Keyword No Operation diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index ea64102409b..7fdb99fbef1 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -13,10 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import OrderedDict import difflib import re import time +from collections import OrderedDict from robot.api import logger, SkipExecution from robot.api.deco import keyword @@ -32,7 +32,7 @@ normalize_whitespace, parse_re_flags, parse_time, prepr, plural_or_not as s, RERAISED_EXCEPTIONS, safe_str, secs_to_timestr, seq2str, split_from_equals, - timestr_to_secs, type_name) + timestr_to_secs) from robot.utils.asserts import assert_equal, assert_not_equal from robot.variables import (evaluate_expression, is_dict_variable, is_list_variable, search_variable, @@ -1843,14 +1843,17 @@ def run_keyword(self, name, *args): can be a variable and thus set dynamically, e.g. from a return value of another keyword or from the command line. """ + ctx = self._context if (is_string(name) - and not self._context.dry_run + and not ctx.dry_run and not self._accepts_embedded_arguments(name)): name, args = self._replace_variables_in_name([name] + list(args)) if not is_string(name): raise RuntimeError('Keyword name must be a string.') - kw = Keyword(name, args=args) - return kw.run(self._context) + parent = ctx.keywords[-1] if ctx.keywords else (ctx.test or ctx.suite) + kw = Keyword(name, args=args, parent=parent, + lineno=getattr(parent, 'lineno', None)) + return kw.run(ctx) def _accepts_embedded_arguments(self, name): if '{' in name: diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 8df225f545f..b04655acb6f 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -64,8 +64,8 @@ def __init__(self, suite, namespace, output, dry_run=False): self.in_suite_teardown = False self.in_test_teardown = False self.in_keyword_teardown = 0 - self._started_keywords = 0 self.timeout_occurred = False + self.keywords = [] self.user_keywords = [] self.step_types = [] @@ -198,8 +198,8 @@ def end_test(self, test): self.timeout_occurred = False def start_keyword(self, keyword): - self._started_keywords += 1 - if self._started_keywords > self._started_keywords_threshold: + self.keywords.append(keyword) + if len(self.keywords) > self._started_keywords_threshold: raise DataError('Maximum limit of started keywords and control ' 'structures exceeded.') self.output.start_keyword(keyword) @@ -208,7 +208,7 @@ def start_keyword(self, keyword): def end_keyword(self, keyword): self.output.end_keyword(keyword) - self._started_keywords -= 1 + self.keywords.pop() if keyword.libname != 'BuiltIn': self.step_types.pop() From f405f90e7a7ed6222bf6ab7347c3eb70627bd94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 15 Jan 2023 19:56:16 +0200 Subject: [PATCH 0344/1592] Refactor --- src/robot/libraries/BuiltIn.py | 2 +- src/robot/running/context.py | 19 +++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 7fdb99fbef1..680e6e204a1 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1850,7 +1850,7 @@ def run_keyword(self, name, *args): name, args = self._replace_variables_in_name([name] + list(args)) if not is_string(name): raise RuntimeError('Keyword name must be a string.') - parent = ctx.keywords[-1] if ctx.keywords else (ctx.test or ctx.suite) + parent = ctx.steps[-1] if ctx.steps else (ctx.test or ctx.suite) kw = Keyword(name, args=args, parent=parent, lineno=getattr(parent, 'lineno', None)) return kw.run(ctx) diff --git a/src/robot/running/context.py b/src/robot/running/context.py index b04655acb6f..a283fe59d81 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -65,9 +65,8 @@ def __init__(self, suite, namespace, output, dry_run=False): self.in_test_teardown = False self.in_keyword_teardown = 0 self.timeout_occurred = False - self.keywords = [] + self.steps = [] self.user_keywords = [] - self.step_types = [] @contextmanager def suite_teardown(self): @@ -145,10 +144,10 @@ def continue_on_failure(self, default=False): @property def allow_loop_control(self): - for typ in reversed(self.step_types): - if typ == 'ITERATION': + for step in reversed(self.steps): + if step.type == 'ITERATION': return True - if typ == 'KEYWORD': + if step.type == 'KEYWORD' and step.libname != 'BuiltIn': return False return False @@ -198,19 +197,15 @@ def end_test(self, test): self.timeout_occurred = False def start_keyword(self, keyword): - self.keywords.append(keyword) - if len(self.keywords) > self._started_keywords_threshold: + self.steps.append(keyword) + if len(self.steps) > self._started_keywords_threshold: raise DataError('Maximum limit of started keywords and control ' 'structures exceeded.') self.output.start_keyword(keyword) - if keyword.libname != 'BuiltIn': - self.step_types.append(keyword.type) def end_keyword(self, keyword): self.output.end_keyword(keyword) - self.keywords.pop() - if keyword.libname != 'BuiltIn': - self.step_types.pop() + self.steps.pop() def get_runner(self, name): return self.namespace.get_runner(name) From e23a60d66edbc61e31444bc3eb74b139b4a13102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 18 Jan 2023 11:13:57 +0200 Subject: [PATCH 0345/1592] JSON serialization: Fix WHILE and FOR body #3902 --- src/robot/model/control.py | 8 ++++++-- utest/running/test_run_model.py | 13 +++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index b6c57805698..aeced4b7be6 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -58,7 +58,8 @@ def to_dict(self): return {'type': self.type, 'variables': list(self.variables), 'flavor': self.flavor, - 'values': list(self.values)} + 'values': list(self.values), + 'body': self.body.to_dicts()} @Body.register @@ -85,9 +86,12 @@ def __str__(self): return f'WHILE {self.condition}' + (f' {self.limit}' if self.limit else '') def to_dict(self): - data = {'type': self.type, 'condition': self.condition} + data = {'type': self.type} + if self.condition: + data['condition'] = self.condition if self.limit: data['limit'] = self.limit + data['body'] = self.body.to_dicts() return data diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index d1c8b91806f..21aaebac5e8 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -244,16 +244,17 @@ def test_keyword(self): name='Setup', lineno=1) def test_for(self): - self._verify(For(), type='FOR', variables=[], flavor='IN', values=[]) - self._verify(For(['${i}'], 'IN RANGE', ['10'], lineno=2), type='FOR', - variables=['${i}'], flavor='IN RANGE', values=['10'], lineno=2) + self._verify(For(), type='FOR', variables=[], flavor='IN', values=[], body=[]) + self._verify(For(['${i}'], 'IN RANGE', ['10'], lineno=2), + type='FOR', variables=['${i}'], flavor='IN RANGE', values=['10'], + body=[], lineno=2) def test_while(self): - self._verify(While(), type='WHILE', condition=None) + self._verify(While(), type='WHILE', body=[]) self._verify(While('1 > 0', '1 min'), - type='WHILE', condition='1 > 0', limit='1 min') + type='WHILE', condition='1 > 0', limit='1 min', body=[]) self._verify(While('True', lineno=3, error='x'), - type='WHILE', condition='True', lineno=3, error='x') + type='WHILE', condition='True', body=[], lineno=3, error='x') def test_if(self): self._verify(If(), type='IF/ELSE ROOT', body=[]) From dbc42be9c743897c9d481cce24a8dcf003a39213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 18 Jan 2023 15:57:38 +0200 Subject: [PATCH 0346/1592] User kws: Support embedded and normal args in same kw Fixes #4234 --- atest/robot/cli/dryrun/dryrun.robot | 8 +++++-- atest/robot/keywords/embedded_arguments.robot | 15 +++++++------ .../trace_log_keyword_arguments.robot | 1 + atest/testdata/cli/dryrun/dryrun.robot | 6 ++++++ .../keywords/embedded_arguments.robot | 21 ++++++++++++------- .../trace_log_keyword_arguments.robot | 5 +++++ src/robot/running/userkeyword.py | 2 -- src/robot/running/userkeywordrunner.py | 21 ++++++++++++------- 8 files changed, 54 insertions(+), 25 deletions(-) diff --git a/atest/robot/cli/dryrun/dryrun.robot b/atest/robot/cli/dryrun/dryrun.robot index a48b4e54116..a276e0f56bf 100644 --- a/atest/robot/cli/dryrun/dryrun.robot +++ b/atest/robot/cli/dryrun/dryrun.robot @@ -14,11 +14,15 @@ Passing keywords Keywords with embedded arguments ${tc}= Check Test Case ${TESTNAME} - Length Should Be ${tc.kws} 3 + Length Should Be ${tc.kws} 5 Check Keyword Data ${tc.kws[0]} Embedded arguments here Check Keyword Data ${tc.kws[0].kws[0]} BuiltIn.No Operation status=NOT RUN Check Keyword Data ${tc.kws[1]} Embedded args rock here Check Keyword Data ${tc.kws[1].kws[0]} BuiltIn.No Operation status=NOT RUN + Check Keyword Data ${tc.kws[2]} Some embedded and normal args args=42 + Check Keyword Data ${tc.kws[2].kws[0]} BuiltIn.No Operation status=NOT RUN + Check Keyword Data ${tc.kws[3]} Some embedded and normal args args=\${does not exist} + Check Keyword Data ${tc.kws[3].kws[0]} BuiltIn.No Operation status=NOT RUN Library keyword with embedded arguments ${tc}= Check Test Case ${TESTNAME} @@ -98,7 +102,7 @@ Non-existing keyword name Invalid syntax in UK Check Test Case ${TESTNAME} - Error In File 0 cli/dryrun/dryrun.robot 155 + Error In File 0 cli/dryrun/dryrun.robot 161 ... Creating keyword 'Invalid Syntax UK' failed: ... Invalid argument specification: ... Invalid argument syntax '\${arg'. diff --git a/atest/robot/keywords/embedded_arguments.robot b/atest/robot/keywords/embedded_arguments.robot index cefbaf65628..fb0853e2870 100644 --- a/atest/robot/keywords/embedded_arguments.robot +++ b/atest/robot/keywords/embedded_arguments.robot @@ -100,13 +100,13 @@ Non String Variable Is Accepted With Custom Regexp Regexp Extensions Are Not Supported Check Test Case ${TEST NAME} - Creating Keyword Failed 1 291 + Creating Keyword Failed 0 294 ... Regexp extensions like \${x:(?x)re} are not supported ... Regexp extensions are not allowed in embedded arguments. Invalid Custom Regexp Check Test Case ${TEST NAME} - Creating Keyword Failed 2 294 + Creating Keyword Failed 1 297 ... Invalid \${x:(} Regexp ... Compiling embedded arguments regexp failed: * @@ -142,10 +142,13 @@ Embedded And Positional Arguments Do Not Work Together Keyword with embedded args cannot be used as "normal" keyword Check Test Case ${TEST NAME} -Creating keyword with both normal and embedded arguments fails - Creating Keyword Failed 0 238 - ... Keyword with \${embedded} and normal args is invalid - ... Keyword cannot have both normal and embedded arguments. +Keyword with both normal and embedded arguments + Check Test Case ${TEST NAME} + +Keyword with both normal, positional and embedded arguments + Check Test Case ${TEST NAME} + +Keyword with both normal and embedded arguments with too few arguments Check Test Case ${TEST NAME} Keyword matching multiple keywords in test case file diff --git a/atest/robot/keywords/trace_log_keyword_arguments.robot b/atest/robot/keywords/trace_log_keyword_arguments.robot index be52a1bf928..fc0ea436f10 100644 --- a/atest/robot/keywords/trace_log_keyword_arguments.robot +++ b/atest/robot/keywords/trace_log_keyword_arguments.robot @@ -75,6 +75,7 @@ Embedded Arguments ${tc}= Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} Arguments: [ \${first}='foo' | \${second}=42 | \${what}='UK' ] TRACE Check Log Message ${tc.kws[1].msgs[0]} Arguments: [ 'bar' | 'Embedded Arguments' ] TRACE + Check Log Message ${tc.kws[2].msgs[0]} Arguments: [ \${embedded}='Embedded' | \${keyword}='keyword' | \${positional}='positively' ] TRACE *** Keywords *** Check Argument Value Trace diff --git a/atest/testdata/cli/dryrun/dryrun.robot b/atest/testdata/cli/dryrun/dryrun.robot index fa4e7a3d651..6eeabe722aa 100644 --- a/atest/testdata/cli/dryrun/dryrun.robot +++ b/atest/testdata/cli/dryrun/dryrun.robot @@ -27,6 +27,8 @@ Passing keywords Keywords with embedded arguments Embedded arguments here Embedded args rock here + Some embedded and normal args 42 + Some embedded and normal args ${does not exist} This is validated Library keyword with embedded arguments @@ -140,6 +142,10 @@ Avoid keyword in dry-run Embedded ${args} here No Operation +Some ${type} and normal args + [Arguments] ${meaning of life} + No Operation + Keyword with Teardown No Operation [Teardown] Does not exist diff --git a/atest/testdata/keywords/embedded_arguments.robot b/atest/testdata/keywords/embedded_arguments.robot index 524274cc4bc..eab0b236995 100644 --- a/atest/testdata/keywords/embedded_arguments.robot +++ b/atest/testdata/keywords/embedded_arguments.robot @@ -162,9 +162,16 @@ Keyword with embedded args cannot be used as "normal" keyword [Documentation] FAIL Variable '${user}' not found. User ${user} Selects ${item} From Webshop -Creating keyword with both normal and embedded arguments fails - [Documentation] FAIL Keyword cannot have both normal and embedded arguments. - Keyword with ${embedded} and normal args is invalid arg1 arg2 +Keyword with both normal and embedded arguments + Number of horses should be 2 + Number of dogs should be count=3 + +Keyword with both normal, positional and embedded arguments + Number of horses should be 2 swimming + +Keyword with both normal and embedded arguments with too few arguments + [Documentation] FAIL Keyword 'Number of ${animals} should be' expected 1 to 2 arguments, got 0. + Number of horses should be Keyword Matching Multiple Keywords In Test Case File [Documentation] FAIL @@ -235,10 +242,6 @@ My embedded ${var} ${x:x} gets ${y:\w} from the ${z:.} Should Be Equal ${x}-${y}-${z} x-y-z -Keyword with ${embedded} and normal args is invalid - [Arguments] ${arg1} ${arg2} - Fail Creating keyword should fail. This should never be executed - ${a}-tc-${b} Log ${a}-tc-${b} @@ -308,3 +311,7 @@ It is totally ${same} It is totally ${same} Fail Not executed + +Number of ${animals} should be + [Arguments] ${count} ${activity}=walking + Log to console Checking if ${count} ${animals} are ${activity} diff --git a/atest/testdata/keywords/trace_log_keyword_arguments.robot b/atest/testdata/keywords/trace_log_keyword_arguments.robot index eba580ef3a8..38086a8408b 100644 --- a/atest/testdata/keywords/trace_log_keyword_arguments.robot +++ b/atest/testdata/keywords/trace_log_keyword_arguments.robot @@ -79,6 +79,7 @@ Arguments With Run Keyword Embedded Arguments Embedded Arguments "foo" and "${42}" with UK Embedded Arguments "bar" and "${TEST NAME}" + Embedded arguments in a keyword with positional arguments positively *** Keywords *** Set Unicode Repr Object As Variable @@ -113,3 +114,7 @@ Embedded Arguments "${first}" and "${second}" with ${what:[KU]+} Should Be Equal ${first} foo Should be Equal ${second} ${42} Should be Equal ${what} UK + +${embedded} arguments in a ${keyword} with positional arguments + [arguments] ${positional} + Log to console ${embedded} ${keyword} ${positional} diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index c53fab79d06..e2ab7aedfe5 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -52,8 +52,6 @@ def _create_handler(self, kw): embedded = EmbeddedArguments.from_name(kw.name) if not embedded: return UserKeywordHandler(kw, self.name) - if kw.args: - raise DataError('Keyword cannot have both normal and embedded arguments.') return EmbeddedArgumentsHandler(kw, self.name, embedded) def _log_creating_failed(self, handler, error): diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 3dacfbed7b9..92531d9af66 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -146,12 +146,16 @@ def _split_kwonly_and_kwargs(self, all_kwargs): return kwonly, kwargs def _trace_log_args_message(self, variables): + return self._format_trace_log_args_message( + self._format_args_for_trace_logging(), variables) + + def _format_args_for_trace_logging(self): args = ['${%s}' % arg for arg in self.arguments.positional] if self.arguments.var_positional: args.append('@{%s}' % self.arguments.var_positional) if self.arguments.var_named: args.append('&{%s}' % self.arguments.var_named) - return self._format_trace_log_args_message(args, variables) + return args def _format_trace_log_args_message(self, args, variables): args = ['%s=%s' % (name, prepr(variables[name])) for name in args] @@ -245,21 +249,22 @@ def __init__(self, handler, name): self.embedded_args = handler.embedded.match(name).groups() def _resolve_arguments(self, args, variables=None): - # Validates that no arguments given. self.arguments.resolve(args, variables) - if not variables: - return [] - embedded = [variables.replace_scalar(e) for e in self.embedded_args] - return self._handler.embedded.map(embedded) + if variables: + embedded = [variables.replace_scalar(e) for e in self.embedded_args] + self.embedded_args = self._handler.embedded.map(embedded) + return super()._resolve_arguments(args, variables) - def _set_arguments(self, embedded_args, context): + def _set_arguments(self, args, context): variables = context.variables - for name, value in embedded_args: + for name, value in self.embedded_args: variables['${%s}' % name] = value + super()._set_arguments(args, context) context.output.trace(lambda: self._trace_log_args_message(variables)) def _trace_log_args_message(self, variables): args = [f'${{{arg}}}' for arg in self._handler.embedded.args] + args += self._format_args_for_trace_logging() return self._format_trace_log_args_message(args, variables) def _get_result(self, kw, assignment, variables): From 86fd5d49ee7008187f2e9f14675b1f0cc1d79c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 18 Jan 2023 07:34:59 +0200 Subject: [PATCH 0347/1592] parsing: report empty test name during parsing --- src/robot/parsing/model/blocks.py | 15 +++++++++------ src/robot/parsing/model/statements.py | 4 ++++ src/robot/running/builder/transformers.py | 3 ++- src/robot/running/model.py | 7 +++++-- src/robot/running/suiterunner.py | 9 +++------ utest/parsing/test_model.py | 2 +- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 47f9a02a6ed..65e5a11cb06 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -17,7 +17,7 @@ from robot.utils import file_writer, is_pathlike, is_string -from .statements import Comment, EmptyLine +from .statements import KeywordCall, TemplateArguments, Continue, Break, Return from .visitor import ModelVisitor from ..lexer import Token @@ -54,10 +54,8 @@ def validate(self, context): pass def _body_is_empty(self): - for node in self.body: - if not isinstance(node, (EmptyLine, Comment)): - return False - return True + valid = (KeywordCall, TemplateArguments, Continue, Return, Break, For, If, While, Try) + return not any(isinstance(node, valid) for node in self.body) class HeaderAndBody(Block): @@ -127,14 +125,19 @@ class CommentSection(Section): class TestCase(Block): _fields = ('header', 'body') - def __init__(self, header, body=None): + def __init__(self, header, body=None, errors=()): self.header = header self.body = body or [] + self.errors = errors @property def name(self): return self.header.name + def validate(self, context): + if self._body_is_empty(): + self.errors += ('Test contains no keywords.',) + class Keyword(Block): _fields = ('header', 'body') diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 5af603a8834..b3b1a78335f 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -577,6 +577,10 @@ def from_params(cls, name, eol=EOL): def name(self): return self.get_value(Token.TESTCASE_NAME) + def validate(self, context): + if not self.name: + self.errors += (f'Test name cannot be empty.',) + @Statement.register class KeywordName(Statement): diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 33c600e84fc..7c6fd545f5b 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -168,7 +168,8 @@ def __init__(self, suite: TestSuite, defaults: Defaults): self.test = None def visit_TestCase(self, node): - self.test = self.suite.tests.create(name=node.name, lineno=node.lineno) + self.test = self.suite.tests.create(name=node.name, lineno=node.lineno, + error=format_error(node.errors + node.header.errors)) self.generic_visit(node) self._set_settings(self.test, self.settings) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 7a48eec6a53..b36c138e57d 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -327,16 +327,17 @@ class TestCase(model.TestCase): See the base class for documentation of attributes not documented here. """ - __slots__ = ['template'] + __slots__ = ['template', 'error'] body_class = Body #: Internal usage only. fixture_class = Keyword #: Internal usage only. def __init__(self, name='', doc='', tags=None, timeout=None, template=None, - lineno=None): + lineno=None, error=None): super().__init__(name, doc, tags, timeout, lineno) #: Name of the keyword that has been used as a template when building the test. # ``None`` if template is not used. self.template = template + self.error = error @property def source(self): @@ -346,6 +347,8 @@ def to_dict(self): data = super().to_dict() if self.template: data['template'] = self.template + if self.error: + data['error'] = self.error return data diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index 6492a860753..e01ffc2a1ec 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -134,12 +134,9 @@ def visit_test(self, test): self._add_exit_combine() result.tags.add('robot:exit') if status.passed: - if not test.name: - status.test_failed( - test_or_task('{Test} name cannot be empty.', settings.rpa)) - elif not test.body: - status.test_failed( - test_or_task('{Test} contains no keywords.', settings.rpa)) + if test.error: + error = test.error if not settings.rpa else test.error.replace('Test', 'Task') + status.test_failed(error) elif test.tags.robot('skip'): status.test_skipped( test_or_task("{Test} skipped using 'robot:skip' tag.", diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 19e8ee03e88..e7b8c7bc969 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1212,7 +1212,7 @@ def visit_Statement(self, node): TestCase(TestCaseName([ Token('TESTCASE NAME', 'EXAMPLE', 2, 0), Token('EOL', '\n', 2, 7) - ])), + ]), errors= ('Test contains no keywords.',)), TestCase(TestCaseName([ Token('TESTCASE NAME', 'Added'), Token('EOL', '\n') From 6aaf9c9c07aceb9d00e1047f652edefda7cafe2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 18 Jan 2023 18:05:54 +0200 Subject: [PATCH 0348/1592] Testadata cleanup Get rid of empty user keywords. This will be invalid syntax in near future. --- atest/robot/cli/model_modifiers/ModelModifier.py | 3 ++- atest/robot/cli/model_modifiers/pre_run.robot | 2 +- atest/robot/keywords/user_keyword_arguments.robot | 12 ++++++------ atest/robot/libdoc/invalid_user_keywords.robot | 6 +++--- atest/robot/libdoc/resource_file.robot | 2 +- atest/testdata/core/empty_testcase_and_uk.robot | 1 - atest/testdata/keywords/user_keyword_arguments.robot | 3 +++ atest/testdata/libdoc/__init__.robot | 4 ++++ atest/testdata/libdoc/invalid_resource.resource | 1 + atest/testdata/libdoc/invalid_resource.robot | 1 + atest/testdata/libdoc/invalid_user_keywords.robot | 5 +++++ atest/testdata/libdoc/resource.robot | 10 ++++++++++ atest/testdata/libdoc/suite.robot | 4 ++++ .../builtin/keyword_should_exist.robot | 3 +++ .../builtin/keyword_should_exist_resource_1.robot | 1 + 15 files changed, 45 insertions(+), 13 deletions(-) diff --git a/atest/robot/cli/model_modifiers/ModelModifier.py b/atest/robot/cli/model_modifiers/ModelModifier.py index 23f8fb5f9c4..8e1f2ab1ed4 100644 --- a/atest/robot/cli/model_modifiers/ModelModifier.py +++ b/atest/robot/cli/model_modifiers/ModelModifier.py @@ -13,7 +13,8 @@ def start_suite(self, suite): if config[0] == 'FAIL': raise RuntimeError(' '.join(self.config[1:])) elif config[0] == 'CREATE': - suite.tests.create(**dict(conf.split('-', 1) for conf in config[1:])) + tc = suite.tests.create(**dict(conf.split('-', 1) for conf in config[1:])) + tc.body.create_keyword('No operation') self.config = [] elif config == ('REMOVE', 'ALL', 'TESTS'): suite.tests = [] diff --git a/atest/robot/cli/model_modifiers/pre_run.robot b/atest/robot/cli/model_modifiers/pre_run.robot index 66c7dcd4d6e..e1c5f924efd 100644 --- a/atest/robot/cli/model_modifiers/pre_run.robot +++ b/atest/robot/cli/model_modifiers/pre_run.robot @@ -49,7 +49,7 @@ Modifiers are used before normal configuration ... --include added --prerun ${CURDIR}/ModelModifier.py:CREATE:name=Created:tags=added ${TEST DATA} Stderr Should Be Empty Length Should Be ${SUITE.tests} 1 - ${tc} = Check test case Created FAIL Test contains no keywords. + ${tc} = Check test case Created Lists should be equal ${tc.tags} ${{['added']}} Modify FOR and IF diff --git a/atest/robot/keywords/user_keyword_arguments.robot b/atest/robot/keywords/user_keyword_arguments.robot index d154c4db875..4dcb2527adf 100644 --- a/atest/robot/keywords/user_keyword_arguments.robot +++ b/atest/robot/keywords/user_keyword_arguments.robot @@ -85,12 +85,12 @@ Caller does not see modifications to varargs Invalid Arguments Spec [Template] Verify Invalid Argument Spec - 0 334 Invalid argument syntax Invalid argument syntax 'no deco'. - 1 338 Non-default after defaults Non-default argument after default arguments. - 2 342 Default with varargs Only normal arguments accept default values, list arguments like '\@{varargs}' do not. - 3 346 Default with kwargs Only normal arguments accept default values, dictionary arguments like '\&{kwargs}' do not. - 4 350 Kwargs not last Only last argument can be kwargs. - 5 354 Multiple errors Multiple errors: + 0 337 Invalid argument syntax Invalid argument syntax 'no deco'. + 1 341 Non-default after defaults Non-default argument after default arguments. + 2 345 Default with varargs Only normal arguments accept default values, list arguments like '\@{varargs}' do not. + 3 349 Default with kwargs Only normal arguments accept default values, dictionary arguments like '\&{kwargs}' do not. + 4 353 Kwargs not last Only last argument can be kwargs. + 5 357 Multiple errors Multiple errors: ... - Invalid argument syntax 'invalid'. ... - Non-default argument after default arguments. ... - Cannot have multiple varargs. diff --git a/atest/robot/libdoc/invalid_user_keywords.robot b/atest/robot/libdoc/invalid_user_keywords.robot index 530cec1840c..bee22e38284 100644 --- a/atest/robot/libdoc/invalid_user_keywords.robot +++ b/atest/robot/libdoc/invalid_user_keywords.robot @@ -9,13 +9,13 @@ Invalid arg spec Stdout should contain error Invalid arg spec 2 ... Invalid argument specification: Only last argument can be kwargs. -Dublicate name +Duplicate name Keyword Name Should Be 3 Same twice Keyword Doc Should Be 3 *Creating keyword failed:* Keyword with same name defined multiple times. - Stdout should contain error Same twice 8 + Stdout should contain error Same twice 10 ... Keyword with same name defined multiple times -Dublicate name with embedded arguments +Duplicate name with embedded arguments Keyword Name Should Be 1 same \${embedded match} Keyword Doc Should Be 1 ${EMPTY} Keyword Name Should Be 2 Same \${embedded} diff --git a/atest/robot/libdoc/resource_file.robot b/atest/robot/libdoc/resource_file.robot index cc1167f3c71..692f152ed4a 100644 --- a/atest/robot/libdoc/resource_file.robot +++ b/atest/robot/libdoc/resource_file.robot @@ -110,7 +110,7 @@ Non ASCII Keyword Source Info Keyword Name Should Be 0 curdir Keyword Should Not Have Source 0 - Keyword Lineno Should Be 0 65 + Keyword Lineno Should Be 0 71 '*.resource' extension is accepted Run Libdoc And Parse Output ${TESTDATADIR}/resource.resource diff --git a/atest/testdata/core/empty_testcase_and_uk.robot b/atest/testdata/core/empty_testcase_and_uk.robot index 76ecd249130..e18a6c85613 100644 --- a/atest/testdata/core/empty_testcase_and_uk.robot +++ b/atest/testdata/core/empty_testcase_and_uk.robot @@ -21,7 +21,6 @@ User Keyword With Only Non-Empty [Return] Works UK With Return User Keyword With Empty [Return] Does Not Work - [Documentation] FAIL User keyword 'UK With Empty Return' contains no keywords. UK With Empty Return Empty User Keyword With Other Settings Than [Return] diff --git a/atest/testdata/keywords/user_keyword_arguments.robot b/atest/testdata/keywords/user_keyword_arguments.robot index d5486885d2f..ef2987058d6 100644 --- a/atest/testdata/keywords/user_keyword_arguments.robot +++ b/atest/testdata/keywords/user_keyword_arguments.robot @@ -272,6 +272,7 @@ Default With Variable Default With Non-Existing Variable [Arguments] ${arg}=${NON EXISTING} + No operation Default With None Variable [Arguments] ${arg}=${None} @@ -303,6 +304,7 @@ Default With List Variable Default With Invalid List Variable [Arguments] ${invalid}=@{VAR} + No operation Default With Dict Variable [Arguments] ${a}=&{EMPTY} ${b}=&{DICT} @@ -317,6 +319,7 @@ Default With Dict Variable Default With Invalid Dict Variable [Arguments] ${invalid}=&{VAR} + No operation Argument With `=` In Name [Arguments] ${=} ${==}== ${===}=${=} diff --git a/atest/testdata/libdoc/__init__.robot b/atest/testdata/libdoc/__init__.robot index e454e9f5b01..256475ce919 100644 --- a/atest/testdata/libdoc/__init__.robot +++ b/atest/testdata/libdoc/__init__.robot @@ -7,11 +7,13 @@ Keyword Tags keyword tags 1. Example [Documentation] Keyword doc with ${CURDIR}. [Tags] tags + No Operation 2. Keyword with some "stuff" to <escape> [Arguments] ${a1} ${a2}=c:\temp\ [Documentation] foo bar `kw` & some "stuff" to <escape> .\n\nbaa `${a1}` [Tags] ${CURDIR} + No Operation 3. Different argument types [Arguments] ${mandatory} ${optional}=default @{varargs} @@ -19,6 +21,8 @@ Keyword Tags keyword tags [Documentation] Multiple ... ... lines. + No Operation 4. Embedded ${arguments} [Documentation] Hyvää yötä. дякую! + No Operation diff --git a/atest/testdata/libdoc/invalid_resource.resource b/atest/testdata/libdoc/invalid_resource.resource index 04aa896f706..cbcb1fab866 100644 --- a/atest/testdata/libdoc/invalid_resource.resource +++ b/atest/testdata/libdoc/invalid_resource.resource @@ -7,3 +7,4 @@ Definitely not allowed *** Keywords *** Example + No Operation diff --git a/atest/testdata/libdoc/invalid_resource.robot b/atest/testdata/libdoc/invalid_resource.robot index 88acdcf297e..aef72d439f8 100644 --- a/atest/testdata/libdoc/invalid_resource.robot +++ b/atest/testdata/libdoc/invalid_resource.robot @@ -4,3 +4,4 @@ Test Setup Not allowed either *** Keywords *** Example + No Operation diff --git a/atest/testdata/libdoc/invalid_user_keywords.robot b/atest/testdata/libdoc/invalid_user_keywords.robot index 4194e168e92..f099f488331 100644 --- a/atest/testdata/libdoc/invalid_user_keywords.robot +++ b/atest/testdata/libdoc/invalid_user_keywords.robot @@ -1,13 +1,18 @@ *** Keywords *** Invalid arg spec [Arguments] &{kwargs} ${invalid} + No Operation Same Twice [Documentation] Having same keyword twice is an error. + No Operation Same twice + No Operation Same ${embedded} [Documentation] This is an error only at run time. + No Operation same ${embedded match} + No Operation diff --git a/atest/testdata/libdoc/resource.robot b/atest/testdata/libdoc/resource.robot index e6fdac2c6f4..108f5f8672d 100644 --- a/atest/testdata/libdoc/resource.robot +++ b/atest/testdata/libdoc/resource.robot @@ -26,9 +26,11 @@ Keyword with some "stuff" to <escape> kw 3 [Documentation] literal\nnewline [Arguments] ${a1} @{a2} + No Operation kw 4 [Arguments] ${positional}=default @{varargs} &{kwargs} [Tags] kw4 Has tags ?!?!?? + No Operation kw 5 [DocumeNtation] foo bar `kw`. ... @@ -48,6 +50,7 @@ kw 5 [DocumeNtation] foo bar `kw`. ... | foo | bar | ... ... tags: a, b, ${3} + No Operation kw 6 [Documentation] Summary line @@ -55,20 +58,27 @@ kw 6 ... Another line. ... Tags: foo, bar [Tags] foo dar + No Operation Different argument types [Arguments] ${mandatory} ${optional}=default @{varargs} ... ${kwo}=default ${another} &{kwargs} + No Operation Embedded ${arguments} + No Operation curdir [Documentation] ${CURDIR} + No Operation non ascii doc [Documentation] Hyvää yötä.\n\nСпасибо! + No Operation Deprecation [Documentation] *DEPRECATED* for some reason. + No Operation Private [Tags] robot:private + No Operation diff --git a/atest/testdata/libdoc/suite.robot b/atest/testdata/libdoc/suite.robot index dc851d88858..418eff7524a 100644 --- a/atest/testdata/libdoc/suite.robot +++ b/atest/testdata/libdoc/suite.robot @@ -10,11 +10,13 @@ This is a suite file, not a resource file. 1. Example [Documentation] Keyword doc with ${CURDIR}. [Tags] tags + No Operation 2. Keyword with some "stuff" to <escape> [Arguments] ${a1} ${a2}=c:\temp\ [Documentation] foo bar `kw` & some "stuff" to <escape> .\n\nbaa `${a1}` [Tags] ${CURDIR} + No Operation 3. Different argument types [Arguments] ${mandatory} ${optional}=default @{varargs} @@ -22,6 +24,8 @@ This is a suite file, not a resource file. [Documentation] Multiple ... ... lines. + No Operation 4. Embedded ${arguments} [Documentation] Hyvää yötä. дякую! + No Operation diff --git a/atest/testdata/standard_libraries/builtin/keyword_should_exist.robot b/atest/testdata/standard_libraries/builtin/keyword_should_exist.robot index 1fc7e5abc69..185e9b9a862 100644 --- a/atest/testdata/standard_libraries/builtin/keyword_should_exist.robot +++ b/atest/testdata/standard_libraries/builtin/keyword_should_exist.robot @@ -78,11 +78,14 @@ My User Keyword Fail This is never executed Duplicate keyword in same resource + No Operation Duplicate keyword in same resource + No Operation No Operation [Documentation] Override keyword from BuiltIn + No Operation ${Prefix} this ${keyword:keyword} exists Fail Not executed diff --git a/atest/testdata/standard_libraries/builtin/keyword_should_exist_resource_1.robot b/atest/testdata/standard_libraries/builtin/keyword_should_exist_resource_1.robot index f0bafb831c6..d9ffc73869e 100644 --- a/atest/testdata/standard_libraries/builtin/keyword_should_exist_resource_1.robot +++ b/atest/testdata/standard_libraries/builtin/keyword_should_exist_resource_1.robot @@ -4,3 +4,4 @@ Resource Keyword Fail Not really executed Duplicated Keyword + No Operation From 4376e59d0757bd22802a048d7429647252634a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 18 Jan 2023 18:07:53 +0200 Subject: [PATCH 0349/1592] Detect empty tests and user keywords during parsing time Relates to #4210 --- atest/robot/cli/console/console_type.robot | 2 +- .../dotted_exitonfailure_empty_test_stderr.txt | 2 ++ src/robot/parsing/model/blocks.py | 12 +++++++++--- src/robot/running/builder/transformers.py | 4 +++- src/robot/running/userkeywordrunner.py | 2 -- utest/parsing/test_model.py | 1 + 6 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt diff --git a/atest/robot/cli/console/console_type.robot b/atest/robot/cli/console/console_type.robot index fd171a5d919..1483a6e7fe6 100644 --- a/atest/robot/cli/console/console_type.robot +++ b/atest/robot/cli/console/console_type.robot @@ -69,7 +69,7 @@ Dotted does not show details for skipped after fatal error --Dotted --ExitOnFailure with empty test case Run tests -X. core/empty_testcase_and_uk.robot Stdout Should Be dotted_exitonfailure_empty_test.txt - Stderr Should Be empty.txt + Stderr Should Be dotted_exitonfailure_empty_test_stderr.txt Check test tags ${EMPTY} ${tc} = Check test case Empty Test Case FAIL ... Failure occurred and exit-on-failure mode is in use. diff --git a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt new file mode 100644 index 00000000000..cebde44da50 --- /dev/null +++ b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt @@ -0,0 +1,2 @@ +[ ERROR ] Error in file '/Users/jth/Code/robotframework/atest/testdata/core/empty_testcase_and_uk.robot' on line 45: Creating keyword 'Empty UK' failed: User keyword 'Empty UK' contains no keywords. +[ ERROR ] Error in file '/Users/jth/Code/robotframework/atest/testdata/core/empty_testcase_and_uk.robot' on line 47: Creating keyword 'Empty UK With Settings' failed: User keyword 'Empty UK With Settings' contains no keywords. diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 65e5a11cb06..d249de87877 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -17,7 +17,7 @@ from robot.utils import file_writer, is_pathlike, is_string -from .statements import KeywordCall, TemplateArguments, Continue, Break, Return +from .statements import KeywordCall, TemplateArguments, Continue, Break, Return, ReturnStatement from .visitor import ModelVisitor from ..lexer import Token @@ -54,7 +54,7 @@ def validate(self, context): pass def _body_is_empty(self): - valid = (KeywordCall, TemplateArguments, Continue, Return, Break, For, If, While, Try) + valid = (KeywordCall, TemplateArguments, Continue, ReturnStatement, Break, For, If, While, Try) return not any(isinstance(node, valid) for node in self.body) @@ -142,14 +142,20 @@ def validate(self, context): class Keyword(Block): _fields = ('header', 'body') - def __init__(self, header, body=None): + def __init__(self, header, body=None, errors=()): self.header = header self.body = body or [] + self.errors = errors @property def name(self): return self.header.name + def validate(self, context): + if self._body_is_empty(): + if not any(isinstance(node, Return) for node in self.body): + self.errors += (f"User keyword '{self.name}' contains no keywords.",) + class If(Block): """Represents IF structures in the model. diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 7c6fd545f5b..fb3c84f04eb 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -272,9 +272,11 @@ def __init__(self, resource: ResourceFile, defaults: Defaults): self.kw = None def visit_Keyword(self, node): + error = format_error(node.errors + node.header.errors) self.kw = self.resource.keywords.create(name=node.name, tags=self.defaults.keyword_tags, - lineno=node.lineno) + lineno=node.lineno, + error=error) self.generic_visit(node) def visit_Documentation(self, node): diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 92531d9af66..533ff311c61 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -163,8 +163,6 @@ def _format_trace_log_args_message(self, args, variables): def _execute(self, context): handler = self._handler - if not (handler.body or handler.return_value): - raise DataError("User keyword '%s' contains no keywords." % self.name) if context.dry_run and handler.tags.robot('no-dry-run'): return None, None error = return_ = pass_ = None diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index e7b8c7bc969..2152dbdb76c 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -913,6 +913,7 @@ def test_invalid_arg_spec(self): 'Only last argument can be kwargs.') ) ], + errors=("User keyword 'Invalid' contains no keywords.",) ) get_and_assert_model(data, expected, depth=1) From 0dfa9ad6a2223618d6d90344eefcee179b60a2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Thu, 12 Jan 2023 18:52:10 +0200 Subject: [PATCH 0350/1592] Pass a library instance to custom converters Fixes #4510 --- .../type_conversion/custom_converters.robot | 13 ++++- .../type_conversion/CustomConverters.py | 48 ++++++++++++++++++- .../type_conversion/custom_converters.robot | 21 ++++++++ .../running/arguments/customconverters.py | 31 +++++++----- src/robot/running/arguments/typeconverters.py | 2 +- src/robot/running/testlibraries.py | 2 +- 6 files changed, 101 insertions(+), 16 deletions(-) diff --git a/atest/robot/keywords/type_conversion/custom_converters.robot b/atest/robot/keywords/type_conversion/custom_converters.robot index 2c038ca6113..6873223942f 100644 --- a/atest/robot/keywords/type_conversion/custom_converters.robot +++ b/atest/robot/keywords/type_conversion/custom_converters.robot @@ -33,12 +33,23 @@ Failing conversion `None` as strict converter Check Test Case ${TESTNAME} +With library as argument to converter + Check Test Case ${TESTNAME} + +Test scope library instance is reset between test + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Global scope library instance is not reset between test + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + Invalid converters Check Test Case ${TESTNAME} Validate Errors ... Custom converters must be callable, converter for Invalid is integer. ... Custom converters must accept one positional argument, 'TooFewArgs' accepts none. - ... Custom converters cannot have more than one mandatory argument, 'TooManyArgs' has 'one' and 'two'. + ... Custom converters cannot have more than two mandatory arguments, 'TooManyArgs' has 'one', 'two' and 'three'. ... Custom converters must accept one positional argument, 'NoPositionalArg' accepts none. ... Custom converters cannot have mandatory keyword-only arguments, 'KwOnlyNotOk' has 'another' and 'kwo'. ... Custom converters must be specified using types, got string 'Bad'. diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index 3102d98cf29..2924579d6d7 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -1,5 +1,6 @@ from datetime import date, datetime from typing import Dict, List, Set, Tuple, Union +from types import ModuleType try: from typing import TypedDict except ImportError: @@ -22,6 +23,18 @@ def string_to_int(value: str) -> int: raise ValueError(f"Don't know number {value!r}.") +class String: + pass + + +def int_to_string_with_lib(value: int, library) -> str: + if library is None: + raise AssertionError('Expected library, got none') + if not isinstance(library, ModuleType): + raise AssertionError(f'Expected library to be instance of {ModuleType}, was {type(library)}') + return str(value) + + def parse_bool(value: Union[str, int, bool]): if isinstance(value, str): value = value.lower() @@ -78,7 +91,7 @@ class TooFewArgs: class TooManyArgs: - def __init__(self, one, two): + def __init__(self, one, two, three): pass @@ -94,6 +107,7 @@ def __init__(self, arg, *, kwo, another): ROBOT_LIBRARY_CONVERTERS = {Number: string_to_int, bool: parse_bool, + String: int_to_string_with_lib, UsDate: UsDate.from_string, FiDate: FiDate.from_string, ClassAsConverter: ClassAsConverter, @@ -121,6 +135,11 @@ def false(argument: bool): assert argument is False +def string(argument: String, expected: str = '123'): + if argument != expected: + raise AssertionError + + def us_date(argument: UsDate, expected: date = None): assert argument == expected @@ -177,3 +196,30 @@ def invalid(a: Invalid, b: TooFewArgs, c: TooManyArgs, d: KwOnlyNotOk): def non_type_annotation(arg1: 'Hello, world!', arg2: 2 = 2): assert arg1 == arg2 + + +def multiplying_converter(value: str, library) -> int: + return library.counter * int(value) + + +class StatefulLibrary: + ROBOT_LIBRARY_CONVERTERS = {Number: multiplying_converter} + + def __init__(self): + self.counter = 1 + + def multiply(self, num: Number, expected: int): + self.counter += 1 + assert num == int(expected) + + +class StatefulGlobalLibrary: + ROBOT_LIBRARY_SCOPE = 'GLOBAL' + ROBOT_LIBRARY_CONVERTERS = {Number: multiplying_converter} + + def __init__(self): + self.counter = 1 + + def global_multiply(self, num: Number, expected: int): + self.counter += 1 + assert num == int(expected) diff --git a/atest/testdata/keywords/type_conversion/custom_converters.robot b/atest/testdata/keywords/type_conversion/custom_converters.robot index 3328e8ef8b4..7125086a53a 100644 --- a/atest/testdata/keywords/type_conversion/custom_converters.robot +++ b/atest/testdata/keywords/type_conversion/custom_converters.robot @@ -1,5 +1,7 @@ *** Settings *** Library CustomConverters.py +Library CustomConverters.StatefulLibrary +Library CustomConverters.StatefulGlobalLibrary Library CustomConvertersWithLibraryDecorator.py Library CustomConvertersWithDynamicLibrary.py Library InvalidCustomConverters.py @@ -67,6 +69,25 @@ Failing conversion Conversion should fail Strict wrong type ... type=Strict error=TypeError: Only Strict instances are accepted, got string. +With library as argument to converter + String ${123} + +Test scope library instance is reset between test 1 + Multiply 2 ${2} + Multiply 2 ${4} + Multiply 4 ${12} + +Test scope library instance is reset between test 2 + Multiply 2 ${2} + +Global scope library instance is not reset between test 1 + Global Multiply 2 ${2} + Global Multiply 2 ${4} + +Global scope library instance is not reset between test 2 + Global Multiply 4 ${12} + + Invalid converters Invalid a b c d diff --git a/src/robot/running/arguments/customconverters.py b/src/robot/running/arguments/customconverters.py index 6d36a729e2f..931fb61abbb 100644 --- a/src/robot/running/arguments/customconverters.py +++ b/src/robot/running/arguments/customconverters.py @@ -24,13 +24,13 @@ def __init__(self, converters): self.converters = converters @classmethod - def from_dict(cls, converters, error_reporter): + def from_dict(cls, converters, library): valid = [] for type_, conv in converters.items(): try: - info = ConverterInfo.for_converter(type_, conv) + info = ConverterInfo.for_converter(type_, conv, library) except TypeError as err: - error_reporter(str(err)) + library.report_error(str(err)) else: valid.append(info) return cls(valid) @@ -51,10 +51,11 @@ def __len__(self): class ConverterInfo: - def __init__(self, type, converter, value_types): + def __init__(self, type, converter, value_types, library=None): self.type = type self.converter = converter self.value_types = value_types + self.library = library @property def name(self): @@ -65,7 +66,7 @@ def doc(self): return getdoc(self.converter) or getdoc(self.type) @classmethod - def for_converter(cls, type_, converter): + def for_converter(cls, type_, converter, library): if not isinstance(type_, type): raise TypeError(f'Custom converters must be specified using types, ' f'got {type_name(type_)} {type_!r}.') @@ -76,7 +77,8 @@ def converter(arg): if not callable(converter): raise TypeError(f'Custom converters must be callable, converter for ' f'{type_name(type_)} is {type_name(converter)}.') - arg_type = cls._get_arg_type(converter) + spec = cls._get_arg_spec(converter) + arg_type = spec.types.get(spec.positional[0]) if arg_type is None: accepts = () elif is_union(arg_type): @@ -85,15 +87,15 @@ def converter(arg): accepts = (arg_type.__origin__,) else: accepts = (arg_type,) - return cls(type_, converter, accepts) + return cls(type_, converter, accepts, library if spec.minargs == 2 else None) @classmethod - def _get_arg_type(cls, converter): + def _get_arg_spec(cls, converter): spec = PythonArgumentParser(type='Converter').parse(converter) - if spec.minargs > 1: + if spec.minargs > 2: required = seq2str([a for a in spec.positional if a not in spec.defaults]) - raise TypeError(f"Custom converters cannot have more than one mandatory " - f"argument, '{converter.__name__}' has {required}.") + raise TypeError(f"Custom converters cannot have more than two mandatory " + f"arguments, '{converter.__name__}' has {required}.") if not spec.positional: raise TypeError(f"Custom converters must accept one positional argument, " f"'{converter.__name__}' accepts none.") @@ -101,4 +103,9 @@ def _get_arg_type(cls, converter): required = seq2str(sorted(set(spec.named_only) - set(spec.defaults))) raise TypeError(f"Custom converters cannot have mandatory keyword-only " f"arguments, '{converter.__name__}' has {required}.") - return spec.types.get(spec.positional[0]) + return spec + + def convert(self, value): + if not self.library: + return self.converter(value) + return self.converter(value, self.library.get_instance()) diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 35d8ea9d0ce..4d60701503d 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -698,7 +698,7 @@ def _handles_value(self, value): def _convert(self, value, explicit_type=True): try: - return self.converter_info.converter(value) + return self.converter_info.convert(value) except ValueError: raise except Exception: diff --git a/src/robot/running/testlibraries.py b/src/robot/running/testlibraries.py index 746800fb46d..ef0ad84e47f 100644 --- a/src/robot/running/testlibraries.py +++ b/src/robot/running/testlibraries.py @@ -167,7 +167,7 @@ def _get_converters(self, libcode): self.report_error(f'Argument converters must be given as a dictionary, ' f'got {type_name(converters)}.') return None - return CustomArgumentConverters.from_dict(converters, self.report_error) + return CustomArgumentConverters.from_dict(converters, self) def reset_instance(self, instance=None): prev = self._libinst From 96ab8a9dd7f86ed605e1ea866fe3c7762d969324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 18 Jan 2023 18:59:11 +0200 Subject: [PATCH 0351/1592] timestr_to_secs: deprecation warning for `accepts_plain_values` argument This was already deprecated, but adding the warning now. Fixes #4522 --- src/robot/utils/robottime.py | 3 +++ utest/utils/test_robottime.py | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 9b325b7db42..314cb92672e 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -15,6 +15,7 @@ import re import time +import warnings from datetime import timedelta from .normalizing import normalize @@ -54,6 +55,8 @@ def timestr_to_secs(timestr, round_to=3, accept_plain_values=True): if accept_plain_values: converters = [_number_to_secs, _timer_to_secs, _time_string_to_secs] else: + # TODO: Remove 'accept_plain_values' in 7.0 + warnings.warn("'accept_plain_values' is deprecated and will be removed in RF 7.0.") converters = [_timer_to_secs, _time_string_to_secs] for converter in converters: secs = converter(timestr) diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index 4447ab96059..c63b452ad43 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -1,6 +1,7 @@ import unittest import re import time +import warnings from datetime import datetime, timedelta from robot.utils.asserts import (assert_equal, assert_raises_with_msg, @@ -177,8 +178,11 @@ def test_timestr_to_secs_with_invalid(self): timestr_to_secs, inv) def test_timestr_to_secs_accept_plain_values(self): - assert_raises_with_msg(ValueError, "Invalid time string '100'.", - timestr_to_secs, '100', accept_plain_values=False) + with warnings.catch_warnings(record=True) as w: + assert_raises_with_msg(ValueError, "Invalid time string '100'.", + timestr_to_secs, '100', accept_plain_values=False) + assert_equal(str(w[-1].message), + "'accept_plain_values' is deprecated and will be removed in RF 7.0.") def test_secs_to_timestr(self): for inp, compact, verbose in [ From 15e11c63be0e7a4ce8401c7d47346a7dc8c81bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 18 Jan 2023 19:34:13 +0200 Subject: [PATCH 0352/1592] Fix absolute paths in test data --- .../dotted_exitonfailure_empty_test_stderr.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt index cebde44da50..5fd7a2bc103 100644 --- a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt +++ b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt @@ -1,2 +1,2 @@ -[ ERROR ] Error in file '/Users/jth/Code/robotframework/atest/testdata/core/empty_testcase_and_uk.robot' on line 45: Creating keyword 'Empty UK' failed: User keyword 'Empty UK' contains no keywords. -[ ERROR ] Error in file '/Users/jth/Code/robotframework/atest/testdata/core/empty_testcase_and_uk.robot' on line 47: Creating keyword 'Empty UK With Settings' failed: User keyword 'Empty UK With Settings' contains no keywords. +[[] ERROR ] Error in file '*' on line 45: Creating keyword 'Empty UK' failed: User keyword 'Empty UK' contains no keywords. +[[] ERROR ] Error in file '*' on line 47: Creating keyword 'Empty UK With Settings' failed: User keyword 'Empty UK With Settings' contains no keywords. From abcaddc6d4010b40741c2a8be884ad01a4b418cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 2 Feb 2023 16:21:30 +0200 Subject: [PATCH 0353/1592] Modernize - Remove Python 2 compatible imports. - super() - f-strings - Cleanup --- src/robot/libraries/dialogs_py.py | 75 +++++++++++++------------------ 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/src/robot/libraries/dialogs_py.py b/src/robot/libraries/dialogs_py.py index 7c0fd2cec68..6154d361a6f 100644 --- a/src/robot/libraries/dialogs_py.py +++ b/src/robot/libraries/dialogs_py.py @@ -14,27 +14,23 @@ # limitations under the License. import sys -from threading import current_thread import time - -try: - from Tkinter import (Button, Entry, Frame, Label, Listbox, TclError, - Toplevel, Tk, BOTH, END, LEFT, W) -except ImportError: - from tkinter import (Button, Entry, Frame, Label, Listbox, TclError, - Toplevel, Tk, BOTH, END, LEFT, W) +from threading import current_thread +from tkinter import (BOTH, Button, END, Entry, Frame, Label, LEFT, Listbox, + TclError, Tk, Toplevel, W, Widget) +from typing import Optional -class _TkDialog(Toplevel): +class TkDialog(Toplevel): _left_button = 'OK' _right_button = 'Cancel' - def __init__(self, message, value=None, **extra): + def __init__(self, message, value=None, **config): self._prevent_execution_with_timeouts() self._parent = self._get_parent() - Toplevel.__init__(self, self._parent) + super().__init__(self._parent) self._initialize_dialog() - self._create_body(message, value, **extra) + self._create_body(message, value, **config) self._create_buttons() self._result = None @@ -43,7 +39,7 @@ def _prevent_execution_with_timeouts(self): raise RuntimeError('Dialogs library is not supported with ' 'timeouts on Python on this platform.') - def _get_parent(self): + def _get_parent(self) -> Tk: parent = Tk() parent.withdraw() return parent @@ -58,15 +54,15 @@ def _initialize_dialog(self): self._bring_to_front() def grab_set(self, timeout=30): - maxtime = time.time() + timeout - while time.time() < maxtime: + max_time = time.time() + timeout + while time.time() < max_time: try: # Fails at least on Linux if mouse is hold down. return Toplevel.grab_set(self) except TclError: pass - raise RuntimeError('Failed to open dialog in %s seconds. One possible ' - 'reason is holding down mouse button.' % timeout) + raise RuntimeError(f'Failed to open dialog in {timeout} seconds. ' + f'One possible reason is holding down mouse button.') def _get_center_location(self): x = (self.winfo_screenwidth() - self.winfo_reqwidth()) // 2 @@ -78,24 +74,23 @@ def _bring_to_front(self): self.attributes('-topmost', True) self.after_idle(self.attributes, '-topmost', False) - def _create_body(self, message, value, **extra): + def _create_body(self, message, value, **config): frame = Frame(self) - Label(frame, text=message, anchor=W, justify=LEFT, wraplength=800).pack(fill=BOTH) - selector = self._create_selector(frame, value, **extra) - if selector: - selector.pack(fill=BOTH) - selector.focus_set() + label = Label(frame, text=message, anchor=W, justify=LEFT, wraplength=800) + label.pack(fill=BOTH) + widget = self._create_widget(frame, value, **config) + if widget: + widget.pack(fill=BOTH) + widget.focus_set() frame.pack(padx=5, pady=5, expand=1, fill=BOTH) - def _create_selector(self, frame, value): + def _create_widget(self, frame, value) -> Optional[Widget]: return None def _create_buttons(self): frame = Frame(self) - self._create_button(frame, self._left_button, - self._left_button_clicked) - self._create_button(frame, self._right_button, - self._right_button_clicked) + self._create_button(frame, self._left_button, self._left_button_clicked) + self._create_button(frame, self._right_button, self._right_button_clicked) frame.pack() def _create_button(self, parent, label, callback): @@ -130,16 +125,16 @@ def show(self): return self._result -class MessageDialog(_TkDialog): +class MessageDialog(TkDialog): _right_button = None -class InputDialog(_TkDialog): +class InputDialog(TkDialog): def __init__(self, message, default='', hidden=False): - _TkDialog.__init__(self, message, default, hidden=hidden) + super().__init__(message, default, hidden=hidden) - def _create_selector(self, parent, default, hidden): + def _create_widget(self, parent, default, hidden=False): self._entry = Entry(parent, show='*' if hidden else '') self._entry.insert(0, default) self._entry.select_range(0, END) @@ -149,12 +144,9 @@ def _get_value(self): return self._entry.get() -class SelectionDialog(_TkDialog): +class SelectionDialog(TkDialog): - def __init__(self, message, values): - _TkDialog.__init__(self, message, values) - - def _create_selector(self, parent, values): + def _create_widget(self, parent, values): self._listbox = Listbox(parent) for item in values: self._listbox.insert(END, item) @@ -168,12 +160,9 @@ def _get_value(self): return self._listbox.get(self._listbox.curselection()) -class MultipleSelectionDialog(_TkDialog): - - def __init__(self, message, values): - _TkDialog.__init__(self, message, values) +class MultipleSelectionDialog(TkDialog): - def _create_selector(self, parent, values): + def _create_widget(self, parent, values): self._listbox = Listbox(parent, selectmode='multiple') for item in values: self._listbox.insert(END, item) @@ -185,7 +174,7 @@ def _get_value(self): return selected_values -class PassFailDialog(_TkDialog): +class PassFailDialog(TkDialog): _left_button = 'PASS' _right_button = 'FAIL' From 713cceb1318bde134209dfefac65deaf2c1824f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 2 Feb 2023 20:48:18 +0200 Subject: [PATCH 0354/1592] Enhance dialog size and position. - Minimum size is dependent on display size (1/6 of width, 1/10 of height). - Also wrapping (i.e. max width) depends on display size (1/2 of width). - Dialogs are centered properly. Earlier calculation failed because dialogs were still constructred and their sizes were reported wrong. `self.update()` fixes that. - Dialogs are given focus also on Windows. Three first points fix #4634. The last one fixes #4635. --- src/robot/libraries/dialogs_py.py | 45 +++++++++++++------------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/robot/libraries/dialogs_py.py b/src/robot/libraries/dialogs_py.py index 6154d361a6f..2a9693be6be 100644 --- a/src/robot/libraries/dialogs_py.py +++ b/src/robot/libraries/dialogs_py.py @@ -14,10 +14,9 @@ # limitations under the License. import sys -import time from threading import current_thread from tkinter import (BOTH, Button, END, Entry, Frame, Label, LEFT, Listbox, - TclError, Tk, Toplevel, W, Widget) + Tk, Toplevel, W, Widget) from typing import Optional @@ -32,6 +31,7 @@ def __init__(self, message, value=None, **config): self._initialize_dialog() self._create_body(message, value, **config) self._create_buttons() + self._finalize_dialog() self._result = None def _prevent_execution_with_timeouts(self): @@ -45,38 +45,29 @@ def _get_parent(self) -> Tk: return parent def _initialize_dialog(self): + self.withdraw() # Remove from display until finalized. self.title('Robot Framework') - self.grab_set() self.protocol("WM_DELETE_WINDOW", self._close) self.bind("<Escape>", self._close) - self.minsize(250, 80) - self.geometry("+%d+%d" % self._get_center_location()) - self._bring_to_front() - - def grab_set(self, timeout=30): - max_time = time.time() + timeout - while time.time() < max_time: - try: - # Fails at least on Linux if mouse is hold down. - return Toplevel.grab_set(self) - except TclError: - pass - raise RuntimeError(f'Failed to open dialog in {timeout} seconds. ' - f'One possible reason is holding down mouse button.') - - def _get_center_location(self): - x = (self.winfo_screenwidth() - self.winfo_reqwidth()) // 2 - y = (self.winfo_screenheight() - self.winfo_reqheight()) // 2 - return x, y - - def _bring_to_front(self): + + def _finalize_dialog(self): + self.update() # Needed to get accurate dialog size. + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + min_width = screen_width // 6 + min_height = screen_height // 10 + width = max(self.winfo_reqwidth(), min_width) + height = max(self.winfo_reqheight(), min_height) + x = (screen_width - width) // 2 + y = (screen_height - height) // 2 + self.geometry(f'{width}x{height}+{x}+{y}') self.lift() - self.attributes('-topmost', True) - self.after_idle(self.attributes, '-topmost', False) + self.deiconify() def _create_body(self, message, value, **config): frame = Frame(self) - label = Label(frame, text=message, anchor=W, justify=LEFT, wraplength=800) + max_width = self.winfo_screenwidth() // 2 + label = Label(frame, text=message, anchor=W, justify=LEFT, wraplength=max_width) label.pack(fill=BOTH) widget = self._create_widget(frame, value, **config) if widget: From e92b2880915e9d44aded8d6c5726e2d4cdbfefde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 2 Feb 2023 22:24:49 +0200 Subject: [PATCH 0355/1592] Dialogs: Bind <Enter> to OK button. On the PASS/FAIL dialog <Enter> does nothing. Fixes #4619. --- .../standard_libraries/dialogs/dialogs.robot | 19 ++++++++++--------- src/robot/libraries/dialogs_py.py | 2 ++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/atest/testdata/standard_libraries/dialogs/dialogs.robot b/atest/testdata/standard_libraries/dialogs/dialogs.robot index ac989b1da47..f05e405e4fd 100644 --- a/atest/testdata/standard_libraries/dialogs/dialogs.robot +++ b/atest/testdata/standard_libraries/dialogs/dialogs.robot @@ -10,13 +10,14 @@ Pause Execution Pause Execution Press OK. Pause Execution With Long Line - Pause Execution Verify that the long text below is wrapped nicely.\n\n${FILLER*200}\n\nAnd then press OK. + Pause Execution Verify that the long text below is wrapped nicely.\n\n${FILLER*200}\n\nAnd then press <Enter>. Pause Execution With Multiple Lines Pause Execution Verify that\nthis multi\nline text\nis displayed\nnicely.\n\nʕ•ᴥ•ʔ\n\nAnd then press <Esc>. Execute Manual Step Passing Execute Manual Step Press PASS. + Execute Manual Step Press <Enter> and validate that the dialog is *NOT* closed.\n\nThen press PASS. Execute Manual Step Failing [Documentation] FAIL Predefined error message @@ -31,17 +32,17 @@ Get Value From User Should Be Equal ${value} value Get Non-ASCII Value From User - ${value} = Get Value From User Press OK. ʕ•ᴥ•ʔ + ${value} = Get Value From User Press <Enter>. ʕ•ᴥ•ʔ Should Be Equal ${value} ʕ•ᴥ•ʔ Get Empty Value From User - ${value} = Get Value From User Press OK. + ${value} = Get Value From User Press OK or <Enter>. Should Be Equal ${value} ${EMPTY} Get Hidden Value From User - ${value} = Get Value From User Type 'value' and press OK. hidden=yes + ${value} = Get Value From User Type 'value' and press OK or <Enter>. hidden=yes Should Be Equal ${value} value - ${value} = Get Value From User Press OK. initial value hide + ${value} = Get Value From User Press OK or <Enter>. initial value hide Should Be Equal ${value} initial value Get Value From User Cancelled @@ -74,9 +75,9 @@ Get Selection From User Exited Get Selections From User ${values}= Get Selections From User - ... Select 'FOO', 'BAR' & 'ZÄP' and press OK.\n\nAlso verify that the dialog is resized properly. + ... Select 'FOO', 'BAR' & 'ZÄP' and press <Enter>.\n\nAlso verify that the dialog is resized properly. ... 1 FOO 3 ʕ•ᴥ•ʔ BAR 6 ZÄP 7 - ... This is a really long string and the window should change the size properly to content. + ... This is a rather long value and the dialog size should be set so that it fits. ... 9 10 11 12 13 14 15 16 17 18 19 20 Should Be True type($values) is list ${expected values}= Create List FOO BAR ZÄP @@ -84,7 +85,7 @@ Get Selections From User Get Selections From User When No Input Provided ${values}= Get Selections From User - ... Press OK. + ... Press OK or <Enter>. ... value 1 value 2 value 3 value 4 Should Be True type($values) is list ${expected values}= Create List @@ -104,6 +105,6 @@ Get Selections From User Exited Multiple dialogs in a row [Documentation] FAIL No value provided by user. - Pause Execution Verify that dialog is closed immediately.\n\nAfter pressing OK. + Pause Execution Verify that dialog is closed immediately.\n\nAfter pressing OK o <Enter>. Sleep 0.5s Get Value From User Verify that dialog is closed immediately.\n\nAfter pressing Cancel. diff --git a/src/robot/libraries/dialogs_py.py b/src/robot/libraries/dialogs_py.py index 2a9693be6be..63bb68734bd 100644 --- a/src/robot/libraries/dialogs_py.py +++ b/src/robot/libraries/dialogs_py.py @@ -49,6 +49,8 @@ def _initialize_dialog(self): self.title('Robot Framework') self.protocol("WM_DELETE_WINDOW", self._close) self.bind("<Escape>", self._close) + if self._left_button == TkDialog._left_button: + self.bind("<Return>", self._left_button_clicked) def _finalize_dialog(self): self.update() # Needed to get accurate dialog size. From 3314dd1e8dfc2a7f6f57bc94c3be5ee87ed864a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 2 Feb 2023 23:58:19 +0200 Subject: [PATCH 0356/1592] Dialogs: Add keyboard shortcuts to buttons. Fixes #4636. --- .../standard_libraries/dialogs/dialogs.robot | 19 +++++++++++-------- src/robot/libraries/dialogs_py.py | 4 +++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/atest/testdata/standard_libraries/dialogs/dialogs.robot b/atest/testdata/standard_libraries/dialogs/dialogs.robot index f05e405e4fd..8635e5529b0 100644 --- a/atest/testdata/standard_libraries/dialogs/dialogs.robot +++ b/atest/testdata/standard_libraries/dialogs/dialogs.robot @@ -7,21 +7,25 @@ ${FILLER} = Wräp < & シ${SPACE} *** Test Cases *** Pause Execution - Pause Execution Press OK. + Pause Execution Press OK button. + Pause Execution Press <Enter> key. + Pause Execution Press <O> key. + Pause Execution Press <o> key. Pause Execution With Long Line - Pause Execution Verify that the long text below is wrapped nicely.\n\n${FILLER*200}\n\nAnd then press <Enter>. + Pause Execution Verify that the long text below is wrapped nicely.\n\n${FILLER*200}\n\nThen press OK or <Enter>. Pause Execution With Multiple Lines - Pause Execution Verify that\nthis multi\nline text\nis displayed\nnicely.\n\nʕ•ᴥ•ʔ\n\nAnd then press <Esc>. + Pause Execution Verify that\nthis multi\nline text\nis displayed\nnicely.\n\nʕ•ᴥ•ʔ\n\nThen press <Esc>. Execute Manual Step Passing Execute Manual Step Press PASS. Execute Manual Step Press <Enter> and validate that the dialog is *NOT* closed.\n\nThen press PASS. + Execute Manual Step Press <P> or <p>. This should not be shown!! Execute Manual Step Failing [Documentation] FAIL Predefined error message - Execute Manual Step Press FAIL and then OK on next dialog. Predefined error message + Execute Manual Step Press FAIL, <F> or <f> and then OK the on next dialog. Predefined error message Execute Manual Step Exit [Documentation] FAIL No value provided by user. @@ -65,7 +69,7 @@ Get Selection From User Get Selection From User Cancelled [Documentation] FAIL No value provided by user. - Get Selection From User Press Cancel. zip zap foo + Get Selection From User Press <C> or <c>. zip zap foo Get Selection From User Exited [Documentation] FAIL No value provided by user. @@ -105,6 +109,5 @@ Get Selections From User Exited Multiple dialogs in a row [Documentation] FAIL No value provided by user. - Pause Execution Verify that dialog is closed immediately.\n\nAfter pressing OK o <Enter>. - Sleep 0.5s - Get Value From User Verify that dialog is closed immediately.\n\nAfter pressing Cancel. + Pause Execution Verify that dialog is closed immediately.\n\nAfter pressing OK or <Enter>. + Get Value From User Verify that dialog is closed immediately.\n\nAfter pressing Cancel or <Esc>. diff --git a/src/robot/libraries/dialogs_py.py b/src/robot/libraries/dialogs_py.py index 63bb68734bd..5299c5fa3df 100644 --- a/src/robot/libraries/dialogs_py.py +++ b/src/robot/libraries/dialogs_py.py @@ -88,8 +88,10 @@ def _create_buttons(self): def _create_button(self, parent, label, callback): if label: - button = Button(parent, text=label, width=10, command=callback) + button = Button(parent, text=label, width=10, command=callback, underline=0) button.pack(side=LEFT, padx=5, pady=5) + self.bind(label[0], callback) + self.bind(label[0].lower(), callback) def _left_button_clicked(self, event=None): if self._validate_value(): From e954f12cfa244d18476fd1c2764a5b2b8eeb0e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 3 Feb 2023 00:50:01 +0200 Subject: [PATCH 0357/1592] Refactor code --- src/robot/libraries/dialogs_py.py | 101 +++++++++++++++--------------- 1 file changed, 51 insertions(+), 50 deletions(-) diff --git a/src/robot/libraries/dialogs_py.py b/src/robot/libraries/dialogs_py.py index 5299c5fa3df..f4d25a9a302 100644 --- a/src/robot/libraries/dialogs_py.py +++ b/src/robot/libraries/dialogs_py.py @@ -15,21 +15,21 @@ import sys from threading import current_thread -from tkinter import (BOTH, Button, END, Entry, Frame, Label, LEFT, Listbox, - Tk, Toplevel, W, Widget) -from typing import Optional +from tkinter import (BOTH, Button, END, Entry, Frame, Label, LEFT, Listbox, Tk, + Toplevel, W) +from typing import Any, Union class TkDialog(Toplevel): - _left_button = 'OK' - _right_button = 'Cancel' + left_button = 'OK' + right_button = 'Cancel' def __init__(self, message, value=None, **config): self._prevent_execution_with_timeouts() - self._parent = self._get_parent() - super().__init__(self._parent) + self.root = self._get_root() + super().__init__(self.root) self._initialize_dialog() - self._create_body(message, value, **config) + self.widget = self._create_body(message, value, **config) self._create_buttons() self._finalize_dialog() self._result = None @@ -39,17 +39,17 @@ def _prevent_execution_with_timeouts(self): raise RuntimeError('Dialogs library is not supported with ' 'timeouts on Python on this platform.') - def _get_parent(self) -> Tk: - parent = Tk() - parent.withdraw() - return parent + def _get_root(self) -> Tk: + root = Tk() + root.withdraw() + return root def _initialize_dialog(self): self.withdraw() # Remove from display until finalized. self.title('Robot Framework') self.protocol("WM_DELETE_WINDOW", self._close) self.bind("<Escape>", self._close) - if self._left_button == TkDialog._left_button: + if self.left_button == TkDialog.left_button: self.bind("<Return>", self._left_button_clicked) def _finalize_dialog(self): @@ -66,7 +66,7 @@ def _finalize_dialog(self): self.lift() self.deiconify() - def _create_body(self, message, value, **config): + def _create_body(self, message, value, **config) -> Union[Entry, Listbox, None]: frame = Frame(self) max_width = self.winfo_screenwidth() // 2 label = Label(frame, text=message, anchor=W, justify=LEFT, wraplength=max_width) @@ -76,14 +76,15 @@ def _create_body(self, message, value, **config): widget.pack(fill=BOTH) widget.focus_set() frame.pack(padx=5, pady=5, expand=1, fill=BOTH) + return widget - def _create_widget(self, frame, value) -> Optional[Widget]: + def _create_widget(self, frame, value) -> Union[Entry, Listbox, None]: return None def _create_buttons(self): frame = Frame(self) - self._create_button(frame, self._left_button, self._left_button_clicked) - self._create_button(frame, self._right_button, self._right_button_clicked) + self._create_button(frame, self.left_button, self._left_button_clicked) + self._create_button(frame, self.right_button, self._right_button_clicked) frame.pack() def _create_button(self, parent, label, callback): @@ -98,30 +99,30 @@ def _left_button_clicked(self, event=None): self._result = self._get_value() self._close() - def _validate_value(self): + def _validate_value(self) -> bool: return True - def _get_value(self): + def _get_value(self) -> Any: return None def _close(self, event=None): # self.destroy() is not enough on Linux - self._parent.destroy() + self.root.destroy() def _right_button_clicked(self, event=None): self._result = self._get_right_button_value() self._close() - def _get_right_button_value(self): + def _get_right_button_value(self) -> Any: return None - def show(self): + def show(self) -> Any: self.wait_window(self) return self._result class MessageDialog(TkDialog): - _right_button = None + right_button = None class InputDialog(TkDialog): @@ -129,52 +130,52 @@ class InputDialog(TkDialog): def __init__(self, message, default='', hidden=False): super().__init__(message, default, hidden=hidden) - def _create_widget(self, parent, default, hidden=False): - self._entry = Entry(parent, show='*' if hidden else '') - self._entry.insert(0, default) - self._entry.select_range(0, END) - return self._entry + def _create_widget(self, parent, default, hidden=False) -> Entry: + widget = Entry(parent, show='*' if hidden else '') + widget.insert(0, default) + widget.select_range(0, END) + return widget - def _get_value(self): - return self._entry.get() + def _get_value(self) -> str: + return self.widget.get() class SelectionDialog(TkDialog): - def _create_widget(self, parent, values): - self._listbox = Listbox(parent) + def _create_widget(self, parent, values) -> Listbox: + widget = Listbox(parent) for item in values: - self._listbox.insert(END, item) - self._listbox.config(width=0) - return self._listbox + widget.insert(END, item) + widget.config(width=0) + return widget - def _validate_value(self): - return bool(self._listbox.curselection()) + def _validate_value(self) -> bool: + return bool(self.widget.curselection()) - def _get_value(self): - return self._listbox.get(self._listbox.curselection()) + def _get_value(self) -> str: + return self.widget.get(self.widget.curselection()) class MultipleSelectionDialog(TkDialog): - def _create_widget(self, parent, values): - self._listbox = Listbox(parent, selectmode='multiple') + def _create_widget(self, parent, values) -> Listbox: + widget = Listbox(parent, selectmode='multiple') for item in values: - self._listbox.insert(END, item) - self._listbox.config(width=0) - return self._listbox + widget.insert(END, item) + widget.config(width=0) + return widget - def _get_value(self): - selected_values = [self._listbox.get(i) for i in self._listbox.curselection()] + def _get_value(self) -> list: + selected_values = [self.widget.get(i) for i in self.widget.curselection()] return selected_values class PassFailDialog(TkDialog): - _left_button = 'PASS' - _right_button = 'FAIL' + left_button = 'PASS' + right_button = 'FAIL' - def _get_value(self): + def _get_value(self) -> bool: return True - def _get_right_button_value(self): + def _get_right_button_value(self) -> bool: return False From 53beaf532e93c65c9a62132a2b3587d0375b6af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 3 Feb 2023 01:12:30 +0200 Subject: [PATCH 0358/1592] Dialogs: Fix setting focus to entry widget on Windows. Part of #4635. --- src/robot/libraries/dialogs_py.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/robot/libraries/dialogs_py.py b/src/robot/libraries/dialogs_py.py index f4d25a9a302..316a7e3681c 100644 --- a/src/robot/libraries/dialogs_py.py +++ b/src/robot/libraries/dialogs_py.py @@ -65,6 +65,8 @@ def _finalize_dialog(self): self.geometry(f'{width}x{height}+{x}+{y}') self.lift() self.deiconify() + if self.widget: + self.widget.focus_set() def _create_body(self, message, value, **config) -> Union[Entry, Listbox, None]: frame = Frame(self) @@ -74,7 +76,6 @@ def _create_body(self, message, value, **config) -> Union[Entry, Listbox, None]: widget = self._create_widget(frame, value, **config) if widget: widget.pack(fill=BOTH) - widget.focus_set() frame.pack(padx=5, pady=5, expand=1, fill=BOTH) return widget From 63c82a31bfebfa7dc63e629abaab2ebed25777bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 3 Feb 2023 12:04:09 +0200 Subject: [PATCH 0359/1592] Enhance docstring. --- src/robot/result/resultbuilder.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/robot/result/resultbuilder.py b/src/robot/result/resultbuilder.py index 882bbeef69f..a3cb4ceb7e0 100644 --- a/src/robot/result/resultbuilder.py +++ b/src/robot/result/resultbuilder.py @@ -88,13 +88,12 @@ def __init__(self, source, include_keywords=True, flattened_keywords=None): """ :param source: Path to the XML output file to build :class:`~.executionresult.Result` objects from. - :param include_keywords: Boolean controlling whether to include - keyword information in the result or not. Keywords are - not needed when generating only report. Although the the option name - has word "keyword", it controls also including FOR and IF structures. - :param flatten_keywords: List of patterns controlling what keywords to - flatten. See the documentation of ``--flattenkeywords`` option for - more details. + :param include_keywords: Controls whether to include keywords and control + structures like FOR and IF in the result or not. They are not needed + when generating only a report. + :param flattened_keywords: List of patterns controlling what keywords + and control structures to flatten. See the documentation of + the ``--flattenkeywords`` option for more details. """ self._source = source \ if isinstance(source, ETSource) else ETSource(source) From d1febc57e9467cf4a06e508cac3b359ad3bc7aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 3 Feb 2023 13:20:47 +0200 Subject: [PATCH 0360/1592] Consistent type conversion with `arg: T = None`. Prior to Python 3.11 that syntax was considered same as `arg: T|None = None` and with unions we don't look at the default value at all if `T` isn't known. This commit makes behavior with Python 3.11 consistent with how conversion works with older Python versions. Fixes #4626. Might be better not to look at the default values in general if an argument has type information. That would be a backwards incompatible change, though, and needs to wait for a major version. --- .../keywords/type_conversion/annotations.robot | 5 ++++- .../type_conversion/annotations_with_typing.robot | 3 +++ .../keywords/type_conversion/Annotations.py | 4 ++++ .../type_conversion/AnnotationsWithTyping.py | 6 +++++- .../keywords/type_conversion/annotations.robot | 12 ++++++++++-- .../type_conversion/annotations_with_typing.robot | 9 +++++++++ src/robot/running/arguments/argumentconverter.py | 14 +++++++++++++- 7 files changed, 48 insertions(+), 5 deletions(-) diff --git a/atest/robot/keywords/type_conversion/annotations.robot b/atest/robot/keywords/type_conversion/annotations.robot index 03cae28a65e..608d82929a4 100644 --- a/atest/robot/keywords/type_conversion/annotations.robot +++ b/atest/robot/keywords/type_conversion/annotations.robot @@ -210,7 +210,10 @@ Invalid kwonly Return value annotation causes no error Check Test Case ${TESTNAME} -None as default +None as default with known type + Check Test Case ${TESTNAME} + +None as default with unknown type Check Test Case ${TESTNAME} Forward references diff --git a/atest/robot/keywords/type_conversion/annotations_with_typing.robot b/atest/robot/keywords/type_conversion/annotations_with_typing.robot index 67f9ec4836f..e3819fe67a6 100644 --- a/atest/robot/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/robot/keywords/type_conversion/annotations_with_typing.robot @@ -99,6 +99,9 @@ Invalid Set None as default Check Test Case ${TESTNAME} +None as default with Any + Check Test Case ${TESTNAME} + Forward references Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/Annotations.py b/atest/testdata/keywords/type_conversion/Annotations.py index 28d8643299d..6734b64a317 100644 --- a/atest/testdata/keywords/type_conversion/Annotations.py +++ b/atest/testdata/keywords/type_conversion/Annotations.py @@ -212,6 +212,10 @@ def none_as_default(argument: list = None, expected=None): _validate_type(argument, expected) +def none_as_default_with_unknown_type(argument: Unknown = None, expected=None): + _validate_type(argument, expected) + + def forward_referenced_concrete_type(argument: 'int', expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py index 41af7ecf4be..0e6083f552e 100644 --- a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py +++ b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py @@ -1,4 +1,4 @@ -from typing import (Dict, List, Mapping, MutableMapping, MutableSet, MutableSequence, +from typing import (Any, Dict, List, Mapping, MutableMapping, MutableSet, MutableSequence, Set, Sequence, Tuple, Union) try: from typing_extensions import TypedDict @@ -117,6 +117,10 @@ def none_as_default(argument: List = None, expected=None): _validate_type(argument, expected) +def none_as_default_with_any(argument: Any = None, expected=None): + _validate_type(argument, expected) + + def forward_reference(argument: 'List', expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/annotations.robot b/atest/testdata/keywords/type_conversion/annotations.robot index b0dd5127890..49029a1234c 100644 --- a/atest/testdata/keywords/type_conversion/annotations.robot +++ b/atest/testdata/keywords/type_conversion/annotations.robot @@ -561,12 +561,20 @@ Invalid kwonly Return value annotation causes no error Return value annotation 42 42 -None as default +None as default with known type None as default None as default [] [] +None as default with unknown type + [Documentation] `a: T = None` was same as `a: T|None = None` prior to Python 3.11. + ... With unions we don't look at the default if `T` isn't a known type + ... and that behavior is preserved for backwards compatiblity. + None as default with unknown type + None as default with unknown type hi! 'hi!' + None as default with unknown type ${42} 42 + None as default with unknown type None 'None' + Forward references - [Tags] require-py3.5 Forward referenced concrete type 42 42 Forward referenced ABC [] [] diff --git a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot index 311f0ce80f0..43dece09569 100644 --- a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot @@ -177,6 +177,15 @@ None as default None as default None as default [1, 2, 3, 4] [1, 2, 3, 4] +None as default with Any + [Documentation] `a: Any = None` was same as `a: Any|None = None` prior to Python 3.11. + ... With unions we don't look at the default in this case and that + ... behavior is preserved for backwards compatiblity. + None as default with Any + None as default with Any hi! 'hi!' + None as default with Any ${42} 42 + None as default with Any None 'None' + Forward references Forward reference [1, 2, 3, 4] [1, 2, 3, 4] Forward ref with types [1, '2', 3, 4.0] [1, 2, 3, 4] diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index ff7be2ad032..544fae22ae7 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -65,7 +65,7 @@ def _convert(self, name, value): return converter.convert(name, value) except ValueError as err: conversion_error = err - if name in spec.defaults: + if self._convert_based_on_defaults(name, spec, bool(conversion_error)): converter = TypeConverter.converter_for(type(spec.defaults[name]), languages=self._languages) if converter: @@ -77,3 +77,15 @@ def _convert(self, name, value): if conversion_error: raise conversion_error return value + + def _convert_based_on_defaults(self, name, spec, has_known_type): + if name not in spec.defaults: + return False + # Handle `arg: T = None` consistently with different Python versions + # regardless is `T` a known type or not. Prior to 3.11 this syntax was + # considered same as `arg: Union[T, None] = None` and with unions we + # don't look at the possible default value if `T` is not known. + # https://github.com/robotframework/robotframework/issues/4626 + return (name not in spec.types + or spec.defaults[name] is not None + or has_known_type) From 1f8b9cab65f58f1d174a407df99849d2fa561dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 3 Feb 2023 15:12:22 +0200 Subject: [PATCH 0361/1592] Mention that RF 7 requires Python 3.8+. Fixes #4637. --- INSTALL.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/INSTALL.rst b/INSTALL.rst index d31ab827367..525e625fcbb 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -31,8 +31,10 @@ available. Robot Framework requires Python 3.6 or newer. If you need to use Python 2, `Jython <http://jython.org>`_ or `IronPython <http://ironpython.net>`_, you can use `Robot Framework 4.1.3`__. +The forthcoming Robot Framework 7.0 will require `Python 3.8 or newer`__. -__ https://github.com/robotframework/robotframework/tree/v4.1.3#readme +__ https://github.com/robotframework/robotframework/blob/v4.1.3/INSTALL.rst +__ https://github.com/robotframework/robotframework/issues/4294 Installing Python on Linux ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -92,7 +94,7 @@ added to `PATH`, you can open the command prompt and execute `python --version`: C:\>python --version Python 3.9.4 -If you install multiple Python versions on Windows, the Python that is used +If you install multiple Python versions on Windows, the version that is used when you execute `python` is the one first in `PATH`. If you need to use others, the easiest way is using the `py launcher`__: From 4c7031d966c69c106eff0eb79e5cbaea46cc19a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 6 Feb 2023 23:36:50 +0200 Subject: [PATCH 0362/1592] Fix handling empty unions in argument conversion. It is now an explicit error to use `a: Union` or `a: ()`. The error message also explains that an empty union isn't allowed. Fixes #4638. Fixes #4646. --- atest/robot/keywords/type_conversion/unions.robot | 3 +++ atest/testdata/keywords/type_conversion/unions.py | 8 ++++++++ atest/testdata/keywords/type_conversion/unions.robot | 5 +++++ src/robot/running/arguments/typeconverters.py | 6 +++++- 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/atest/robot/keywords/type_conversion/unions.robot b/atest/robot/keywords/type_conversion/unions.robot index 62b32782e82..ea765641c32 100644 --- a/atest/robot/keywords/type_conversion/unions.robot +++ b/atest/robot/keywords/type_conversion/unions.robot @@ -62,3 +62,6 @@ Union with invalid types Tuple with invalid types Check Test Case ${TESTNAME} + +Union without types + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/unions.py b/atest/testdata/keywords/type_conversion/unions.py index d82b188e3f7..e5722d0e932 100644 --- a/atest/testdata/keywords/type_conversion/unions.py +++ b/atest/testdata/keywords/type_conversion/unions.py @@ -109,3 +109,11 @@ def union_with_invalid_types(argument: Union['nonex', 'references'], expected): def tuple_with_invalid_types(argument: ('invalid', 666), expected): assert argument == expected + + +def union_without_types(argument: Union): + assert False + + +def empty_tuple(argument: ()): + assert False diff --git a/atest/testdata/keywords/type_conversion/unions.robot b/atest/testdata/keywords/type_conversion/unions.robot index bbfabadae31..969e885c980 100644 --- a/atest/testdata/keywords/type_conversion/unions.robot +++ b/atest/testdata/keywords/type_conversion/unions.robot @@ -143,3 +143,8 @@ Tuple with invalid types [Template] Tuple with invalid types xxx xxx ${42} ${42} + +Union without types + [Template] Conversion should fail + Union without types whatever error=Cannot have union without types. type=union + Empty tuple ${666} error=Cannot have union without types. type=union arg_type=integer diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 4d60701503d..448300caec2 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -645,10 +645,12 @@ def _get_types(self, union): return () if isinstance(union, tuple): return union - return union.__args__ + return getattr(union, '__args__', ()) @property def type_name(self): + if not self.used_type: + return 'union' return ' or '.join(type_name(t) for t in self.used_type) @classmethod @@ -665,6 +667,8 @@ def no_conversion_needed(self, value): return False def _convert(self, value, explicit_type=True): + if not self.used_type: + raise ValueError('Cannot have union without types.') for converter in self.converters: if not converter: return value From 600aa75e2fd493a891898476554b12e8c4a95e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 7 Feb 2023 19:01:23 +0200 Subject: [PATCH 0363/1592] Optional base classes for dynamic and hybrid libs. #4567 --- doc/api/autodoc/robot.api.rst | 8 + .../CreatingTestLibraries.rst | 4 +- src/robot/api/__init__.py | 10 +- src/robot/api/interfaces.py | 261 ++++++++++++++++++ src/robot/result/model.py | 2 +- 5 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 src/robot/api/interfaces.py diff --git a/doc/api/autodoc/robot.api.rst b/doc/api/autodoc/robot.api.rst index 48d01816b4a..29461c5d1f6 100644 --- a/doc/api/autodoc/robot.api.rst +++ b/doc/api/autodoc/robot.api.rst @@ -25,6 +25,14 @@ robot.api.exceptions module :undoc-members: :show-inheritance: +robot.api.interfaces module +--------------------------- + +.. automodule:: robot.api.interfaces + :members: + :undoc-members: + :show-inheritance: + robot.api.logger module ----------------------- diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index a3b31a34cd5..799a8123abe 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1082,8 +1082,8 @@ below implementing the same keyword as in earlier examples: Regardless of the approach that is used, it is not necessarily to specify types for all arguments. When specifying types as a list, it is possible -to use `None` to mark that a certain argument does not have a type, and -arguments at the end can be omitted altogether. For example, both of these +to use `None` to mark that a certain argument does not have type information +and arguments at the end can be omitted altogether. For example, both of these keywords specify the type only for the second argument: .. sourcecode:: python diff --git a/src/robot/api/__init__.py b/src/robot/api/__init__.py index f846165d3c9..d1440d186e2 100644 --- a/src/robot/api/__init__.py +++ b/src/robot/api/__init__.py @@ -30,6 +30,9 @@ reporting failures and other events. These exceptions can be imported also directly via :mod:`robot.api` like ``from robot.api import SkipExecution``. +* :mod:`.interfaces` that contains optional base classes that can be used + when creating libraries or listeners. New in RF 6.1. + * :mod:`.parsing` module exposing the parsing APIs. This module is new in Robot Framework 4.0. Various parsing related functions and classes were exposed directly via :mod:`robot.api` already in Robot Framework 3.2, but they are @@ -62,9 +65,9 @@ classes for external tools that need to work with different translations. The latter is also the base class to use with custom translations. -All of the above names can be imported like:: +All of the above classes can be imported like:: - from robot.api import ApiName + from robot.api import ClassName See documentations of the individual APIs for more details. @@ -75,8 +78,7 @@ from robot.conf.languages import Language, Languages from robot.model import SuiteVisitor from robot.parsing import (get_tokens, get_resource_tokens, get_init_tokens, - get_model, get_resource_model, get_init_model, - Token) + get_model, get_resource_model, get_init_model, Token) from robot.reporting import ResultWriter from robot.result import ExecutionResult, ResultVisitor from robot.running import TestSuite, TestSuiteBuilder diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py new file mode 100644 index 00000000000..e884b465151 --- /dev/null +++ b/src/robot/api/interfaces.py @@ -0,0 +1,261 @@ +# 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. + +"""Optional base classes for libraries and listeners. + +Module contents: + +- :class:`DynamicLibrary` for libraries using the `dynamic library API`__. +- :class:`HybridLibrary` for libraries using the `hybrid library API`__. +- `ListenerV2` for `listener interface version 2`__. *TODO*. +- `ListenerV3` for `listener interface version 3`__. *TODO*. +- Type definitions used by the aforementioned classes. + +Main benefit of using these base classes is that editors can provide automatic +completion, documentation and type information. Their usage is not required. +Notice also that libraries typically use the static API and do not need any +base class. + +.. note:: These classes are not exposed via the top level :mod:`robot.api` + package. They need to imported via :mod:`robot.api.interfaces`. + +New in Robot Framework 6.1. + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dynamic-library-api +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#hybrid-library-api +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-2 +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-3 +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple, Union + + +# Type aliases. +Name = str +Documentation = str +ArgumentSpec = List[ + Union[ + str, # Name with possible default like `arg` or `arg=1`. + Tuple[str], # Name without a default like `('arg',)`. + Tuple[str, Any] # Name and default like `('arg', 1)`. + ] +] +TypeSpec = Union[ + Dict[ # Types by name. + str, # Name. + Union[ + type, # Actual type. + str # Type name or alias. + ] + ], + List[ # Types by position. + Union[ + type, # Actual type. + str, # Type name or alias. + None # No type info. + ] + ] +] +Tags = List[str] +Source = str + + +class DynamicLibrary(ABC): + """Optional base class for libraries using the dynamic library API. + + The dynamic library API makes it possible to dynamically specify + what keywords a library implements and run them by using + :meth:`get_keyword_names` and :meth:`run_keyword` methods, respectively. + In addition to that it has various optional methods for returning more + information about the implemented keywords to Robot Framework. + """ + + @abstractmethod + def get_keyword_names(self) -> List[Name]: + """Return names of the keywords this library implements. + + :return: Keyword names as a list of strings. + + ``name`` passed to other methods is always in the same format as + returned by this method. + """ + raise NotImplementedError + + @abstractmethod + def run_keywords(self, name: Name, args: List[Any], named: Dict[str, Any]) -> Any: + """Execute the specified keyword using the given arguments. + + :param name: Keyword name as a string. + :param args: Positional arguments as a list. + :param named: Named arguments as a dictionary. + :raises: Reporting FAIL or SKIP status. + :return: Keyword's return value. + + Reporting status, logging, returning values, etc. is handled the same + way as with the normal static library API. + """ + raise NotImplementedError + + def get_keyword_documentation(self, name: Name) -> Optional[Documentation]: + """Optional method to return keyword documentation. + + The first logical line of keyword documentation is shown in + the execution log under the executed keyword. The whole + documentation is shown in documentation generated by Libdoc. + + :param name: Keyword name as a string. + :return: Documentation as a string oras ``None`` if there is no + documentation. + + This method is also used to get the overall library documentation as + well as documentation related to importing the library. They are + got by calling this method with special names ``__intro__`` and + ``__init__``, respectively. + """ + return None + + def get_keyword_arguments(self, name: Name) -> Optional[ArgumentSpec]: + """Optional method to return keyword's argument specification. + + Returned information is used during execution for argument validation. + In addition to that, arguments are shown in documentation generated + by Libdoc. + + :param name: Keyword name as a string. + :return: Argument specification using format explained below. + + Argument specification defines what arguments the keyword accepts. + Returning ``None`` means that the keywords accepts any arguments. + Accepted arguments are returned as a list using these rules: + + - Normal arguments are specified as a list of strings like + ``['arg1', 'arg2']``. An empty list denotes that the keyword + accepts no arguments. + - Varargs must have a ``*`` prefix like ``['*numbers']``. There can + be only one varargs, and it must follow normal arguments. + - Arguments after varargs like ``['*items', 'arg']`` are considered + named-only arguments. + - If keyword does not accept varargs, a lone ``*`` can be used + a separator between normal and named-only arguments like + ``['normal', '*', 'named']``. + - Kwargs must have a ``**`` prefix like [``**config``]. There can + be only one kwargs, and it must be last. + + Both normal arguments and named-only arguments can have default values: + + - Default values can be embedded to argument names so that they are + separated with the equal sign like ``name=default``. In this case + the default value type is always a string. + - Alternatively arguments and their default values can be represented + as two-tuples like ``('name', 'default')``. This allows non-string + default values and automatic argument conversion based on them. + - Arguments without default values can also be specified as tuples + containing just the name like ``('name',)``. + - With normal arguments, arguments with default values must follow + arguments without them. There is no such restriction with named-only + arguments. + """ + return None + + def get_keyword_types(self, name: Name) -> Optional[TypeSpec]: + """Optional method to return keyword's type specification. + + Type information is used for automatic argument conversion during + execution. It is also shown in documentation generated by Libdoc. + + :param name: Keyword name as a string. + :return: Type specification as a dictionary, as a list, or as ``None`` + if type information is not known. + + Type information can be mapped to arguments returned by + :meth:`get_keyword_names` either by names using a dictionary or + by position using a list. For example, if a keyword has argument + specification ``['arg', 'second']``, it would be possible to return + types both like ``{'arg': str, 'second': int}`` and ``[str, int]``. + + Regardless of the approach that is used, it is not necessarily to + specify types for all arguments. When using a dictionary, some + arguments can be omitted altogether. When using a list, it is possible + to use ``None`` to mark that a certain argument does not have type + information and arguments at the end can be omitted altogether. + + If is possible to specify that an argument has multiple possible types + by using unions like ``{'arg': Union[int, float]}`` or tuples like + ``{'arg': (int, float)}``. + + In addition to specifying types using classes, it is also possible + to use names or aliases like ``{'a': 'int', 'b': 'boolean'}``. + For an up-to-date list of supported types, names and aliases see + the User Guide. + """ + return None + + def get_keyword_tags(self, name: Name) -> Optional[Tags]: + """Optional method to return keyword's tags. + + Tags are shown in the execution log and in documentation generated by + Libdoc. Tags can also be used with various command line options. + + :param name: Keyword name as a string. + :return: Tags as a list of strings or ``None`` if there are no tags. + """ + return None + + def get_keyword_source(self, name: Name) -> Optional[Source]: + """Optional method to return keyword's source path and line number. + + Source information is used by IDEs to provide navigation from + keyword usage to implementation. + + :param name: Keyword name as a string. + :return: Source as a string in format ``path:lineno`` or ``None`` + if source is not known. + + The general format to return the source is ``path:lineno`` like + ``/example/Lib.py:42``. If the line number is not known, it is + possible to return only the path. If the keyword is in the same + file as the main library class, the path can be omitted and only + the line number returned like ``:42``. + + The source information of the library itself is got automatically from + the imported library class. The library source path is used with all + keywords that do not return their own path. + """ + return None + + +class HybridLibrary(ABC): + """Optional base class for libraries using the hybrid library API. + + Hybrid library API makes it easy to specify what keywords a library + implements by using the :meth:`get_keyword_names` method. After getting + keyword names, Robot Framework uses ``getattr`` to get the actual keyword + methods exactly like it does when using the normal static library API. + Keyword name, arguments, documentation, tags, and so on are got directly + from the keyword method. + + It is possible to implement keywords also outside the main library class. + In such cases the library needs to have a ``__getattr__`` method that + returns desired keyword methods. + """ + + @abstractmethod + def get_keyword_names(self) -> List[Name]: + """Return names of the implemented keyword methods as a list or strings. + + Returned names must match names of the implemented keyword methods. + """ + raise NotImplementedError diff --git a/src/robot/result/model.py b/src/robot/result/model.py index e681012a0e5..2b26e09025a 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -483,7 +483,7 @@ def messages(self): def children(self): """List of child keywords and messages in creation order. - Deprecated since Robot Framework 4.0. Use :att:`body` instead. + Deprecated since Robot Framework 4.0. Use :attr:`body` instead. """ warnings.warn("'Keyword.children' is deprecated. Use 'Keyword.body' instead.") return list(self.body) From 1227fc8e1bf16f88ffc5c5107868f5824b4623e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 6 Feb 2023 18:11:07 +0200 Subject: [PATCH 0364/1592] Add explicit converter for Any. Fixes #4647. --- .../annotations_with_typing.robot | 3 + atest/robot/libdoc/datatypes_py-json.robot | 95 ++++++++++--------- atest/robot/libdoc/datatypes_py-xml.robot | 37 +++++--- .../type_conversion/AnnotationsWithTyping.py | 4 + .../annotations_with_typing.robot | 8 ++ atest/testdata/libdoc/DataTypesLibrary.py | 8 +- .../CreatingTestLibraries.rst | 10 +- src/robot/libdocpkg/standardtypes.py | 4 + src/robot/running/arguments/typeconverters.py | 23 ++++- 9 files changed, 129 insertions(+), 63 deletions(-) diff --git a/atest/robot/keywords/type_conversion/annotations_with_typing.robot b/atest/robot/keywords/type_conversion/annotations_with_typing.robot index e3819fe67a6..9c0f3bd570e 100644 --- a/atest/robot/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/robot/keywords/type_conversion/annotations_with_typing.robot @@ -96,6 +96,9 @@ Set with incompatible types Invalid Set Check Test Case ${TESTNAME} +Any + Check Test Case ${TESTNAME} + None as default Check Test Case ${TESTNAME} diff --git a/atest/robot/libdoc/datatypes_py-json.robot b/atest/robot/libdoc/datatypes_py-json.robot index 91d034f2eb1..68db343fc6c 100644 --- a/atest/robot/libdoc/datatypes_py-json.robot +++ b/atest/robot/libdoc/datatypes_py-json.robot @@ -23,7 +23,7 @@ Init docs Keyword Arguments [Template] Verify Argument Models ${MODEL}[keywords][0][args] value operator: AssertionOperator | None = None exp: str = something? - ${MODEL}[keywords][1][args] arg: CustomType arg2: CustomType2 arg3: CustomType + ${MODEL}[keywords][1][args] arg: CustomType arg2: CustomType2 arg3: CustomType arg4: Unknown ${MODEL}[keywords][2][args] funny: bool | int | float | str | AssertionOperator | Small | GeoLocation | None = equal ${MODEL}[keywords][3][args] location: GeoLocation ${MODEL}[keywords][4][args] list_of_str: List[str] dict_str_int: Dict[str, int] whatever: Any *args: List[Any] @@ -38,9 +38,9 @@ TypedDict ... <li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0.</li> ... </ul> ... <p>Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></p> - ${MODEL}[typedocs][6][type] TypedDict - ${MODEL}[typedocs][6][name] GeoLocation - ${MODEL}[typedocs][6][doc] <p>Defines the geolocation.</p> + ${MODEL}[typedocs][7][type] TypedDict + ${MODEL}[typedocs][7][name] GeoLocation + ${MODEL}[typedocs][7][doc] <p>Defines the geolocation.</p> ... <ul> ... <li><code>latitude</code> Latitude between -90 and 90.</li> ... <li><code>longitude</code> Longitude between -180 and 180.</li> @@ -74,9 +74,9 @@ Enum ${MODEL}[dataTypes][enums][0][name] AssertionOperator ${MODEL}[dataTypes][enums][0][doc] <p>This is some Doc</p> ... <p>This has was defined by assigning to __doc__.</p> - ${MODEL}[typedocs][0][type] Enum - ${MODEL}[typedocs][0][name] AssertionOperator - ${MODEL}[typedocs][0][doc] <p>This is some Doc</p> + ${MODEL}[typedocs][1][type] Enum + ${MODEL}[typedocs][1][name] AssertionOperator + ${MODEL}[typedocs][1][doc] <p>This is some Doc</p> ... <p>This has was defined by assigning to __doc__.</p> Enum Members @@ -85,66 +85,73 @@ Enum Members FOR ${cur} ${exp} IN ZIP ${MODEL}[dataTypes][enums][0][members] ${exp_list} Dictionaries Should Be Equal ${cur} ${exp} END - FOR ${cur} ${exp} IN ZIP ${MODEL}[typedocs][0][members] ${exp_list} + FOR ${cur} ${exp} IN ZIP ${MODEL}[typedocs][1][members] ${exp_list} Dictionaries Should Be Equal ${cur} ${exp} END Custom types - ${MODEL}[typedocs][2][type] Custom - ${MODEL}[typedocs][2][name] CustomType - ${MODEL}[typedocs][2][doc] <p>Converter method doc is used when defined.</p> ${MODEL}[typedocs][3][type] Custom - ${MODEL}[typedocs][3][name] CustomType2 - ${MODEL}[typedocs][3][doc] <p>Class doc is used when converter method has no doc.</p> + ${MODEL}[typedocs][3][name] CustomType + ${MODEL}[typedocs][3][doc] <p>Converter method doc is used when defined.</p> + ${MODEL}[typedocs][4][type] Custom + ${MODEL}[typedocs][4][name] CustomType2 + ${MODEL}[typedocs][4][doc] <p>Class doc is used when converter method has no doc.</p> Standard types - ${MODEL}[typedocs][1][type] Standard - ${MODEL}[typedocs][1][name] boolean - ${MODEL}[typedocs][1][doc] <p>Strings <code>TRUE</code>, <code>YES</code>, start=True + ${MODEL}[typedocs][0][type] Standard + ${MODEL}[typedocs][0][name] Any + ${MODEL}[typedocs][0][doc] <p>Any value is accepted. No conversion is done.</p> + ${MODEL}[typedocs][2][type] Standard + ${MODEL}[typedocs][2][name] boolean + ${MODEL}[typedocs][2][doc] <p>Strings <code>TRUE</code>, <code>YES</code>, start=True Standard types with generics - ${MODEL}[typedocs][4][type] Standard - ${MODEL}[typedocs][4][name] dictionary - ${MODEL}[typedocs][4][doc] <p>Strings must be Python <a start=True - ${MODEL}[typedocs][8][type] Standard - ${MODEL}[typedocs][8][name] list - ${MODEL}[typedocs][8][doc] <p>Strings must be Python <a start=True + ${MODEL}[typedocs][5][type] Standard + ${MODEL}[typedocs][5][name] dictionary + ${MODEL}[typedocs][5][doc] <p>Strings must be Python <a start=True + ${MODEL}[typedocs][9][type] Standard + ${MODEL}[typedocs][9][name] list + ${MODEL}[typedocs][9][doc] <p>Strings must be Python <a start=True Accepted types - ${MODEL}[typedocs][1][type] Standard - ${MODEL}[typedocs][1][accepts] ['string', 'integer', 'float', 'None'] - ${MODEL}[typedocs][2][type] Custom - ${MODEL}[typedocs][2][accepts] ['string', 'integer'] + ${MODEL}[typedocs][0][type] Standard + ${MODEL}[typedocs][0][accepts] ['Any'] + ${MODEL}[typedocs][2][type] Standard + ${MODEL}[typedocs][2][accepts] ['string', 'integer', 'float', 'None'] ${MODEL}[typedocs][3][type] Custom - ${MODEL}[typedocs][3][accepts] [] - ${MODEL}[typedocs][6][type] TypedDict - ${MODEL}[typedocs][6][accepts] ['string'] - ${MODEL}[typedocs][0][type] Enum - ${MODEL}[typedocs][0][accepts] ['string'] - ${MODEL}[typedocs][10][type] Enum - ${MODEL}[typedocs][10][accepts] ['string', 'integer'] + ${MODEL}[typedocs][3][accepts] ['string', 'integer'] + ${MODEL}[typedocs][4][type] Custom + ${MODEL}[typedocs][4][accepts] [] + ${MODEL}[typedocs][7][type] TypedDict + ${MODEL}[typedocs][7][accepts] ['string'] + ${MODEL}[typedocs][1][type] Enum + ${MODEL}[typedocs][1][accepts] ['string'] + ${MODEL}[typedocs][11][type] Enum + ${MODEL}[typedocs][11][accepts] ['string', 'integer'] Usages - ${MODEL}[typedocs][1][type] Standard - ${MODEL}[typedocs][1][usages] ['Funny Unions'] - ${MODEL}[typedocs][4][type] Standard - ${MODEL}[typedocs][4][usages] ['Typing Types'] - ${MODEL}[typedocs][2][type] Custom - ${MODEL}[typedocs][2][usages] ['Custom'] - ${MODEL}[typedocs][6][type] TypedDict - ${MODEL}[typedocs][6][usages] ['Funny Unions', 'Set Location'] - ${MODEL}[typedocs][10][type] Enum - ${MODEL}[typedocs][10][usages] ['__init__', 'Funny Unions'] + ${MODEL}[typedocs][2][type] Standard + ${MODEL}[typedocs][2][usages] ['Funny Unions'] + ${MODEL}[typedocs][5][type] Standard + ${MODEL}[typedocs][5][usages] ['Typing Types'] + ${MODEL}[typedocs][3][type] Custom + ${MODEL}[typedocs][3][usages] ['Custom'] + ${MODEL}[typedocs][7][type] TypedDict + ${MODEL}[typedocs][7][usages] ['Funny Unions', 'Set Location'] + ${MODEL}[typedocs][11][type] Enum + ${MODEL}[typedocs][11][usages] ['__init__', 'Funny Unions'] Typedoc links in arguments ${MODEL}[keywords][0][args][1][typedocs] {'AssertionOperator': 'AssertionOperator', 'None': 'None'} ${MODEL}[keywords][0][args][2][typedocs] {'str': 'string'} ${MODEL}[keywords][1][args][0][typedocs] {'CustomType': 'CustomType'} ${MODEL}[keywords][1][args][1][typedocs] {'CustomType2': 'CustomType2'} + ${MODEL}[keywords][1][args][2][typedocs] {'CustomType': 'CustomType'} + ${MODEL}[keywords][1][args][3][typedocs] {} ${MODEL}[keywords][2][args][0][typedocs] {'bool': 'boolean', 'int': 'integer', 'float': 'float', 'str': 'string', 'AssertionOperator': 'AssertionOperator', 'Small': 'Small', 'GeoLocation': 'GeoLocation', 'None': 'None'} ${MODEL}[keywords][4][args][0][typedocs] {'List[str]': 'list'} ${MODEL}[keywords][4][args][1][typedocs] {'Dict[str, int]': 'dictionary'} - ${MODEL}[keywords][4][args][2][typedocs] {} + ${MODEL}[keywords][4][args][2][typedocs] {'Any': 'Any'} ${MODEL}[keywords][4][args][3][typedocs] {'List[Any]': 'list'} *** Keywords *** diff --git a/atest/robot/libdoc/datatypes_py-xml.robot b/atest/robot/libdoc/datatypes_py-xml.robot index bf1378a789f..2a48240a3ca 100644 --- a/atest/robot/libdoc/datatypes_py-xml.robot +++ b/atest/robot/libdoc/datatypes_py-xml.robot @@ -52,40 +52,47 @@ Custom Standard DataType Standard Should Be 0 + ... Any + ... Any value is accepted. No conversion is done. + DataType Standard Should Be 1 ... boolean ... Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``, Standard with generics - DataType Standard Should Be 1 + DataType Standard Should Be 2 ... dictionary ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#dict|dictionary] - DataType Standard Should Be 4 + DataType Standard Should Be 5 ... list ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#list|list] Accepted types - Accepted Types Should Be 1 Standard boolean + Accepted Types Should Be 0 Standard Any + ... Any + Accepted Types Should Be 2 Standard boolean ... string integer float None - Accepted Types Should Be 2 Custom CustomType + Accepted Types Should Be 3 Custom CustomType ... string integer - Accepted Types Should Be 3 Custom CustomType2 - Accepted Types Should Be 6 TypedDict GeoLocation + Accepted Types Should Be 4 Custom CustomType2 + Accepted Types Should Be 7 TypedDict GeoLocation ... string - Accepted Types Should Be 0 Enum AssertionOperator + Accepted Types Should Be 1 Enum AssertionOperator ... string - Accepted Types Should Be 10 Enum Small + Accepted Types Should Be 11 Enum Small ... string integer Usages - Usages Should Be 1 Standard boolean + Usages Should Be 0 Standard Any + ... Typing Types + Usages Should Be 2 Standard boolean ... Funny Unions - Usages Should Be 4 Standard dictionary + Usages Should Be 5 Standard dictionary ... Typing Types - Usages Should Be 2 Custom CustomType + Usages Should Be 3 Custom CustomType ... Custom - Usages Should be 6 TypedDict GeoLocation + Usages Should be 7 TypedDict GeoLocation ... Funny Unions Set Location - Usages Should Be 10 Enum Small + Usages Should Be 11 Enum Small ... __init__ Funny Unions Typedoc links in arguments @@ -93,8 +100,10 @@ Typedoc links in arguments Typedoc links should be 0 2 str:string Typedoc links should be 1 0 CustomType Typedoc links should be 1 1 CustomType2 + Typedoc links should be 1 2 CustomType + Typedoc links should be 1 3 Unknown: Typedoc links should be 2 0 bool:boolean int:integer float str:string AssertionOperator Small GeoLocation None Typedoc links should be 4 0 List[str]:list Typedoc links should be 4 1 Dict[str, int]:dictionary - Typedoc links should be 4 2 Any: + Typedoc links should be 4 2 Any:Any Typedoc links should be 4 3 List[Any]:list diff --git a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py index 0e6083f552e..f0a05af595e 100644 --- a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py +++ b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py @@ -113,6 +113,10 @@ def mutable_set_with_types(argument: MutableSet[float], expected=None): _validate_type(argument, expected) +def any_(argument: Any = 1, expected=None): + _validate_type(argument, expected) + + def none_as_default(argument: List = None, expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot index 43dece09569..7c38950424f 100644 --- a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot @@ -173,9 +173,17 @@ Invalid Set Set {} error=Value is dictionary, not set. Set ooops error=Invalid expression. +Any + Any hello 'hello' + Any 42 '42' + Any ${42} 42 + Any None 'None' + Any ${None} None + None as default None as default None as default [1, 2, 3, 4] [1, 2, 3, 4] + None as default NoNe None None as default with Any [Documentation] `a: Any = None` was same as `a: Any|None = None` prior to Python 3.11. diff --git a/atest/testdata/libdoc/DataTypesLibrary.py b/atest/testdata/libdoc/DataTypesLibrary.py index dd9a002af16..7cceddcfec5 100644 --- a/atest/testdata/libdoc/DataTypesLibrary.py +++ b/atest/testdata/libdoc/DataTypesLibrary.py @@ -71,6 +71,10 @@ def __init__(self, value): self.value = value +class Unknown: + pass + + class A: @classmethod def not_used_converter_should_not_be_documented(cls, value): @@ -103,7 +107,7 @@ def set_location(self, location: GeoLocation): def assert_something(self, value, operator: Optional[AssertionOperator] = None, exp: str = 'something?'): """This links to `AssertionOperator` . - This is the next Line that links to 'Set Location` . + This is the next Line that links to `Set Location` . """ pass @@ -124,5 +128,5 @@ def funny_unions(self, def typing_types(self, list_of_str: List[str], dict_str_int: Dict[str, int], whatever: Any, *args: List[Any]): pass - def custom(self, arg: CustomType, arg2: 'CustomType2', arg3: CustomType): + def custom(self, arg: CustomType, arg2: 'CustomType2', arg3: CustomType, arg4: Unknown): pass diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 799a8123abe..6438ca209e2 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1274,6 +1274,12 @@ Other types cause conversion failures. | None_ | | NoneType | str_ | String `NONE` (case-insensitive) is converted to the Python | | `None` | | | | | | `None` object. Other values cause an error. | | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | Any_ | | | Any | Any value is accepted. No conversion is done. | | + | | | | | | | + | | | | | New in RF 6.1. Any_ was not recognized with earlier versions, | | + | | | | | but conversion may have been done based on `default values | | + | | | | | <Implicit argument types based on default values_>`__. | | + +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | list_ | Sequence_ | | str_, | Strings must be Python list literals. They are converted | | `['one', 'two']` | | | | | Sequence_ | to actual lists using the `ast.literal_eval`_ function. | | `[('one', 1), ('two', 2)]` | | | | | | They can contain any values `ast.literal_eval` supports, | | @@ -1304,13 +1310,14 @@ Other types cause conversion failures. | | | | | | | `{'width': 1600, 'enabled': True}` | +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ -.. note:: Starting from Robot Framework 5.0, types that are automatically converted are +.. note:: Starting from Robot Framework 5.0, types that have a converted are automatically shown in Libdoc_ outputs. .. note:: Prior to Robot Framework 4.0, most types supported converting string `NONE` (case-insensitively) to Python `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. +.. _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 .. _Integral: https://docs.python.org/library/numbers.html#numbers.Integral @@ -1796,7 +1803,6 @@ information about conversion. It is especially important to document converter functions registered for existing types, because their own documentation is likely not very useful in this context. - `@keyword` decorator ~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/libdocpkg/standardtypes.py b/src/robot/libdocpkg/standardtypes.py index e90cdef932e..3366063ed65 100644 --- a/src/robot/libdocpkg/standardtypes.py +++ b/src/robot/libdocpkg/standardtypes.py @@ -16,9 +16,13 @@ from datetime import date, datetime, timedelta from decimal import Decimal from pathlib import Path +from typing import Any STANDARD_TYPE_DOCS = { + Any: '''\ +Any value is accepted. No conversion is done. +''', bool: '''\ Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``, the empty string as well as strings ``FALSE``, ``NO``, ``OFF`` and ``0`` diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 448300caec2..24092f6071b 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -16,13 +16,13 @@ from ast import literal_eval from collections import OrderedDict from collections.abc import ByteString, Container, Mapping, Sequence, Set -from typing import Any, Tuple, TypeVar, Union from datetime import datetime, date, timedelta from decimal import InvalidOperation, Decimal from enum import Enum from numbers import Integral, Real from os import PathLike from pathlib import Path, PurePath +from typing import Any, Tuple, TypeVar, Union from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time @@ -216,6 +216,27 @@ def _find_by_int_value(self, enum, value): f"Available: {seq2str(values)}") +@TypeConverter.register +class AnyConverter(TypeConverter): + type = Any + type_name = 'Any' + aliases = ('any',) + value_types = (Any,) + + @classmethod + def handles(cls, type_): + return type_ is Any + + def no_conversion_needed(self, value): + return True + + def _convert(self, value, explicit_type=True): + return value + + def _handles_value(self, value): + return True + + @TypeConverter.register class StringConverter(TypeConverter): type = str From c76728e62cbb5659220e08e468d88ea92fa9a4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 6 Feb 2023 22:59:03 +0200 Subject: [PATCH 0365/1592] Consistent handling of unrecognized types in union conversion. After this change `arg: int|Unrecognized` and `arg: Unrecognized|int` behave the same way. Earlier `int` conversions wasn't attempted in the latter case because the unrecognized type was encountered first. Fixes #4648. After this change `arg: T = None` and `arg: T|None = None` are handled the same way regardless is `T` known or not. That means that it was possible to remove the code needed to make `arg: T = None` behave consistently with different Python versions (#4626). --- .../keywords/type_conversion/unions.robot | 11 +++++- .../keywords/type_conversion/unionsugar.robot | 5 ++- .../type_conversion/annotations.robot | 8 ++-- .../type_conversion/conversion.resource | 3 ++ .../keywords/type_conversion/unions.py | 23 ++++++++---- .../keywords/type_conversion/unions.robot | 37 ++++++++++++++----- .../keywords/type_conversion/unionsugar.py | 12 +++--- .../keywords/type_conversion/unionsugar.robot | 16 +++++--- .../CreatingTestLibraries.rst | 32 ++++++++-------- .../running/arguments/argumentconverter.py | 14 +------ src/robot/running/arguments/typeconverters.py | 29 ++++++++++----- 11 files changed, 118 insertions(+), 72 deletions(-) diff --git a/atest/robot/keywords/type_conversion/unions.robot b/atest/robot/keywords/type_conversion/unions.robot index ea765641c32..98da904562f 100644 --- a/atest/robot/keywords/type_conversion/unions.robot +++ b/atest/robot/keywords/type_conversion/unions.robot @@ -30,7 +30,10 @@ Union with item not liking isinstance Argument not matching union Check Test Case ${TESTNAME} -Union with custom type +Union with unrecognized type + Check Test Case ${TESTNAME} + +Union with only unrecognized types Check Test Case ${TESTNAME} Multiple types using tuple @@ -57,6 +60,12 @@ Avoid unnecessary conversion Avoid unnecessary conversion with ABC Check Test Case ${TESTNAME} +Default value type + Check Test Case ${TESTNAME} + +Default value type with unrecognized type + Check Test Case ${TESTNAME} + Union with invalid types Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/unionsugar.robot b/atest/robot/keywords/type_conversion/unionsugar.robot index 97c098f0c97..f50493409b3 100644 --- a/atest/robot/keywords/type_conversion/unionsugar.robot +++ b/atest/robot/keywords/type_conversion/unionsugar.robot @@ -31,7 +31,10 @@ Union with item not liking isinstance Argument not matching union Check Test Case ${TESTNAME} -Union with custom type +Union with unrecognized type + Check Test Case ${TESTNAME} + +Union with only unrecognized types Check Test Case ${TESTNAME} Avoid unnecessary conversion diff --git a/atest/testdata/keywords/type_conversion/annotations.robot b/atest/testdata/keywords/type_conversion/annotations.robot index 49029a1234c..45a6700dc5c 100644 --- a/atest/testdata/keywords/type_conversion/annotations.robot +++ b/atest/testdata/keywords/type_conversion/annotations.robot @@ -563,16 +563,14 @@ Return value annotation causes no error None as default with known type None as default - None as default [] [] + None as default [1, 2] [1, 2] + None as default None None None as default with unknown type - [Documentation] `a: T = None` was same as `a: T|None = None` prior to Python 3.11. - ... With unions we don't look at the default if `T` isn't a known type - ... and that behavior is preserved for backwards compatiblity. None as default with unknown type None as default with unknown type hi! 'hi!' None as default with unknown type ${42} 42 - None as default with unknown type None 'None' + None as default with unknown type None None Forward references Forward referenced concrete type 42 42 diff --git a/atest/testdata/keywords/type_conversion/conversion.resource b/atest/testdata/keywords/type_conversion/conversion.resource index c2613db084b..7a71385efab 100644 --- a/atest/testdata/keywords/type_conversion/conversion.resource +++ b/atest/testdata/keywords/type_conversion/conversion.resource @@ -1,3 +1,6 @@ +*** Variables *** +${CUSTOM} ${{type('Custom', (), {})()}} + *** Keywords *** Conversion Should Fail [Arguments] ${kw} @{args} ${error}= ${type}=${kw.lower()} ${arg_type}= &{kwargs} diff --git a/atest/testdata/keywords/type_conversion/unions.py b/atest/testdata/keywords/type_conversion/unions.py index e5722d0e932..a9c42139faa 100644 --- a/atest/testdata/keywords/type_conversion/unions.py +++ b/atest/testdata/keywords/type_conversion/unions.py @@ -10,7 +10,7 @@ class MyObject: pass -class UnexpectedObject: +class AnotherObject: pass @@ -27,10 +27,6 @@ def create_my_object(): return MyObject() -def create_unexpected_object(): - return UnexpectedObject() - - def union_of_int_float_and_string(argument: Union[int, float, str], expected): assert argument == expected @@ -71,8 +67,12 @@ def union_with_item_not_liking_isinstance(argument: Union[BadRational, int], exp assert argument == expected, '%r != %r' % (argument, expected) -def custom_type_in_union(argument: Union[MyObject, str], expected_type): - assert isinstance(argument, eval(expected_type)) +def unrecognized_type(argument: Union[MyObject, str], expected_type): + assert type(argument).__name__ == expected_type + + +def only_unrecognized_types(argument: Union[MyObject, AnotherObject], expected_type): + assert type(argument).__name__ == expected_type def tuple_of_int_float_and_string(argument: (int, float, str), expected): @@ -103,6 +103,15 @@ def union_with_string_first(argument: Union[str, None], expected): assert argument == expected +def incompatible_default(argument: Union[None, int] = 1.1, expected=object()): + assert argument == expected + + +def unrecognized_type_with_incompatible_default(argument: Union[MyObject, int] = 1.1, + expected=object()): + assert argument == expected + + def union_with_invalid_types(argument: Union['nonex', 'references'], expected): assert argument == expected diff --git a/atest/testdata/keywords/type_conversion/unions.robot b/atest/testdata/keywords/type_conversion/unions.robot index 969e885c980..987b1e4ab00 100644 --- a/atest/testdata/keywords/type_conversion/unions.robot +++ b/atest/testdata/keywords/type_conversion/unions.robot @@ -24,6 +24,7 @@ Union with None and str 1 1 NONE NONE ${2} ${2} + ${2.0} ${2} ${None} ${None} three three @@ -60,17 +61,26 @@ Argument not matching union [Template] Conversion Should Fail Union of int and float not a number type=integer or float Union of int and float ${NONE} type=integer or float arg_type=None - Union of int and float ${{type('Custom', (), {})()}} - ... type=integer or float arg_type=Custom + Union of int and float ${CUSTOM} type=integer or float arg_type=Custom Union with int and None invalid type=integer or None + Union with int and None ${1.1} type=integer or None arg_type=float Union with subscripted generics invalid type=list or integer -Union with custom type +Union with unrecognized type ${myobject}= Create my object - ${object}= Create unexpected object - Custom type in union my string str - Custom type in union ${myobject} MyObject - Custom type in union ${object} UnexpectedObject + Unrecognized type my string str + Unrecognized type ${myobject} MyObject + Unrecognized type ${42} str + Unrecognized type ${CUSTOM} str + Unrecognized type ${{type('StrFails', (), {'__str__': lambda self: 1/0})()}} + ... StrFails + +Union with only unrecognized types + ${myobject}= Create my object + Only unrecognized types my string str + Only unrecognized types ${myobject} MyObject + Only unrecognized types ${42} int + Only unrecognized types ${CUSTOM} Custom Multiple types using tuple [Template] Tuple of int float and string @@ -84,8 +94,7 @@ Argument not matching tuple types [Template] Conversion Should Fail Tuple of int and float not a number type=integer or float Tuple of int and float ${NONE} type=integer or float arg_type=None - Tuple of int and float ${{type('Custom', (), {})()}} - ... type=integer or float arg_type=Custom + Tuple of int and float ${CUSTOM} type=integer or float arg_type=Custom Optional argument [Template] Optional argument @@ -134,6 +143,16 @@ Avoid unnecessary conversion with ABC ${1} ${1} ${{fractions.Fraction(1, 3)}} ${{fractions.Fraction(1, 3)}} +Default value type + [Documentation] Default value type is used if conversion fails. + Incompatible default 1 ${1} + Incompatible default 1.2 ${1.2} + +Default value type with unrecognized type + [Documentation] Default value type is never used because conversion cannot fail. + Unrecognized type with incompatible default 1 ${1} + Unrecognized type with incompatible default 1.2 1.2 + Union with invalid types [Template] Union with invalid types xxx xxx diff --git a/atest/testdata/keywords/type_conversion/unionsugar.py b/atest/testdata/keywords/type_conversion/unionsugar.py index 6775ba409a0..76c6186e6af 100644 --- a/atest/testdata/keywords/type_conversion/unionsugar.py +++ b/atest/testdata/keywords/type_conversion/unionsugar.py @@ -6,7 +6,7 @@ class MyObject: pass -class UnexpectedObject: +class AnotherObject: pass @@ -23,10 +23,6 @@ def create_my_object(): return MyObject() -def create_unexpected_object(): - return UnexpectedObject() - - def union_of_int_float_and_string(argument: int | float | str, expected): assert argument == expected @@ -68,7 +64,11 @@ def union_with_item_not_liking_isinstance(argument: BadRational | bool, expected def custom_type_in_union(argument: MyObject | str, expected_type): - assert isinstance(argument, eval(expected_type)) + assert type(argument).__name__ == expected_type + + +def only_custom_types_in_union(argument: MyObject | AnotherObject, expected_type): + assert type(argument).__name__ == expected_type def union_with_string_first(argument: str | None, expected): diff --git a/atest/testdata/keywords/type_conversion/unionsugar.robot b/atest/testdata/keywords/type_conversion/unionsugar.robot index d05e790a78f..19a8bb46275 100644 --- a/atest/testdata/keywords/type_conversion/unionsugar.robot +++ b/atest/testdata/keywords/type_conversion/unionsugar.robot @@ -61,17 +61,23 @@ Argument not matching union [Template] Conversion Should Fail Union of int and float not a number type=integer or float Union of int and float ${NONE} type=integer or float arg_type=None - Union of int and float ${{type('Custom', (), {})()}} - ... type=integer or float arg_type=Custom + Union of int and float ${CUSTOM} type=integer or float arg_type=Custom Union with int and None invalid type=integer or None Union with subscripted generics invalid type=list or integer -Union with custom type +Union with unrecognized type ${myobject}= Create my object - ${object}= Create unexpected object Custom type in union my string str Custom type in union ${myobject} MyObject - Custom type in union ${object} UnexpectedObject + Custom type in union ${42} str + Custom type in union ${CUSTOM} str + +Union with only unrecognized types + ${myobject}= Create my object + Only custom types in union my string str + Only custom types in union ${myobject} MyObject + Only custom types in union ${42} int + Only custom types in union ${CUSTOM} Custom Avoid unnecessary conversion [Template] Union With String First diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 6438ca209e2..0f50af190fb 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1361,7 +1361,7 @@ has multiple possible types. In this situation argument conversion is attempted based on each type and the whole conversion fails if none of these conversions succeed. -When using function annotations, the natural syntax to specify that argument +When using function annotations, the natural syntax to specify that an argument has multiple possible types is using Union_: .. sourcecode:: python @@ -1370,16 +1370,15 @@ has multiple possible types is using Union_: def example(length: Union[int, float], padding: Union[int, str, None] = None): - # ... + ... -When using Python 3.10 or newer, it is possible to use the native `type1 | type2` +When using Python 3.10 or newer, it is possible to use the native `type1 | type2`__ syntax instead: .. sourcecode:: python def example(length: int | float, padding: int | str | None = None): - # ... - + ... An alternative is specifying types as a tuple. It is not recommended with annotations, because that syntax is not supported by other tools, but it works well with @@ -1392,7 +1391,7 @@ the `@keyword` decorator: @keyword(types={'length': (int, float), 'padding': (int, str, None)}) def example(length, padding=None): - # ... + ... With the above examples the `length` argument would first be converted to an integer and if that fails then to a float. The `padding` would be first @@ -1433,21 +1432,22 @@ attempted in the order types are specified. If any conversion succeeds, the resulting value is used without attempting remaining conversions. If no individual conversion succeeds, the whole conversion fails. -If a specified type is not recognized by Robot Framework, then the original value -is used as-is. For example, with this keyword conversion would first be attempted -to an integer but if that fails the keyword would get the original given argument: +If a specified type is not recognized by Robot Framework, then the original argument +value is used as-is. For example, with this keyword conversion would first be attempted +to an integer, but if that fails the keyword would get the original argument: .. sourcecode:: python - def example(argument: Union[int, MyCustomType]): - # ... + def example(argument: Union[int, Unrecognized]): + ... -.. note:: In Robot Framework 4.0 argument conversion was done always, regardless - of the type of the given argument. It caused various__ problems__ and - was changed in Robot Framework 4.0.1. +Starting from Robot Framework 6.1, the above logic works also if an unrecognized +type is listed before a recognized type like `Union[Unrecognized, int]`. +Also in this case `int` conversion is attempted, and the argument id passed as-is +if it fails. With earlier Robot Framework versions, `int` conversion would not be +attempted at all. -__ https://github.com/robotframework/robotframework/issues/3897 -__ https://github.com/robotframework/robotframework/issues/3908 +__ https://peps.python.org/pep-0604/ .. _Union: https://docs.python.org/3/library/typing.html#typing.Union Type conversion with generics diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 544fae22ae7..ff7be2ad032 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -65,7 +65,7 @@ def _convert(self, name, value): return converter.convert(name, value) except ValueError as err: conversion_error = err - if self._convert_based_on_defaults(name, spec, bool(conversion_error)): + if name in spec.defaults: converter = TypeConverter.converter_for(type(spec.defaults[name]), languages=self._languages) if converter: @@ -77,15 +77,3 @@ def _convert(self, name, value): if conversion_error: raise conversion_error return value - - def _convert_based_on_defaults(self, name, spec, has_known_type): - if name not in spec.defaults: - return False - # Handle `arg: T = None` consistently with different Python versions - # regardless is `T` a known type or not. Prior to 3.11 this syntax was - # considered same as `arg: Union[T, None] = None` and with unions we - # don't look at the possible default value if `T` is not known. - # https://github.com/robotframework/robotframework/issues/4626 - return (name not in spec.types - or spec.defaults[name] is not None - or has_known_type) diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 24092f6071b..8cc98512a9a 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -682,21 +682,32 @@ def _handles_value(self, value): return True def no_conversion_needed(self, value): - for converter in self.converters: - if converter and converter.no_conversion_needed(value): - return True + for converter, type_ in zip(self.converters, self.used_type): + if converter: + if converter.no_conversion_needed(value): + return True + else: + try: + if isinstance(value, type_): + return True + except TypeError: + pass return False def _convert(self, value, explicit_type=True): if not self.used_type: raise ValueError('Cannot have union without types.') + unrecognized_types = False for converter in self.converters: - if not converter: - return value - try: - return converter.convert('', value, explicit_type) - except ValueError: - pass + if converter: + try: + return converter.convert('', value, explicit_type) + except ValueError: + pass + else: + unrecognized_types = True + if unrecognized_types: + return value raise ValueError From 929055be62cf03d9ac44bd4ee02466a6cf7fefc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 8 Feb 2023 00:50:18 +0200 Subject: [PATCH 0366/1592] Enhance/fix dynamic API base class. Most importantly, `run_keywords` is renamed to `run_keyword`, but also typing is enhanced/fixed. Base class was tested with a simple library with Robot (execution), Libdoc and Mypy. Adding automated tests would require some work and that wasn't considered worth the effort. --- src/robot/api/interfaces.py | 38 ++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index e884b465151..c425d4c0e21 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -39,32 +39,40 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-3 """ +import sys from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional, Tuple, Union -# Type aliases. +# Need to use version check and not try/except to support Mypy's stubgen. +if sys.version_info >= (3, 10): + from types import UnionType + Type = (type # Actual type. + | str # Type name or alias. + | UnionType # Union syntax (e.g. `int | float`). + | tuple[ # Tuple of types. Behaves like union. + type | str, ... + ]) +else: + # Same as above but without UnionType. + Type = Union[type, str, Tuple[Union[type, str], ...]] + Name = str +PositArgs = List[Any] +NamedArgs = Dict[str, Any] Documentation = str -ArgumentSpec = List[ +Arguments = List[ Union[ str, # Name with possible default like `arg` or `arg=1`. Tuple[str], # Name without a default like `('arg',)`. Tuple[str, Any] # Name and default like `('arg', 1)`. ] ] -TypeSpec = Union[ - Dict[ # Types by name. - str, # Name. - Union[ - type, # Actual type. - str # Type name or alias. - ] - ], +Types = Union[ + Dict[str, Type], # Types by name. List[ # Types by position. Union[ - type, # Actual type. - str, # Type name or alias. + Type, # Type info. None # No type info. ] ] @@ -95,7 +103,7 @@ def get_keyword_names(self) -> List[Name]: raise NotImplementedError @abstractmethod - def run_keywords(self, name: Name, args: List[Any], named: Dict[str, Any]) -> Any: + def run_keyword(self, name: Name, args: PositArgs, named: NamedArgs) -> Any: """Execute the specified keyword using the given arguments. :param name: Keyword name as a string. @@ -127,7 +135,7 @@ def get_keyword_documentation(self, name: Name) -> Optional[Documentation]: """ return None - def get_keyword_arguments(self, name: Name) -> Optional[ArgumentSpec]: + def get_keyword_arguments(self, name: Name) -> Optional[Arguments]: """Optional method to return keyword's argument specification. Returned information is used during execution for argument validation. @@ -170,7 +178,7 @@ def get_keyword_arguments(self, name: Name) -> Optional[ArgumentSpec]: """ return None - def get_keyword_types(self, name: Name) -> Optional[TypeSpec]: + def get_keyword_types(self, name: Name) -> Optional[Types]: """Optional method to return keyword's type specification. Type information is used for automatic argument conversion during From 232c116bc64e3888e6e3a9adf06a71e41764dc57 Mon Sep 17 00:00:00 2001 From: Vincema <maire.vincent31@gmail.com> Date: Wed, 8 Feb 2023 06:24:16 +0700 Subject: [PATCH 0367/1592] Support long command line options with hyphens like --pre-run-modifier (#4608) Fixes #4547. --- .../robot/cli/console/colors_and_width.robot | 2 +- atest/robot/cli/runner/argumentfile.robot | 2 +- .../runner/rerunfailedsuites_corners.robot | 2 +- atest/robot/cli/runner/run_empty_suite.robot | 2 +- .../src/ExecutingTestCases/BasicUsage.rst | 6 ++--- src/robot/utils/argumentparser.py | 14 +++++++----- utest/utils/test_argumentparser.py | 22 ++++++++++++++++++- 7 files changed, 36 insertions(+), 14 deletions(-) diff --git a/atest/robot/cli/console/colors_and_width.robot b/atest/robot/cli/console/colors_and_width.robot index ea1b139d7ba..9a6680afe80 100644 --- a/atest/robot/cli/console/colors_and_width.robot +++ b/atest/robot/cli/console/colors_and_width.robot @@ -17,7 +17,7 @@ Console Colors On Outputs should have ANSI colors when not on Windows Console Colors ANSI - Run Tests With Colors --ConsoleColors AnSi + Run Tests With Colors --Console-Colors AnSi Outputs should have ANSI colors Invalid Console Colors diff --git a/atest/robot/cli/runner/argumentfile.robot b/atest/robot/cli/runner/argumentfile.robot index 122474cfc5d..d71c7519296 100644 --- a/atest/robot/cli/runner/argumentfile.robot +++ b/atest/robot/cli/runner/argumentfile.robot @@ -33,7 +33,7 @@ Argument File Two Argument Files Create Argument File ${ARGFILE} --metadata A1:Value1 --metadata A2:to be overridden Create Argument File ${ARGFILE2} --metadata A2:Value2 - ${result} = Run Tests -A ${ARGFILE} --ArgumentFile ${ARGFILE2} ${TESTFILE} + ${result} = Run Tests -A ${ARGFILE} --Argument-File ${ARGFILE2} ${TESTFILE} Execution Should Have Succeeded ${result} Should Be Equal ${SUITE.metadata['A1']} Value1 Should Be Equal ${SUITE.metadata['A2']} Value2 diff --git a/atest/robot/cli/runner/rerunfailedsuites_corners.robot b/atest/robot/cli/runner/rerunfailedsuites_corners.robot index 45008eba073..930827c4305 100644 --- a/atest/robot/cli/runner/rerunfailedsuites_corners.robot +++ b/atest/robot/cli/runner/rerunfailedsuites_corners.robot @@ -7,7 +7,7 @@ ${RUN FAILED FROM} %{TEMPDIR}${/}run-failed-output.xml *** Test Cases *** Runs everything when output is set to NONE - Run Tests --ReRunFailedSuites NoNe cli/runfailed/onlypassing + Run Tests --Re-Run-Failed-Suites NoNe cli/runfailed/onlypassing File Should Exist ${OUTFILE} Check Test Case Passing diff --git a/atest/robot/cli/runner/run_empty_suite.robot b/atest/robot/cli/runner/run_empty_suite.robot index 90d326fb0a6..a7be72c65df 100644 --- a/atest/robot/cli/runner/run_empty_suite.robot +++ b/atest/robot/cli/runner/run_empty_suite.robot @@ -17,7 +17,7 @@ No tests in directory [Teardown] Remove directory ${NO TESTS DIR} Empty suite after filtering by tags - Run empty suite --RunEmptySuite --include nonex ${TEST FILE} + Run empty suite --Run-Empty-Suite --include nonex ${TEST FILE} Empty suite after filtering by names Run empty suite --RunEmptySuite --test nonex ${TEST FILE} diff --git a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst index 52129db468c..24b3922d56f 100644 --- a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst +++ b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst @@ -125,9 +125,9 @@ and shortened options are practical when executing test cases manually, but long options are recommended in `start-up scripts`_, because they are easier to understand. -The long option format is case-insensitive, which facilitates writing option -names in an easy-to-read format. For example, :option:`--SuiteStatLevel` -is equivalent to, but easier to read than :option:`--suitestatlevel`. +The long option format is case-insensitive and hyphen-insensitive, which facilitates writing option +names in an easy-to-read format. For example, :option:`--SuiteStatLevel` and :option:`--suite-stat-level` +are equivalent to, but easier to read than :option:`--suitestatlevel`. Setting option values ~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/utils/argumentparser.py b/src/robot/utils/argumentparser.py index f309229d711..b39505437cf 100644 --- a/src/robot/utils/argumentparser.py +++ b/src/robot/utils/argumentparser.py @@ -165,20 +165,20 @@ def _handle_special_options(self, opts, args): return opts, args def _parse_args(self, args): - args = [self._lowercase_long_option(a) for a in args] + args = [self._normalize_long_option(a) for a in args] try: opts, args = getopt.getopt(args, self._short_opts, self._long_opts) except getopt.GetoptError as err: raise DataError(err.msg) return self._process_opts(opts), self._glob_args(args) - def _lowercase_long_option(self, opt): + def _normalize_long_option(self, opt): if not opt.startswith('--'): return opt if '=' not in opt: - return opt.lower() + return '--%s' % opt.lower().replace('-', '') opt, value = opt.split('=', 1) - return '%s=%s' % (opt.lower(), value) + return '--%s=%s' % (opt.lower().replace('-', ''), value) def _process_possible_argfile(self, args): options = ['--argumentfile'] @@ -232,7 +232,7 @@ def _create_options(self, usage): res = self._opt_line_re.match(line) if res: self._create_option(short_opts=[o[1] for o in res.group(1).split()], - long_opt=res.group(3).lower(), + long_opt=res.group(3).lower().replace('-', ''), takes_arg=bool(res.group(4)), is_multi=bool(res.group(5))) @@ -356,7 +356,9 @@ def _get_index(self, args): for opt in self._options: start = opt + '=' if opt.startswith('--') else opt for index, arg in enumerate(args): - normalized_arg = arg.lower() if opt.startswith('--') else arg + normalized_arg = ( + '--' + arg.lower().replace('-', '') if opt.startswith('--') else arg + ) # Handles `--argumentfile foo` and `-A foo` if normalized_arg == opt and index + 1 < len(args): return args[index+1], slice(index, index+2) diff --git a/utest/utils/test_argumentparser.py b/utest/utils/test_argumentparser.py index de3d99c647f..3e1e1033fdc 100644 --- a/utest/utils/test_argumentparser.py +++ b/utest/utils/test_argumentparser.py @@ -102,6 +102,11 @@ def test_case_insensitive_long_options(self): self.assert_short_opts('fB', ap) self.assert_long_opts(['foo', 'bar'], ap) + def test_long_options_with_hyphens(self): + ap = ArgumentParser(' -f --f-o-o\n -B --bar--\n') + self.assert_short_opts('fB', ap) + self.assert_long_opts(['foo', 'bar'], ap) + def test_same_option_multiple_times(self): for usage in [' --foo\n --foo\n', ' --foo\n -f --Foo\n', @@ -196,6 +201,21 @@ def test_case_insensitive_long_options_with_equal_sign(self): assert_equal(opts['variable'], ['X:y', 'ZzZ']) assert_equal(args, []) + def test_long_options_with_hyphens(self): + opts, args = self.ap.parse_args('--var-i-a--ble x-y ----toggle---- arg'.split()) + assert_equal(opts['variable'], ['x-y']) + assert_equal(opts['toggle'], True) + assert_equal(args, ['arg']) + + def test_long_options_with_hyphens_with_equal_sign(self): + opts, args = self.ap.parse_args('--var-i-a--ble=x-y ----variable----=--z--'.split()) + assert_equal(opts['variable'], ['x-y', '--z--']) + assert_equal(args, []) + + def test_long_options_with_hyphens_only(self): + args = '-----=value1'.split() + assert_raises(DataError, self.ap.parse_args, args) + def test_split_pythonpath(self): ap = ArgumentParser('ignored') data = [(['path'], ['path']), @@ -236,7 +256,7 @@ def test_special_options_are_removed(self): ap = ArgumentParser('''Usage: -h --help -v --version - --argumentfile path + --Argument-File path --option ''') opts, args = ap.parse_args(['--option']) From d3ef8f43f12d9ca0dba93e432861c6aa6cd27386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 8 Feb 2023 01:30:05 +0200 Subject: [PATCH 0368/1592] Minor doc enhancements to supporting long opts with hyphens. - Mention this functionality in --help texts. - Mention in the UG that this is new in RF 6.1. Part of #4547. --- doc/userguide/src/ExecutingTestCases/BasicUsage.rst | 9 ++++++--- src/robot/rebot.py | 8 +++----- src/robot/run.py | 8 +++----- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst index 24b3922d56f..2fbd4a962bd 100644 --- a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst +++ b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst @@ -125,9 +125,12 @@ and shortened options are practical when executing test cases manually, but long options are recommended in `start-up scripts`_, because they are easier to understand. -The long option format is case-insensitive and hyphen-insensitive, which facilitates writing option -names in an easy-to-read format. For example, :option:`--SuiteStatLevel` and :option:`--suite-stat-level` -are equivalent to, but easier to read than :option:`--suitestatlevel`. +The long option names are case-insensitive and hyphen-insensitive, +which facilitates writing option names in an easy-to-read format. +For example, :option:`--SuiteStatLevel` and :option:`--suite-stat-level` +are equivalent to, but easier to read than, :option:`--suitestatlevel`. + +.. note:: Long options being hyphen-insensitive is new in Robot Framework 6.1. Setting option values ~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/rebot.py b/src/robot/rebot.py index ddf3508d142..6e9139a9bc7 100755 --- a/src/robot/rebot.py +++ b/src/robot/rebot.py @@ -287,11 +287,9 @@ `--merge --merge --nomerge --nostatusrc --statusrc` would not activate the merge mode and would return a normal return code. -Long option format is case-insensitive. For example, --SuiteStatLevel is -equivalent to but easier to read than --suitestatlevel. Long options can -also be shortened as long as they are unique. For example, `--logti Title` -works while `--lo log.html` does not because the former matches only --logtitle -but the latter matches both --log and --logtitle. +Long option names are case and hyphen insensitive. For example, --TagStatLink +and --tag-stat-link are equivalent to, but easier to read than, --tagstatlink. +Long options can also be shortened as long as they are unique. Environment Variables ===================== diff --git a/src/robot/run.py b/src/robot/run.py index f6e6df6328b..b3372bf1cd3 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -370,11 +370,9 @@ `--dryrun --dryrun --nodryrun --nostatusrc --statusrc` would not activate the dry-run mode and would return a normal return code. -Long option format is case-insensitive. For example, --SuiteStatLevel is -equivalent to but easier to read than --suitestatlevel. Long options can -also be shortened as long as they are unique. For example, `--logti Title` -works while `--lo log.html` does not because the former matches only --logtitle -but the latter matches --log, --loglevel and --logtitle. +Long option names are case and hyphen insensitive. For example, --TagStatLink +and --tag-stat-link are equivalent to, but easier to read than, --tagstatlink. +Long options can also be shortened as long as they are unique. Environment Variables ===================== From 2a5e0db41cbf6f7623a224149cf8eb1228d92bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Thu, 9 Feb 2023 18:54:11 +0200 Subject: [PATCH 0369/1592] ug: add docs for custom converters with library args Fixes #4510 --- .../CreatingTestLibraries.rst | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 0f50af190fb..5bde9950a2b 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1538,7 +1538,7 @@ a custom converter and registering it to handle date_ conversion: ROBOT_LIBRARY_CONVERTERS = {date: parse_fi_date} - # Keyword using custom converter. Converter is got based on argument type. + # Keyword using custom converter. Converter is resolved based on argument type. def keyword(arg: date): print(f'year: {arg.year}, month: {arg.month}, day: {arg.day}') @@ -1768,6 +1768,47 @@ the code above: .. note:: Using `None` as a strict converter is new in Robot Framework 6.0. An explicit converter function needs to be used with earlier versions. +Accessing the test library from converter +````````````````````````````````````````` +Starting from Robot Framework 6.1, it is possible to access the test library +instance from a converter function. This allows defining dynamic type conversions +that depend on the library state. For example, if the library can be configured to +test particular locale, you might use the library state to determine how a date +should be parsed like this: + +.. sourcecode:: python + + from datetime import date + import re + + + def parse_date(value, library): + # Validate input using regular expression and raise ValueError if not valid. + # Use locale based from library state to determine parsing format. + match = None + format = '' + if library.locale == 'en_US': + match = re.match(r'(\d{1,2})/(\d{1,2})/(\d{4})$', value) + format = 'mm/dd/yyyy' + else: + match = re.match(r'(\d{1,2})\.(\d{1,2})\.(\d{4})$', value) + format = 'dd.mm.yyyy' + if not match: + raise ValueError(f"Expected date in format '{format}', got '{value}'.") + day, month, year = match.groups() + return date(int(year), int(month), int(day)) + + + ROBOT_LIBRARY_CONVERTERS = {date: parse_date} + + + def keyword(arg: date): + print(f'year: {arg.year}, month: {arg.month}, day: {arg.day}') + + +The `library` argument to converter function is optional, i.e. if the converter function +only accepts one argument, the `library` argument is omitted. + Converter documentation ``````````````````````` From d161a15774dd4149fe7d01bb567e2687a4cc681e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Fri, 10 Feb 2023 16:09:40 +0200 Subject: [PATCH 0370/1592] ug: add docs for embedded and regular args Fixes #4234 --- .../CreatingTestData/CreatingUserKeywords.rst | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index a6747c81104..2aa6163057e 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -535,13 +535,29 @@ the keyword is called. In the above example, `${animal}` has value `cat` when the keyword is used for the first time and `dog` when it is used for the second time. -Keywords using embedded arguments cannot take any "normal" arguments -(specified with :setting:`[Arguments]` setting), but otherwise they are -created just like other user keywords. They are also used the same way as -other keywords except that spaces and underscores are not ignored in their +Starting from Robot Framework 6.1, it is possible to create user keywords that have +both embedded and "normal" (specified with :setting:`[Arguments]` setting) arguments. +Earlier, having "normal" arguments was not possible. Otherwise, keywords with embedded +arguments are created just like other user keywords. They are also used the same +way as other keywords except that spaces and underscores are not ignored in their names when keywords are matched. They are, however, case-insensitive like other keywords. For example, the keyword in the example above could be used like :name:`select cow from list`, but not like :name:`Select cow fromlist`. +Example below demonstrates using embedded and regular arguments in a single keyword: + +.. sourcecode:: robotframework + + *** Test Cases *** + Embedded and normal arguments + Number of cats should be 5 + Number of elephants should be 1 + + *** Keywords *** + Number of ${animals} should be + [Arguments] ${expected_count} + Open Page Pet Selection + Select Items From List animal_list ${animals} + Number of Selected List Items Should Be ${expected_count} Embedded arguments do not support default values or variable number of arguments like normal arguments do. If such functionality is needed, normal From 138baafadd423af32dd84d364c02cf21b3130369 Mon Sep 17 00:00:00 2001 From: Fabio Zadrozny <fabiofz@gmail.com> Date: Fri, 10 Feb 2023 16:19:19 -0300 Subject: [PATCH 0371/1592] Improve failure message on test failure. (#4610) --- utest/running/test_imports.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/utest/running/test_imports.py b/utest/running/test_imports.py index 7defc927f2f..3d27d181f8d 100644 --- a/utest/running/test_imports.py +++ b/utest/running/test_imports.py @@ -27,6 +27,18 @@ def assert_test(test, name, status, tags=(), msg=''): class TestImports(unittest.TestCase): + def run_and_check_pass(self, suite): + result = run(suite) + try: + assert_suite(result, 'Suite', 'PASS') + assert_test(result.tests[0], 'Test', 'PASS') + except AssertionError as e: + # Something failed. Let's print more info. + full_msg = ["Expected and obtained don't match. Test messages:"] + for test in result.tests: + full_msg.append('%s: %s' % (test, test.message)) + raise AssertionError('\n'.join(full_msg)) from e + def test_create(self): suite = TestSuite(name='Suite') suite.resource.imports.create('Library', 'OperatingSystem') @@ -36,27 +48,22 @@ def test_create(self): test.body.create_keyword('Directory Should Exist', args=['.']) test.body.create_keyword('My Test Keyword') test.body.create_keyword('Convert To Lower Case', args=['ROBOT']) - result = run(suite) - assert_suite(result, 'Suite', 'PASS') - assert_test(result.tests[0], 'Test', 'PASS') + self.run_and_check_pass(suite) + def test_library(self): suite = TestSuite(name='Suite') suite.resource.imports.library('OperatingSystem') suite.tests.create(name='Test').body.create_keyword('Directory Should Exist', args=['.']) - result = run(suite) - assert_suite(result, 'Suite', 'PASS') - assert_test(result.tests[0], 'Test', 'PASS') + self.run_and_check_pass(suite) def test_resource(self): suite = TestSuite(name='Suite') suite.resource.imports.resource('test_resource.txt') suite.tests.create(name='Test').body.create_keyword('My Test Keyword') assert_equal(suite.tests[0].body[0].name, 'My Test Keyword') - result = run(suite) - assert_suite(result, 'Suite', 'PASS') - assert_test(result.tests[0], 'Test', 'PASS') + self.run_and_check_pass(suite) def test_variables(self): suite = TestSuite(name='Suite') @@ -65,9 +72,7 @@ def test_variables(self): 'Should Be Equal As Strings', args=['${MY_VARIABLE}', 'An example string'] ) - result = run(suite) - assert_suite(result, 'Suite', 'PASS') - assert_test(result.tests[0], 'Test', 'PASS') + self.run_and_check_pass(suite) def test_invalid_type(self): assert_raises_with_msg(ValueError, From 013a2d2b0a25e9b57f5c0638415a29fe12107b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 9 Feb 2023 19:09:28 +0200 Subject: [PATCH 0372/1592] f-strings, grammar --- src/robot/variables/replacer.py | 63 ++++++++++++++++----------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/src/robot/variables/replacer.py b/src/robot/variables/replacer.py index bd5e84ee365..ea316823954 100644 --- a/src/robot/variables/replacer.py +++ b/src/robot/variables/replacer.py @@ -36,7 +36,7 @@ def replace_list(self, items, replace_until=None, ignore_errors=False): 'replace_until' can be used to limit replacing arguments to certain index from the beginning. Used with Run Keyword variants that only - want to resolve some of the arguments in the beginning and pass others + want to resolve some arguments in the beginning and pass others to called keywords unmodified. """ items = list(items or []) @@ -46,8 +46,8 @@ def replace_list(self, items, replace_until=None, ignore_errors=False): def _replace_list_until(self, items, replace_until, ignore_errors): # @{list} variables can contain more or less arguments than needed. - # Therefore we need to go through items one by one, and escape possible - # extra items we got. + # Therefore, we need to go through items one by one, and escape + # possible extra items we got. replaced = [] while len(replaced) < replace_until and items: replaced.extend(self._replace_list([items.pop(0)], ignore_errors)) @@ -74,7 +74,7 @@ def replace_scalar(self, item, ignore_errors=False): """Replaces variables from a scalar item. If the item is not a string it is returned as is. If it is a variable, - its value is returned. Otherwise possible variables are replaced with + its value is returned. Otherwise, possible variables are replaced with 'replace_string'. Result may be any object. """ match = self._search_variable(item, ignore_errors=ignore_errors) @@ -118,8 +118,8 @@ def _get_variable_value(self, match, ignore_errors): match.resolve_base(self, ignore_errors) # TODO: Do we anymore need to reserve `*{var}` syntax for anything? if match.identifier == '*': - logger.warn(r"Syntax '%s' is reserved for future use. Please " - r"escape it like '\%s'." % (match, match)) + logger.warn(rf"Syntax '{match}' is reserved for future use. Please " + rf"escape it like '\{match}'.") return str(match) try: value = self._finder.find(match) @@ -129,9 +129,9 @@ def _get_variable_value(self, match, ignore_errors): value = self._validate_value(match, value) except VariableError: raise - except: - raise VariableError("Resolving variable '%s' failed: %s" - % (match, get_error_message())) + except Exception: + error = get_error_message() + raise VariableError(f"Resolving variable '{match}' failed: {error}") except DataError: if not ignore_errors: raise @@ -147,12 +147,12 @@ def _get_variable_item(self, match, value): value = self._get_sequence_variable_item(name, value, item) else: raise VariableError( - "Variable '%s' is %s, which is not subscriptable, and " - "thus accessing item '%s' from it is not possible. To use " - "'[%s]' as a literal value, it needs to be escaped like " - "'\\[%s]'." % (name, type_name(value), item, item, item) + f"Variable '{name}' is {type_name(value)}, which is not " + f"subscriptable, and thus accessing item '{item}' from it " + f"is not possible. To use '[{item}]' as a literal value, " + f"it needs to be escaped like '\\[{item}]'." ) - name = '%s[%s]' % (name, item) + name = f'{name}[{item}]' return value def _get_sequence_variable_item(self, name, variable, index): @@ -163,19 +163,20 @@ def _get_sequence_variable_item(self, name, variable, index): try: return variable[index] except TypeError: - raise VariableError("%s '%s' used with invalid index '%s'. " - "To use '[%s]' as a literal value, it needs " - "to be escaped like '\\[%s]'." - % (type_name(variable, capitalize=True), name, - index, index, index)) - except: - raise VariableError("Accessing '%s[%s]' failed: %s" - % (name, index, get_error_message())) + var_type = type_name(variable, capitalize=True) + raise VariableError( + f"{var_type} '{name}' used with invalid index '{index}'. " + f"To use '[{index}]' as a literal value, it needs to be " + f"escaped like '\\[{index}]'." + ) + except Exception: + error = get_error_message() + raise VariableError(f"Accessing '{name}[{index}]' failed: {error}") try: return variable[index] except IndexError: - raise VariableError("%s '%s' has no item in index %d." - % (type_name(variable, capitalize=True), name, index)) + var_type = type_name(variable, capitalize=True) + raise VariableError(f"{var_type} '{name}' has no item in index {index}.") def _parse_sequence_variable_index(self, index): if isinstance(index, (int, slice)): @@ -193,21 +194,19 @@ def _get_dict_variable_item(self, name, variable, key): try: return variable[key] except KeyError: - raise VariableError("Dictionary '%s' has no key '%s'." - % (name, key)) + raise VariableError(f"Dictionary '{name}' has no key '{key}'.") except TypeError as err: - raise VariableError("Dictionary '%s' used with invalid key: %s" - % (name, err)) + raise VariableError(f"Dictionary '{name}' used with invalid key: {err}") def _validate_value(self, match, value): if match.identifier == '@': if not is_list_like(value): - raise VariableError("Value of variable '%s' is not list or " - "list-like." % match) + raise VariableError(f"Value of variable '{match}' is not list " + f"or list-like.") return list(value) if match.identifier == '&': if not is_dict_like(value): - raise VariableError("Value of variable '%s' is not dictionary " - "or dictionary-like." % match) + raise VariableError(f"Value of variable '{match}' is not dictionary " + f"or dictionary-like.") return DotDict(value) return value From 9aa3066f2a3745097e8b88286273882d184e018e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 10 Feb 2023 21:22:37 +0200 Subject: [PATCH 0373/1592] Add utest/resources to PYTHONPATH Without this some tests fail when running utest/run.py running Apparently this path is set to PYTHONPATH by some tests, because running all tests succeeded. Fixes #4611. --- utest/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utest/run.py b/utest/run.py index 3ff83ee1c28..a9e74825a01 100755 --- a/utest/run.py +++ b/utest/run.py @@ -32,7 +32,7 @@ base = os.path.abspath(os.path.normpath(os.path.split(sys.argv[0])[0])) -for path in ['../src', '../atest/testresources/testlibs']: +for path in ['../src', '../atest/testresources/testlibs', '../utest/resources']: path = os.path.join(base, path.replace('/', os.sep)) if path not in sys.path: sys.path.insert(0, path) From 9fd4ab959c3fa7a95434a523c7fd5c704a8c4859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Fri, 10 Feb 2023 22:02:07 +0200 Subject: [PATCH 0374/1592] ug: improve custom converter docs Relates to #4510 --- .../ExtendingRobotFramework/CreatingTestLibraries.rst | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 5bde9950a2b..ae4ec59ef17 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1770,7 +1770,7 @@ the code above: Accessing the test library from converter ````````````````````````````````````````` -Starting from Robot Framework 6.1, it is possible to access the test library +Starting from Robot Framework 6.1, it is possible to access the library instance from a converter function. This allows defining dynamic type conversions that depend on the library state. For example, if the library can be configured to test particular locale, you might use the library state to determine how a date @@ -1785,18 +1785,15 @@ should be parsed like this: def parse_date(value, library): # Validate input using regular expression and raise ValueError if not valid. # Use locale based from library state to determine parsing format. - match = None - format = '' if library.locale == 'en_US': - match = re.match(r'(\d{1,2})/(\d{1,2})/(\d{4})$', value) + match = re.match(r'(?P<month>\d{1,2})/(?P<day>\d{1,2})/(?P<year>\d{4})$', value) format = 'mm/dd/yyyy' else: - match = re.match(r'(\d{1,2})\.(\d{1,2})\.(\d{4})$', value) + match = re.match(r'(?P<day>\d{1,2})\.(?P<month>\d{1,2})\.(?P<year>\d{4})$', value) format = 'dd.mm.yyyy' if not match: raise ValueError(f"Expected date in format '{format}', got '{value}'.") - day, month, year = match.groups() - return date(int(year), int(month), int(day)) + return date(int(match.group('year')), int(match.group('month')), int(match.group('day'))) ROBOT_LIBRARY_CONVERTERS = {date: parse_date} From 0867239aa29417a26dc3c440eec6112f577ebff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Thu, 19 Jan 2023 14:35:44 +0200 Subject: [PATCH 0375/1592] Implement robot:flatten keyword tag See #4584 --- atest/robot/running/flatten.robot | 35 ++++++++ atest/testdata/running/flatten.robot | 64 +++++++++++++++ .../listeners/flatten_listener.py | 18 +++++ src/robot/output/output.py | 24 +++++- src/robot/output/xmllogger.py | 79 +++++++++++++++++++ src/robot/running/librarykeywordrunner.py | 3 +- src/robot/running/userkeywordrunner.py | 6 +- src/robot/variables/assigner.py | 3 +- utest/running/test_testlibrary.py | 2 +- 9 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 atest/robot/running/flatten.robot create mode 100644 atest/testdata/running/flatten.robot create mode 100644 atest/testresources/listeners/flatten_listener.py diff --git a/atest/robot/running/flatten.robot b/atest/robot/running/flatten.robot new file mode 100644 index 00000000000..b87fd710147 --- /dev/null +++ b/atest/robot/running/flatten.robot @@ -0,0 +1,35 @@ +*** Settings *** +Suite Setup Run Tests --loglevel trace --listener flatten_listener.Listener running/flatten.robot +Resource atest_resource.robot + +*** Test Cases *** +A single user keyword + ${tc}= User keyword content should be flattened 1 + Check Log Message ${tc.body[0].messages[0]} From the main kw + +Nested UK + ${tc}= User keyword content should be flattened 2 + Check Log Message ${tc.body[0].messages[0]} arg + Check Log Message ${tc.body[0].messages[1]} from nested kw + +Loops and stuff + ${tc}= User keyword content should be flattened 19 + Check Log Message ${tc.body[0].messages[0]} inside for 0 + Check Log Message ${tc.body[0].messages[5]} inside while 0 + Check Log Message ${tc.body[0].messages[15]} inside if + Check Log Message ${tc.body[0].messages[18]} inside except + +Recursion + User keyword content should be flattened 8 + +Listener methods start and end keyword are called + Stderr Should Be Empty + +*** Keywords *** +User keyword content should be flattened + [Arguments] ${expected_message_count}=0 + ${tc}= Check Test Case ${TESTNAME} + ${kw}= set variable ${tc.body[0]} + Length Should Be ${kw.body} ${expected_message_count} + Length Should Be ${kw.messages} ${expected_message_count} + RETURN ${tc} diff --git a/atest/testdata/running/flatten.robot b/atest/testdata/running/flatten.robot new file mode 100644 index 00000000000..5b6b78540fb --- /dev/null +++ b/atest/testdata/running/flatten.robot @@ -0,0 +1,64 @@ +*** Variables *** +${while limit} ${0} + +*** Test Cases *** +A single user keyword + UK + +Nested UK + Nested UK arg + +Loops and stuff + Loops and stuff + +Recursion + Recursion ${3} + +*** Keywords *** +UK + [Tags] robot:flatten + Log From the main kw + RETURN 42 + +Nested UK + [Arguments] ${arg} + [Tags] robot:flatten + Log ${arg} + Nest + +Nest + [Return] foo + Log from nested kw + +Loops and stuff + [Tags] robot:flatten + FOR ${i} IN RANGE 5 + Log inside for ${i} + IF ${i} > 3 + BREAK + ELSE + CONTINUE + END + END + WHILE ${while limit} < 5 + Log inside while ${while limit} + ${while limit}= Set Variable ${while limit + 1} + END + IF True + Log inside if + ELSE + Fail + END + TRY + Fail + EXCEPT + Log inside except + END + + Recursion + [Arguments] ${num} + [Tags] robot:flatten + Log Level: ${num} + IF ${num} < 10 + Recursion ${num+1} + END diff --git a/atest/testresources/listeners/flatten_listener.py b/atest/testresources/listeners/flatten_listener.py new file mode 100644 index 00000000000..b88fe38bd2d --- /dev/null +++ b/atest/testresources/listeners/flatten_listener.py @@ -0,0 +1,18 @@ +class Listener: + ROBOT_LISTENER_API_VERSION = '2' + + def __init__(self): + self.start_kw_count = 0 + self.end_kw_count = 0 + + def start_keyword(self, kw, attrs): + self.start_kw_count += 1 + + def end_keyword(self, kw, attrs): + self.end_kw_count += 1 + + def end_suite(self, *args): + if not self.start_kw_count: + raise AssertionError("No keywords started") + if not self.end_kw_count: + raise AssertionError("No keywords ended") diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 88a796e1e2b..45e7f26a74a 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -16,9 +16,9 @@ from . import pyloggingconf from .debugfile import DebugFile from .listeners import LibraryListeners, Listeners -from .logger import LOGGER +from .logger import LOGGER, LoggerProxy from .loggerhelper import AbstractLogger -from .xmllogger import XmlLogger +from .xmllogger import XmlLogger, FlatXmlLogger class Output(AbstractLogger): @@ -27,10 +27,18 @@ def __init__(self, settings): AbstractLogger.__init__(self) self._xmllogger = XmlLogger(settings.output, settings.log_level, settings.rpa) + self._flat_xml_logger = None self.listeners = Listeners(settings.listeners, settings.log_level) self.library_listeners = LibraryListeners(settings.log_level) self._register_loggers(DebugFile(settings.debug_file)) self._settings = settings + self._flatten_level = 0 + + @property + def flat_xml_logger(self): + if self._flat_xml_logger is None: + self._flat_xml_logger = FlatXmlLogger(self._xmllogger) + return self._flat_xml_logger def _register_loggers(self, debug_file): LOGGER.register_xml_logger(self._xmllogger) @@ -61,13 +69,25 @@ def end_test(self, test): def start_keyword(self, kw): LOGGER.start_keyword(kw) + if kw.tags.robot('flatten'): + self._flatten_level += 1 + if self._flatten_level == 1: + LOGGER._xml_logger = LoggerProxy(self.flat_xml_logger) def end_keyword(self, kw): + if kw.tags.robot('flatten'): + self._flatten_level -= 1 + if not self._flatten_level: + LOGGER._xml_logger = LoggerProxy(self._xmllogger) LOGGER.end_keyword(kw) def message(self, msg): LOGGER.log_message(msg) + def trace(self, msg, write_if_flat=True): + if write_if_flat or self._flatten_level == 0: + self.write(msg, 'TRACE') + def set_log_level(self, level): pyloggingconf.set_level(level) self.listeners.set_log_level(level) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index bc3120be2f9..46d712ea6d3 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -253,3 +253,82 @@ def _write_status(self, item): if not (item.starttime and item.endtime): attrs['elapsedtime'] = str(item.elapsedtime) self._writer.element('status', item.message, attrs) + + +class FlatXmlLogger(XmlLogger): + + def __init__(self, real_xml_logger): + super().__init__(None) + self._writer = real_xml_logger._writer + + def start_keyword(self, kw): + pass + + def end_keyword(self, kw): + pass + + def start_for(self, for_): + pass + + def end_for(self, for_): + pass + + def start_for_iteration(self, iteration): + pass + + def end_for_iteration(self, iteration): + pass + + def start_if(self, if_): + pass + + def end_if(self, if_): + pass + + def start_if_branch(self, branch): + pass + + def end_if_branch(self, branch): + pass + + def start_try(self, root): + pass + + def end_try(self, root): + pass + + def start_try_branch(self, branch): + pass + + def end_try_branch(self, branch): + pass + + def start_while(self, while_): + pass + + def end_while(self, while_): + pass + + def start_while_iteration(self, iteration): + pass + + def end_while_iteration(self, iteration): + pass + + def start_break(self, break_): + pass + + def end_break(self, break_): + pass + + def start_continue(self, continue_): + pass + + def end_continue(self, continue_): + pass + + def start_return(self, return_): + pass + + def end_return(self, return_): + pass diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index 2ef881b89ad..af134e411ee 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -73,7 +73,8 @@ def _run(self, context, args): variables = context.variables if not context.dry_run else None positional, named = self._handler.resolve_arguments(args, variables, self.languages) - context.output.trace(lambda: self._trace_log_args(positional, named)) + context.output.trace(lambda: self._trace_log_args(positional, named), + write_if_flat=False) runner = self._runner_for(context, self._handler.current_handler(), positional, dict(named)) return self._run_with_output_captured_and_signal_monitor(runner, context) diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 533ff311c61..260665899ae 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -116,7 +116,8 @@ def _set_arguments(self, arguments, context): args, kwargs = self.arguments.map(positional, named, replace_defaults=False) self._set_variables(args, kwargs, variables) - context.output.trace(lambda: self._trace_log_args_message(variables)) + context.output.trace(lambda: self._trace_log_args_message(variables), + write_if_flat=False) def _set_variables(self, positional, kwargs, variables): spec = self.arguments @@ -258,7 +259,8 @@ def _set_arguments(self, args, context): for name, value in self.embedded_args: variables['${%s}' % name] = value super()._set_arguments(args, context) - context.output.trace(lambda: self._trace_log_args_message(variables)) + context.output.trace(lambda: self._trace_log_args_message(variables), + write_if_flat=False) def _trace_log_args_message(self, variables): args = [f'${{{arg}}}' for arg in self._handler.embedded.args] diff --git a/src/robot/variables/assigner.py b/src/robot/variables/assigner.py index be18e55ceae..26e68b21cae 100644 --- a/src/robot/variables/assigner.py +++ b/src/robot/variables/assigner.py @@ -102,7 +102,8 @@ def __exit__(self, etype, error, tb): def assign(self, return_value): context = self._context - context.trace(lambda: 'Return: %s' % prepr(return_value)) + context.output.trace(lambda: 'Return: %s' % prepr(return_value), + write_if_flat=False) resolver = ReturnValueResolver(self._assignment) for name, value in resolver.resolve(return_value): if not self._extended_assign(name, value, context.variables): diff --git a/utest/running/test_testlibrary.py b/utest/running/test_testlibrary.py index 2640f19a800..8600dae1eef 100644 --- a/utest/running/test_testlibrary.py +++ b/utest/running/test_testlibrary.py @@ -526,7 +526,7 @@ def __getitem__(self, key): class _FakeOutput: - def trace(self, str): + def trace(self, str, write_if_flat=True): pass def log_output(self, output): pass From cc5f31a53bf8bb688930964341bff348413f0a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Thu, 19 Jan 2023 16:16:45 +0200 Subject: [PATCH 0376/1592] doc: document robot:flatten keyword tag Fixes #4584 --- .../CreatingTestData/CreatingTestCases.rst | 3 ++ .../CreatingTestData/CreatingUserKeywords.rst | 4 ++- .../src/ExecutingTestCases/OutputFiles.rst | 31 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index d28d3344fea..f9baf5567f2 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -730,6 +730,9 @@ to be added in the future. `robot:exit` Added to tests automatically when `execution is stopped gracefully`__. +`robot:flatten` + Enable `flattening keyword during execution time`_. + __ `Enabling continue-on-failure using tags`_ __ `Disabling continue-on-failure using tags`_ __ `Automatically skipping failed tests`_ diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 2aa6163057e..bdd099cc111 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -189,7 +189,9 @@ tag, and new usages for keywords tags are possibly added in later releases. Similarly as with `test case tags`_, user keyword tags with the `robot:` prefix are reserved__ for special features by Robot Framework itself. Users should thus not use any tag with these prefixes unless actually -activating the special functionality. +activating the special functionality. Starting from Robot Framework 6.1, +`flattening keyword during execution time`_ can be taken into use using +reserved tag `robot:flatten`. .. note:: :setting:`Keyword Tags` is new in Robot Framework 6.0. With earlier versions all keyword tags need to be specified using the diff --git a/doc/userguide/src/ExecutingTestCases/OutputFiles.rst b/doc/userguide/src/ExecutingTestCases/OutputFiles.rst index e29b1aa0194..c2b4208037c 100644 --- a/doc/userguide/src/ExecutingTestCases/OutputFiles.rst +++ b/doc/userguide/src/ExecutingTestCases/OutputFiles.rst @@ -559,6 +559,37 @@ Flattening keywords is done already when the `output file`_ is parsed initially. This can save a significant amount of memory especially with deeply nested keyword structures. +Flattening keyword during execution time +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Starting from Robot Framework 6.1, it is possible to enable the keyword flattening during +the execution time. This can be done only on an user keyword level by defining the `reserved tag`__ +`robot:flatten` as a `keyword tag`__. Using this tag will work similarly as the command line +option described in the previous chapter, e.g. all content except for log messages is removed +from under the keyword having the tag. One important difference is that in this case, the removed +content is not written to the output file at all, and thus cannot be accessed at later time. + +Some examples + +.. sourcecode:: robotframework + + *** Keywords *** + Flattening affects this keyword and all it's children + [Tags] robot:flatten + Log something + FOR ${i} IN RANGE 2 + Log The message is preserved but for loop iteration is not + END + + *** Settings *** + # Flatten content of all uer keywords + Keyword Tags robot:flatten + + +__ `Reserved tags`_ +__ `Keyword tags`_ + + Automatically expanding keywords -------------------------------- From 660333d0fd84260edd8529699aa2e73059687cb0 Mon Sep 17 00:00:00 2001 From: Likai R <lebsfi@gmail.com> Date: Tue, 14 Feb 2023 02:31:06 +0200 Subject: [PATCH 0377/1592] Fix Variables.rst (#4631) Add a missing period. --- doc/userguide/src/CreatingTestData/Variables.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/userguide/src/CreatingTestData/Variables.rst b/doc/userguide/src/CreatingTestData/Variables.rst index 5ac7d53be76..c107319c3af 100644 --- a/doc/userguide/src/CreatingTestData/Variables.rst +++ b/doc/userguide/src/CreatingTestData/Variables.rst @@ -1212,7 +1212,7 @@ Extended variable syntax Extended variable syntax allows accessing attributes of an object assigned to a variable (for example, `${object.attribute}`) and even calling its methods (for example, `${obj.getName()}`). It works both with -scalar and list variables, but is mainly useful with the former +scalar and list variables, but is mainly useful with the former. Extended variable syntax is a powerful feature, but it should be used with care. Accessing attributes is normally not a problem, on From ae2429e840e9434300de37476da5afc319e1ffa7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Feb 2023 02:53:05 +0200 Subject: [PATCH 0378/1592] Bump actions/setup-python from 4.4.0 to 4.5.0 (#4594) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.4.0 to 4.5.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.4.0...v4.5.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index b42453129f2..8705812ce45 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.4.0 + uses: actions/setup-python@v4.5.0 with: python-version: '3.10' architecture: 'x64' @@ -49,7 +49,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.4.0 + uses: actions/setup-python@v4.5.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index e1ec685eeeb..6bb89665b2a 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.4.0 + uses: actions/setup-python@v4.5.0 with: python-version: '3.11' 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@v4.4.0 + uses: actions/setup-python@v4.5.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 69e8d8b2d15..2c69420f914 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.4.0 + uses: actions/setup-python@v4.5.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 653bb69bcae..9d3c872442d 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.4.0 + uses: actions/setup-python@v4.5.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From ca524955fe22a2e6de91e5e8e08ce8af82a37f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 13 Feb 2023 15:12:58 +0200 Subject: [PATCH 0379/1592] Refactor parsing code. Includes adding type info. --- src/robot/parsing/lexer/blocklexers.py | 81 ++++++----- src/robot/parsing/lexer/context.py | 20 +-- src/robot/parsing/lexer/lexer.py | 4 +- src/robot/parsing/lexer/settings.py | 2 +- src/robot/parsing/lexer/statementlexers.py | 50 ++++--- src/robot/parsing/model/blocks.py | 135 ++++++++---------- src/robot/parsing/model/statements.py | 46 +++--- .../test_statements_in_invalid_position.py | 1 + 8 files changed, 165 insertions(+), 174 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index 2b448477c9a..d7d7ba16bf8 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -15,6 +15,7 @@ from robot.utils import normalize_whitespace +from .context import FileContext, LexingContext, SuiteFileContext, TestOrKeywordContext from .tokens import Token from .statementlexers import (Lexer, SettingSectionHeaderLexer, SettingLexer, @@ -35,15 +36,14 @@ class BlockLexer(Lexer): - def __init__(self, ctx): - """:type ctx: :class:`robot.parsing.lexer.context.FileContext`""" + def __init__(self, ctx: LexingContext): super().__init__(ctx) self.lexers = [] - def accepts_more(self, statement): + def accepts_more(self, statement: list): return True - def input(self, statement): + def input(self, statement: list): if self.lexers and self.lexers[-1].accepts_more(statement): lexer = self.lexers[-1] else: @@ -52,7 +52,7 @@ def input(self, statement): lexer.input(statement) return lexer - def lexer_for(self, statement): + def lexer_for(self, statement: list): for cls in self.lexer_classes(): if cls.handles(statement, self.ctx): lexer = cls(self.ctx) @@ -90,14 +90,14 @@ def lexer_classes(self): class SectionLexer(BlockLexer): - def accepts_more(self, statement): + def accepts_more(self, statement: list): return not statement[0].value.startswith('*') class SettingSectionLexer(SectionLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return ctx.setting_section(statement) def lexer_classes(self): @@ -107,7 +107,7 @@ def lexer_classes(self): class VariableSectionLexer(SectionLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return ctx.variable_section(statement) def lexer_classes(self): @@ -117,7 +117,7 @@ def lexer_classes(self): class TestCaseSectionLexer(SectionLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return ctx.test_case_section(statement) def lexer_classes(self): @@ -127,7 +127,7 @@ def lexer_classes(self): class TaskSectionLexer(SectionLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return ctx.task_section(statement) def lexer_classes(self): @@ -137,7 +137,7 @@ def lexer_classes(self): class KeywordSectionLexer(SettingSectionLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return ctx.keyword_section(statement) def lexer_classes(self): @@ -147,7 +147,7 @@ def lexer_classes(self): class CommentSectionLexer(SectionLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return ctx.comment_section(statement) def lexer_classes(self): @@ -157,7 +157,7 @@ def lexer_classes(self): class ImplicitCommentSectionLexer(SectionLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return True def lexer_classes(self): @@ -167,7 +167,7 @@ def lexer_classes(self): class ErrorSectionLexer(SectionLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return statement and statement[0].value.startswith('*') def lexer_classes(self): @@ -178,10 +178,10 @@ class TestOrKeywordLexer(BlockLexer): name_type = NotImplemented _name_seen = False - def accepts_more(self, statement): + def accepts_more(self, statement: list): return not statement[0].value - def input(self, statement): + def input(self, statement: list): self._handle_name_or_indentation(statement) if statement: super().input(statement) @@ -206,31 +206,30 @@ def lexer_classes(self): class TestCaseLexer(TestOrKeywordLexer): name_type = Token.TESTCASE_NAME - def __init__(self, ctx): - """:type ctx: :class:`robot.parsing.lexer.context.TestCaseFileContext`""" + def __init__(self, ctx: SuiteFileContext): super().__init__(ctx.test_case_context()) - def lex(self,): + def lex(self): self._lex_with_priority(priority=TestOrKeywordSettingLexer) class KeywordLexer(TestOrKeywordLexer): name_type = Token.KEYWORD_NAME - def __init__(self, ctx): + def __init__(self, ctx: FileContext): super().__init__(ctx.keyword_context()) class NestedBlockLexer(BlockLexer): - def __init__(self, ctx): + def __init__(self, ctx: TestOrKeywordContext): super().__init__(ctx) self._block_level = 0 - def accepts_more(self, statement): + def accepts_more(self, statement: list): return self._block_level > 0 - def input(self, statement): + def input(self, statement: list): lexer = super().input(statement) if isinstance(lexer, (ForHeaderLexer, IfHeaderLexer, TryHeaderLexer, WhileHeaderLexer)): @@ -242,7 +241,7 @@ def input(self, statement): class ForLexer(NestedBlockLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return ForHeaderLexer.handles(statement, ctx) def lexer_classes(self): @@ -253,7 +252,7 @@ def lexer_classes(self): class WhileLexer(NestedBlockLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return WhileHeaderLexer.handles(statement, ctx) def lexer_classes(self): @@ -261,10 +260,22 @@ def lexer_classes(self): ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) +class TryLexer(NestedBlockLexer): + + @classmethod + def handles(cls, statement: list, ctx: TestOrKeywordContext): + return TryHeaderLexer.handles(statement, ctx) + + def lexer_classes(self): + return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, + ForLexer, InlineIfLexer, IfLexer, WhileLexer, EndLexer, ReturnLexer, + BreakLexer, ContinueLexer, KeywordCallLexer) + + class IfLexer(NestedBlockLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return IfHeaderLexer.handles(statement, ctx) def lexer_classes(self): @@ -276,19 +287,19 @@ def lexer_classes(self): class InlineIfLexer(BlockLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): if len(statement) <= 2: return False return InlineIfHeaderLexer.handles(statement, ctx) - def accepts_more(self, statement): + def accepts_more(self, statement: list): return False def lexer_classes(self): return (InlineIfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) - def input(self, statement): + def input(self, statement: list): for part in self._split(statement): if part: super().input(part) @@ -323,15 +334,3 @@ def _split(self, statement): else: current.append(token) yield current - - -class TryLexer(NestedBlockLexer): - - @classmethod - def handles(cls, statement, ctx): - return TryHeaderLexer(ctx).handles(statement, ctx) - - def lexer_classes(self): - return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, - ForLexer, InlineIfLexer, IfLexer, WhileLexer, EndLexer, ReturnLexer, - BreakLexer, ContinueLexer, KeywordCallLexer) diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 3cc0bf02fc4..4fcc193e9a6 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -16,7 +16,7 @@ from robot.conf import Languages from robot.utils import normalize_whitespace -from .settings import (InitFileSettings, TestCaseFileSettings, ResourceFileSettings, +from .settings import (InitFileSettings, SuiteFileSettings, ResourceFileSettings, TestCaseSettings, KeywordSettings) from .tokens import Token @@ -76,15 +76,15 @@ def _get_invalid_section_error(self, header): def _handles_section(self, statement, header): marker = statement[0].value - return (marker[:1] == '*' and + return (marker and marker[0] == '*' and self.languages.headers.get(self._normalize(marker)) == header) def _normalize(self, marker): return normalize_whitespace(marker).strip('* ').title() -class TestCaseFileContext(FileContext): - settings_class = TestCaseFileSettings +class SuiteFileContext(FileContext): + settings_class = SuiteFileSettings def test_case_context(self): return TestCaseContext(settings=TestCaseSettings(self.settings, self.languages)) @@ -129,15 +129,19 @@ def _get_invalid_section_error(self, header): return message, False -class TestCaseContext(LexingContext): +class TestOrKeywordContext(LexingContext): @property def template_set(self): - return self.settings.template_set + return False -class KeywordContext(LexingContext): +class TestCaseContext(TestOrKeywordContext): @property def template_set(self): - return False + return self.settings.template_set + + +class KeywordContext(TestOrKeywordContext): + pass diff --git a/src/robot/parsing/lexer/lexer.py b/src/robot/parsing/lexer/lexer.py index 780a8c084ea..0421d756b9d 100644 --- a/src/robot/parsing/lexer/lexer.py +++ b/src/robot/parsing/lexer/lexer.py @@ -19,7 +19,7 @@ from robot.utils import get_error_message, FileReader from .blocklexers import FileLexer -from .context import InitFileContext, TestCaseFileContext, ResourceFileContext +from .context import InitFileContext, SuiteFileContext, ResourceFileContext from .tokenizer import Tokenizer from .tokens import EOS, END, Token @@ -47,7 +47,7 @@ def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): Returns a generator that yields :class:`~robot.parsing.lexer.tokens.Token` instances. """ - lexer = Lexer(TestCaseFileContext(lang=lang), data_only, tokenize_variables) + lexer = Lexer(SuiteFileContext(lang=lang), data_only, tokenize_variables) lexer.input(source) return lexer.get_tokens() diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 18c84f68b19..cce09358176 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -134,7 +134,7 @@ def _lex_arguments(self, tokens): token.type = Token.ARGUMENT -class TestCaseFileSettings(Settings): +class SuiteFileSettings(Settings): names = ( 'Documentation', 'Metadata', diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 7f5bcdce00c..e361551e7b2 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -17,23 +17,24 @@ from robot.utils import normalize_whitespace from robot.variables import is_assign +from .context import FileContext, LexingContext, TestOrKeywordContext from .tokens import Token class Lexer: """Base class for lexers.""" - def __init__(self, ctx): + def __init__(self, ctx: LexingContext): self.ctx = ctx @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: LexingContext): return True - def accepts_more(self, statement): + def accepts_more(self, statement: list): raise NotImplementedError - def input(self, statement): + def input(self, statement: list): raise NotImplementedError def lex(self): @@ -43,14 +44,14 @@ def lex(self): class StatementLexer(Lexer): token_type = None - def __init__(self, ctx): + def __init__(self, ctx: FileContext): super().__init__(ctx) self.statement = None - def accepts_more(self, statement): + def accepts_more(self, statement: list): return False - def input(self, statement): + def input(self, statement: list): self.statement = statement def lex(self): @@ -73,9 +74,10 @@ def lex(self): class SectionHeaderLexer(SingleType): + ctx: FileContext @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: FileContext): return statement[0].value.startswith('*') @@ -114,8 +116,9 @@ class CommentLexer(SingleType): class ImplicitCommentLexer(CommentLexer): + ctx: FileContext - def input(self, statement): + def input(self, statement: list): super().input(statement) if len(statement) == 1 and statement[0].value.lower().startswith('language:'): lang = statement[0].value.split(':', 1)[1].strip() @@ -144,7 +147,7 @@ def lex(self): class TestOrKeywordSettingLexer(SettingLexer): @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): marker = statement[0].value return marker and marker[0] == '[' and marker[-1] == ']' @@ -154,6 +157,7 @@ class VariableLexer(TypeAndArguments): class KeywordCallLexer(StatementLexer): + ctx: TestOrKeywordContext def lex(self): if self.ctx.template_set: @@ -181,7 +185,7 @@ class ForHeaderLexer(StatementLexer): separators = ('IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP') @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'FOR' def lex(self): @@ -201,7 +205,7 @@ class IfHeaderLexer(TypeAndArguments): token_type = Token.IF @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'IF' and len(statement) <= 2 @@ -209,7 +213,7 @@ class InlineIfHeaderLexer(StatementLexer): token_type = Token.INLINE_IF @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): for token in statement: if token.value == 'IF': return True @@ -233,7 +237,7 @@ class ElseIfHeaderLexer(TypeAndArguments): token_type = Token.ELSE_IF @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return normalize_whitespace(statement[0].value) == 'ELSE IF' @@ -241,7 +245,7 @@ class ElseHeaderLexer(TypeAndArguments): token_type = Token.ELSE @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'ELSE' @@ -249,7 +253,7 @@ class TryHeaderLexer(TypeAndArguments): token_type = Token.TRY @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'TRY' @@ -257,7 +261,7 @@ class ExceptHeaderLexer(StatementLexer): token_type = Token.EXCEPT @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'EXCEPT' def lex(self): @@ -281,7 +285,7 @@ class FinallyHeaderLexer(TypeAndArguments): token_type = Token.FINALLY @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'FINALLY' @@ -289,7 +293,7 @@ class WhileHeaderLexer(StatementLexer): token_type = Token.WHILE @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'WHILE' def lex(self): @@ -304,7 +308,7 @@ class EndLexer(TypeAndArguments): token_type = Token.END @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'END' @@ -312,7 +316,7 @@ class ReturnLexer(TypeAndArguments): token_type = Token.RETURN_STATEMENT @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'RETURN' @@ -320,7 +324,7 @@ class ContinueLexer(TypeAndArguments): token_type = Token.CONTINUE @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'CONTINUE' @@ -328,5 +332,5 @@ class BreakLexer(TypeAndArguments): token_type = Token.BREAK @classmethod - def handles(cls, statement, ctx): + def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'BREAK' diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index d249de87877..cf2cd65d67b 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -14,10 +14,12 @@ # limitations under the License. import ast +from contextlib import contextmanager from robot.utils import file_writer, is_pathlike, is_string -from .statements import KeywordCall, TemplateArguments, Continue, Break, Return, ReturnStatement +from .statements import (Break, Continue, KeywordCall, Return, ReturnStatement, + Statement, TemplateArguments) from .visitor import ModelVisitor from ..lexer import Token @@ -50,22 +52,24 @@ def end_col_offset(self): def validate_model(self): ModelValidator().visit(self) - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): pass - def _body_is_empty(self): - valid = (KeywordCall, TemplateArguments, Continue, ReturnStatement, Break, For, If, While, Try) - return not any(isinstance(node, valid) for node in self.body) - class HeaderAndBody(Block): _fields = ('header', 'body') - def __init__(self, header, body=None, errors=()): + def __init__(self, header=None, body=None, errors=()): self.header = header self.body = body or [] self.errors = errors + def _body_is_empty(self): + # This works with tests, keywords and blocks inside them, not with sections. + valid = (KeywordCall, TemplateArguments, Continue, ReturnStatement, Break, + Block) + return not any(isinstance(node, valid) for node in self.body) + class File(Block): _fields = ('sections',) @@ -90,12 +94,8 @@ def save(self, output=None): ModelWriter(output).write(self) -class Section(Block): - _fields = ('header', 'body') - - def __init__(self, header=None, body=None): - self.header = header - self.body = body or [] +class Section(HeaderAndBody): + pass class SettingSection(Section): @@ -106,7 +106,7 @@ class VariableSection(Section): pass -# FIXME: should there be a separate TaskSection? +# TODO: should there be a separate TaskSection? class TestCaseSection(Section): @property @@ -122,42 +122,31 @@ class CommentSection(Section): pass -class TestCase(Block): - _fields = ('header', 'body') - - def __init__(self, header, body=None, errors=()): - self.header = header - self.body = body or [] - self.errors = errors +class TestCase(HeaderAndBody): @property def name(self): return self.header.name - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): + # FIXME: Tasks! self.errors += ('Test contains no keywords.',) -class Keyword(Block): - _fields = ('header', 'body') - - def __init__(self, header, body=None, errors=()): - self.header = header - self.body = body or [] - self.errors = errors +class Keyword(HeaderAndBody): @property def name(self): return self.header.name - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): if not any(isinstance(node, Return) for node in self.body): self.errors += (f"User keyword '{self.name}' contains no keywords.",) -class If(Block): +class If(HeaderAndBody): """Represents IF structures in the model. Used with IF, Inline IF, ELSE IF and ELSE nodes. The :attr:`type` attribute @@ -166,11 +155,9 @@ class If(Block): _fields = ('header', 'body', 'orelse', 'end') def __init__(self, header, body=None, orelse=None, end=None, errors=()): - self.header = header - self.body = body or [] + super().__init__(header, body, errors) self.orelse = orelse self.end = end - self.errors = errors @property def type(self): @@ -184,7 +171,7 @@ def condition(self): def assign(self): return self.header.assign - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): self._validate_body() if self.type == Token.IF: self._validate_structure() @@ -232,14 +219,12 @@ def _validate_inline_if(self): branch = branch.orelse -class For(Block): +class For(HeaderAndBody): _fields = ('header', 'body', 'end') def __init__(self, header, body=None, end=None, errors=()): - self.header = header - self.body = body or [] + super().__init__(header, body, errors) self.end = end - self.errors = errors @property def variables(self): @@ -253,22 +238,20 @@ def values(self): def flavor(self): return self.header.flavor - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): self.errors += ('FOR loop cannot be empty.',) if not self.end: self.errors += ('FOR loop must have closing END.',) -class Try(Block): +class Try(HeaderAndBody): _fields = ('header', 'body', 'next', 'end') def __init__(self, header, body=None, next=None, end=None, errors=()): - self.header = header - self.body = body or [] + super().__init__(header, body, errors) self.next = next self.end = end - self.errors = errors @property def type(self): @@ -286,7 +269,7 @@ def pattern_type(self): def variable(self): return getattr(self.header, 'variable', None) - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): self._validate_body() if self.type == Token.TRY: self._validate_structure() @@ -334,14 +317,12 @@ def _validate_end(self): self.errors += ('TRY must have closing END.',) -class While(Block): +class While(HeaderAndBody): _fields = ('header', 'body', 'end') def __init__(self, header, body=None, end=None, errors=()): - self.header = header - self.body = body or [] + super().__init__(header, body, errors) self.end = end - self.errors = errors @property def condition(self): @@ -351,7 +332,7 @@ def condition(self): def limit(self): return self.header.limit - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): self.errors += ('WHILE loop cannot be empty.',) if not self.end: @@ -368,14 +349,14 @@ def __init__(self, output): self.writer = output self.close_writer = False - def write(self, model): + def write(self, model: Block): try: self.visit(model) finally: if self.close_writer: self.writer.close() - def visit_Statement(self, statement): + def visit_Statement(self, statement: Statement): for token in statement.tokens: self.writer.write(token.value) @@ -383,48 +364,46 @@ def visit_Statement(self, statement): class ModelValidator(ModelVisitor): def __init__(self): - self._context = ValidationContext() + self.ctx = ValidationContext() - def visit_Block(self, node): - self._context.start_block(node) - node.validate(self._context) - ModelVisitor.generic_visit(self, node) - self._context.end_block() + def visit_Block(self, node: Block): + with self.ctx.block(node): + node.validate(self.ctx) + super().generic_visit(node) - def visit_Try(self, node): - if node.header.type == Token.FINALLY: - self._context.in_finally = True - self.visit_Block(node) - self._context.in_finally = False - - def visit_Statement(self, node): - node.validate(self._context) - ModelVisitor.generic_visit(self, node) + def visit_Statement(self, node: Statement): + node.validate(self.ctx) class ValidationContext: def __init__(self): - self.roots = [] - self.in_finally = False + self.blocks = [] - def start_block(self, node): - self.roots.append(node) + @contextmanager + def block(self, node: Block): + self.blocks.append(node) + try: + yield + finally: + self.blocks.pop() - def end_block(self): - self.roots.pop() + @property + def parent_block(self): + return self.blocks[-1] if self.blocks else None @property def in_keyword(self): - return Keyword in [type(r) for r in self.roots] + return any(isinstance(b, Keyword) for b in self.blocks) @property - def in_for(self): - return For in [type(r) for r in self.roots] + def in_loop(self): + return any(isinstance(b, (For, While)) for b in self.blocks) @property - def in_while(self): - return While in [type(r) for r in self.roots] + def in_finally(self): + parent = self.parent_block + return isinstance(parent, Try) and parent.header.type == Token.FINALLY class FirstStatementFinder(ModelVisitor): diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index b3b1a78335f..cce7b55132d 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -15,6 +15,7 @@ import ast import re +from typing import TYPE_CHECKING from robot.conf import Language from robot.running.arguments import UserKeywordArgumentParser @@ -23,6 +24,9 @@ from ..lexer import Token +if TYPE_CHECKING: + from .blocks import ValidationContext + FOUR_SPACES = ' ' EOL = '\n' @@ -133,7 +137,7 @@ def lines(self): if line: yield line - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): pass def __iter__(self): @@ -540,7 +544,7 @@ def name(self): def value(self): return self.get_values(Token.ARGUMENT) - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): name = self.get_value(Token.VARIABLE) match = search_variable(name, ignore_errors=True) if not match.is_assign(allow_assign_mark=True): @@ -577,7 +581,7 @@ def from_params(cls, name, eol=EOL): def name(self): return self.get_value(Token.TESTCASE_NAME) - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): if not self.name: self.errors += (f'Test name cannot be empty.',) @@ -693,7 +697,7 @@ def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): tokens.append(Token(Token.EOL, eol)) return cls(tokens) - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): errors = [] UserKeywordArgumentParser(error_reporter=errors.append).parse(self.values) self.errors = tuple(errors) @@ -796,7 +800,7 @@ def flavor(self): separator = self.get_token(Token.FOR_SEPARATOR) return normalize_whitespace(separator.value) if separator else None - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): if not self.variables: self._add_error('no loop variables') if not self.flavor: @@ -844,7 +848,7 @@ def condition(self): return ', '.join(values) if values else None return values[0] - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): conditions = len(self.get_tokens(Token.ARGUMENT)) if conditions == 0: self.errors += ('%s must have a condition.' % self.type,) @@ -878,7 +882,7 @@ def from_params(cls, indent=FOUR_SPACES, eol=EOL): Token(Token.EOL, eol) ]) - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): if self.get_tokens(Token.ARGUMENT): values = self.get_values(Token.ARGUMENT) self.errors += (f'ELSE does not accept arguments, got {seq2str(values)}.',) @@ -894,7 +898,7 @@ def from_params(cls, indent=FOUR_SPACES, eol=EOL): Token(Token.EOL, eol) ]) - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): if self.get_tokens(Token.ARGUMENT): self.errors += (f'{self.type} does not accept arguments, got ' f'{seq2str(self.values)}.',) @@ -945,7 +949,7 @@ def pattern_type(self): def variable(self): return self.get_value(Token.VARIABLE) - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): as_token = self.get_token(Token.AS) if as_token: variables = self.get_tokens(Token.VARIABLE) @@ -993,7 +997,7 @@ def limit(self): value = self.get_value(Token.OPTION) return value[len('limit='):] if value else None - def validate(self, context): + def validate(self, ctx: 'ValidationContext'): values = self.get_values(Token.ARGUMENT) if len(values) == 0: self.errors += ('WHILE must have a condition.',) @@ -1022,21 +1026,21 @@ def from_params(cls, values=(), indent=FOUR_SPACES, separator=FOUR_SPACES, eol=E tokens.append(Token(Token.EOL, eol)) return cls(tokens) - def validate(self, context): - if not context.in_keyword: - self.errors += ('RETURN can only be used inside a user keyword.', ) - if context.in_keyword and context.in_finally: - self.errors += ('RETURN cannot be used in FINALLY branch.', ) + def validate(self, ctx: 'ValidationContext'): + if not ctx.in_keyword: + self.errors += ('RETURN can only be used inside a user keyword.',) + if ctx.in_finally: + self.errors += ('RETURN cannot be used in FINALLY branch.',) class LoopControl(NoArgumentHeader): - def validate(self, context): - super(LoopControl, self).validate(context) - if not (context.in_for or context.in_while): - self.errors += (f'{self.type} can only be used inside a loop.', ) - if context.in_finally: - self.errors += (f'{self.type} cannot be used in FINALLY branch.', ) + def validate(self, ctx: 'ValidationContext'): + super().validate(ctx) + if not ctx.in_loop: + self.errors += (f'{self.type} can only be used inside a loop.',) + if ctx.in_finally: + self.errors += (f'{self.type} cannot be used in FINALLY branch.',) @Statement.register diff --git a/utest/parsing/test_statements_in_invalid_position.py b/utest/parsing/test_statements_in_invalid_position.py index ea1659ba9bb..892879a2a9c 100644 --- a/utest/parsing/test_statements_in_invalid_position.py +++ b/utest/parsing/test_statements_in_invalid_position.py @@ -116,6 +116,7 @@ def test_in_test_case_body_inside_try_except(self): expected.tokens[0].lineno = 8 remove_non_data_nodes_and_assert(tryroot.next.next.body[0], expected, data_only) expected.tokens[0].lineno = 10 + expected.errors += ('RETURN cannot be used in FINALLY branch.',) remove_non_data_nodes_and_assert(tryroot.next.next.next.body[0], expected, data_only) def test_in_finally_in_uk(self): From 0d6391d8f770d4e566aaf29586e013658cf800e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 13 Feb 2023 23:10:11 +0200 Subject: [PATCH 0380/1592] Add forward compatibible ReturnSetting alias for Return. Fixes #4656. --- src/robot/api/parsing.py | 6 ++++-- src/robot/parsing/model/blocks.py | 4 ++-- src/robot/parsing/model/statements.py | 17 +++++++++++++++++ src/robot/parsing/model/visitor.py | 9 ++++++--- utest/parsing/test_model.py | 26 ++++++++++++++++++++++++-- utest/parsing/test_statements.py | 14 +------------- 6 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/robot/api/parsing.py b/src/robot/api/parsing.py index c4ca3b387d0..132afdd4fd0 100644 --- a/src/robot/api/parsing.py +++ b/src/robot/api/parsing.py @@ -222,7 +222,8 @@ class were exposed directly via the :mod:`robot.api` package, but other - :class:`~robot.parsing.model.statements.Template` - :class:`~robot.parsing.model.statements.Timeout` - :class:`~robot.parsing.model.statements.Arguments` -- :class:`~robot.parsing.model.statements.Return` +- :class:`~robot.parsing.model.statements.Return` (deprecated, will mean ``ReturnStatement`` in RF 7.0) +- :class:`~robot.parsing.model.statements.ReturnSetting` (alias for ``Return``, new in RF 6.1) - :class:`~robot.parsing.model.statements.KeywordCall` - :class:`~robot.parsing.model.statements.TemplateArguments` - :class:`~robot.parsing.model.statements.IfHeader` @@ -239,7 +240,7 @@ class were exposed directly via the :mod:`robot.api` package, but other - :class:`~robot.parsing.model.statements.Break` - :class:`~robot.parsing.model.statements.Continue` - :class:`~robot.parsing.model.statements.Comment` -- :class:`~robot.parsing.model.statements.Config` (new in 6.0) +- :class:`~robot.parsing.model.statements.Config` (new in RF 6.0) - :class:`~robot.parsing.model.statements.Error` - :class:`~robot.parsing.model.statements.EmptyLine` @@ -529,6 +530,7 @@ def visit_File(self, node): Timeout, Arguments, Return, + ReturnSetting, KeywordCall, TemplateArguments, IfHeader, diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index cf2cd65d67b..d6aed0045c1 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -18,7 +18,7 @@ from robot.utils import file_writer, is_pathlike, is_string -from .statements import (Break, Continue, KeywordCall, Return, ReturnStatement, +from .statements import (Break, Continue, KeywordCall, ReturnSetting, ReturnStatement, Statement, TemplateArguments) from .visitor import ModelVisitor from ..lexer import Token @@ -142,7 +142,7 @@ def name(self): def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): - if not any(isinstance(node, Return) for node in self.body): + if not any(isinstance(node, ReturnSetting) for node in self.body): self.errors += (f"User keyword '{self.name}' contains no keywords.",) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index cce7b55132d..d7379c53507 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -703,8 +703,21 @@ def validate(self, ctx: 'ValidationContext'): self.errors = tuple(errors) +# TODO: Change Return to mean ReturnStatement in RF 7.0 +# - Rename current Return to ReturnSetting +# - Rename current ReturnStatement to Return +# - Add backwards compatible ReturnStatement alias +# - Change Token.RETURN to mean Token.RETURN_STATEMENT +# - Update also ModelVisitor @Statement.register class Return(MultiValue): + """Represents the deprecated ``[Return]`` setting. + + In addition to the ``[Return]`` setting itself, also the ``Return`` node + in the parsing model is deprecated. ``ReturnSetting`` (new in RF 6.1) should + be used instead. ``ReturnStatement`` will be renamed to ``Return`` in + the future, most likely already in RF 7.0. + """ type = Token.RETURN @classmethod @@ -718,6 +731,10 @@ def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): return cls(tokens) +# Forward compatible alias for Return. +ReturnSetting = Return + + @Statement.register class KeywordCall(Statement): type = Token.KEYWORD diff --git a/src/robot/parsing/model/visitor.py b/src/robot/parsing/model/visitor.py index 26306481742..b5952fac3a1 100644 --- a/src/robot/parsing/model/visitor.py +++ b/src/robot/parsing/model/visitor.py @@ -24,6 +24,9 @@ def _find_visitor(self, cls): method = 'visit_' + cls.__name__ if hasattr(self, method): return getattr(self, method) + # Forward-compatibility. + if method == 'visit_Return' and hasattr(self, 'visit_ReturnSetting'): + return self.visit_ReturnSetting for base in cls.__bases__: visitor = self._find_visitor(base) if visitor: @@ -34,14 +37,14 @@ def _find_visitor(self, cls): class ModelVisitor(ast.NodeVisitor, VisitorFinder): """NodeVisitor that supports matching nodes based on their base classes. - Otherwise identical to the standard `ast.NodeVisitor + In other ways identical to the standard `ast.NodeVisitor <https://docs.python.org/library/ast.html#ast.NodeVisitor>`__, but allows creating ``visit_ClassName`` methods so that the ``ClassName`` is one of the base classes of the node. For example, this visitor method - matches all statements:: + matches all ``Statement`` nodes:: def visit_Statement(self, node): - # ... + ... """ def visit(self, node): diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 2152dbdb76c..c33b9fb176b 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -12,8 +12,8 @@ from robot.parsing.model.statements import ( Arguments, Break, Comment, Config, Continue, Documentation, ForHeader, End, ElseHeader, ElseIfHeader, EmptyLine, Error, IfHeader, InlineIfHeader, TryHeader, ExceptHeader, - FinallyHeader, KeywordCall, KeywordName, ReturnStatement, SectionHeader, - TestCaseName, Variable, WhileHeader + FinallyHeader, KeywordCall, KeywordName, Return, ReturnSetting, ReturnStatement, + SectionHeader, TestCaseName, Variable, WhileHeader ) from robot.utils.asserts import assert_equal, assert_raises_with_msg @@ -1266,6 +1266,28 @@ def visit_Block(self, node): ]) assert_model(model, expected) + def test_visit_Return(self): + class VisitReturn(ModelVisitor): + def visit_Return(self, node): + self.node = node + + for cls in Return, ReturnSetting: + visitor = VisitReturn() + ret = cls.from_params(()) + visitor.visit(ret) + assert_equal(visitor.node, ret) + + def test_visit_ReturnSetting(self): + class VisitReturnSetting(ModelVisitor): + def visit_ReturnSetting(self, node): + self.node = node + + for cls in Return, ReturnSetting: + visitor = VisitReturnSetting() + ret = cls.from_params(()) + visitor.visit(ret) + assert_equal(visitor.node, ret) + class TestLanguageConfig(unittest.TestCase): diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 8e327c2d1b7..fe2a4b7d0da 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -335,18 +335,6 @@ def test_LibraryImport(self): name='library_name.py' ) - # Library library_name.py 127.0.0.1 8080 - tokens = [ - Token(Token.LIBRARY, 'Library'), - Token(Token.SEPARATOR, ' '), - Token(Token.NAME, 'library_name.py'), - Token(Token.SEPARATOR, ' '), - Token(Token.ARGUMENT, '127.0.0.1'), - Token(Token.SEPARATOR, ' '), - Token(Token.ARGUMENT, '8080'), - Token(Token.EOL, '\n') - ] - # Library library_name.py WITH NAME anothername tokens = [ Token(Token.LIBRARY, 'Library'), @@ -632,7 +620,7 @@ def test_ReturnSetting(self): ] assert_created_statement( tokens, - Return, + ReturnSetting, args=['${arg1}', '${arg2}=4'] ) From 35cadc916aab68307cf8f0d443bb3eac78c2d5bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 14 Feb 2023 12:28:22 +0200 Subject: [PATCH 0381/1592] Add type hints to visitor API #4569 --- src/robot/model/visitor.py | 158 +++++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 59 deletions(-) diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 21e425462ac..36c2bcf07f1 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -74,8 +74,47 @@ internally by Robot Framework itself. Some good examples are :class:`~robot.model.tagsetter.TagSetter` and :mod:`keyword removers <robot.result.keywordremover>`. + +Type hints +---------- + +Visitor methods have type hints to give more information about the model objects +they receive to editors. Because visitors can be used with both running and result +models, the types that are used are base classes from the :mod:`robot.model` +module. Actual visitors may want to import appropriate types from +:mod:`robot.running.model` or from :mod:`robot.result.model` modules instead. +For example, this code that prints failed tests uses result side model objects:: + + from robot.api import SuiteVisitor + from robot.result.model import TestCase, TestSuite + + + class FailurePrinter(SuiteVisitor): + + def start_suite(self, suite: TestSuite): + print(f"{suite.longname}: {suite.statistics.failed} failed") + + def visit_test(self, test: TestCase): + if test.failed: + print(f'- {test.name}: {test.message}') + +Type hints were added in Robot Framework 6.1. They are optional and can be +removed altogether if they get in the way. """ +from typing import TYPE_CHECKING + +from .body import BodyItem +from .control import Break, Continue, For, If, IfBranch, Return, Try, TryBranch, While +from .keyword import Keyword +from .message import Message +from .testcase import TestCase + +# Avoid circular imports. +if TYPE_CHECKING: + from robot.result import ForIteration, WhileIteration + from .testsuite import TestSuite + class SuiteVisitor: """Abstract class to ease traversing through the suite structure. @@ -84,7 +123,7 @@ class SuiteVisitor: information and an example. """ - def visit_suite(self, suite): + def visit_suite(self, suite: 'TestSuite'): """Implements traversing through suites. Can be overridden to allow modifying the passed in ``suite`` without @@ -100,18 +139,18 @@ def visit_suite(self, suite): suite.teardown.visit(self) self.end_suite(suite) - def start_suite(self, suite): + def start_suite(self, suite: 'TestSuite'): """Called when a suite starts. Default implementation does nothing. Can return explicit ``False`` to stop visiting. """ pass - def end_suite(self, suite): + def end_suite(self, suite: 'TestSuite'): """Called when a suite ends. Default implementation does nothing.""" pass - def visit_test(self, test): + def visit_test(self, test: TestCase): """Implements traversing through tests. Can be overridden to allow modifying the passed in ``test`` without calling @@ -125,32 +164,32 @@ def visit_test(self, test): test.teardown.visit(self) self.end_test(test) - def start_test(self, test): + def start_test(self, test: TestCase): """Called when a test starts. Default implementation does nothing. Can return explicit ``False`` to stop visiting. """ pass - def end_test(self, test): + def end_test(self, test: TestCase): """Called when a test ends. Default implementation does nothing.""" pass - def visit_keyword(self, kw): + def visit_keyword(self, keyword: Keyword): """Implements traversing through keywords. Can be overridden to allow modifying the passed in ``kw`` without calling :meth:`start_keyword` or :meth:`end_keyword` nor visiting the body of the keyword """ - if self.start_keyword(kw) is not False: - if hasattr(kw, 'body'): - kw.body.visit(self) - if kw.has_teardown: - kw.teardown.visit(self) - self.end_keyword(kw) + if self.start_keyword(keyword) is not False: + if hasattr(keyword, 'body'): + keyword.body.visit(self) + if keyword.has_teardown: + keyword.teardown.visit(self) + self.end_keyword(keyword) - def start_keyword(self, keyword): + def start_keyword(self, keyword: Keyword): """Called when a keyword starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -159,14 +198,14 @@ def start_keyword(self, keyword): """ return self.start_body_item(keyword) - def end_keyword(self, keyword): + def end_keyword(self, keyword: Keyword): """Called when a keyword ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(keyword) - def visit_for(self, for_): + def visit_for(self, for_: For): """Implements traversing through FOR loops. Can be overridden to allow modifying the passed in ``for_`` without @@ -176,7 +215,7 @@ def visit_for(self, for_): for_.body.visit(self) self.end_for(for_) - def start_for(self, for_): + def start_for(self, for_: For): """Called when a FOR loop starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -185,14 +224,14 @@ def start_for(self, for_): """ return self.start_body_item(for_) - def end_for(self, for_): + def end_for(self, for_: For): """Called when a FOR loop ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(for_) - def visit_for_iteration(self, iteration): + def visit_for_iteration(self, iteration: 'ForIteration'): """Implements traversing through single FOR loop iteration. This is only used with the result side model because on the running side @@ -206,7 +245,7 @@ def visit_for_iteration(self, iteration): iteration.body.visit(self) self.end_for_iteration(iteration) - def start_for_iteration(self, iteration): + def start_for_iteration(self, iteration: 'ForIteration'): """Called when a FOR loop iteration starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -215,18 +254,19 @@ def start_for_iteration(self, iteration): """ return self.start_body_item(iteration) - def end_for_iteration(self, iteration): + def end_for_iteration(self, iteration: 'ForIteration'): """Called when a FOR loop iteration ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(iteration) - def visit_if(self, if_): + def visit_if(self, if_: If): """Implements traversing through IF/ELSE structures. - Notice that ``if_`` does not have any data directly. Actual IF/ELSE branches - are in its ``body`` and visited using :meth:`visit_if_branch`. + Notice that ``if_`` does not have any data directly. Actual IF/ELSE + branches are in its ``body`` and they are visited separately using + :meth:`visit_if_branch`. Can be overridden to allow modifying the passed in ``if_`` without calling :meth:`start_if` or :meth:`end_if` nor visiting branches. @@ -235,7 +275,7 @@ def visit_if(self, if_): if_.body.visit(self) self.end_if(if_) - def start_if(self, if_): + def start_if(self, if_: If): """Called when an IF/ELSE structure starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -244,14 +284,14 @@ def start_if(self, if_): """ return self.start_body_item(if_) - def end_if(self, if_): + def end_if(self, if_: If): """Called when an IF/ELSE structure ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(if_) - def visit_if_branch(self, branch): + def visit_if_branch(self, branch: IfBranch): """Implements traversing through single IF/ELSE branch. Can be overridden to allow modifying the passed in ``branch`` without @@ -261,7 +301,7 @@ def visit_if_branch(self, branch): branch.body.visit(self) self.end_if_branch(branch) - def start_if_branch(self, branch): + def start_if_branch(self, branch: IfBranch): """Called when an IF/ELSE branch starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -270,14 +310,14 @@ def start_if_branch(self, branch): """ return self.start_body_item(branch) - def end_if_branch(self, branch): + def end_if_branch(self, branch: IfBranch): """Called when an IF/ELSE branch ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(branch) - def visit_try(self, try_): + def visit_try(self, try_: Try): """Implements traversing through TRY/EXCEPT structures. This method is used with the TRY/EXCEPT root element. Actual TRY, EXCEPT, ELSE @@ -287,7 +327,7 @@ def visit_try(self, try_): try_.body.visit(self) self.end_try(try_) - def start_try(self, try_): + def start_try(self, try_: Try): """Called when a TRY/EXCEPT structure starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -296,20 +336,20 @@ def start_try(self, try_): """ return self.start_body_item(try_) - def end_try(self, try_): + def end_try(self, try_: Try): """Called when a TRY/EXCEPT structure ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(try_) - def visit_try_branch(self, branch): + def visit_try_branch(self, branch: TryBranch): """Visits individual TRY, EXCEPT, ELSE and FINALLY branches.""" if self.start_try_branch(branch) is not False: branch.body.visit(self) self.end_try_branch(branch) - def start_try_branch(self, branch): + def start_try_branch(self, branch: TryBranch): """Called when TRY, EXCEPT, ELSE or FINALLY branches start. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -318,14 +358,14 @@ def start_try_branch(self, branch): """ return self.start_body_item(branch) - def end_try_branch(self, branch): + def end_try_branch(self, branch: TryBranch): """Called when TRY, EXCEPT, ELSE and FINALLY branches end. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(branch) - def visit_while(self, while_): + def visit_while(self, while_: While): """Implements traversing through WHILE loops. Can be overridden to allow modifying the passed in ``while_`` without @@ -335,7 +375,7 @@ def visit_while(self, while_): while_.body.visit(self) self.end_while(while_) - def start_while(self, while_): + def start_while(self, while_: While): """Called when a WHILE loop starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -344,14 +384,14 @@ def start_while(self, while_): """ return self.start_body_item(while_) - def end_while(self, while_): + def end_while(self, while_: While): """Called when a WHILE loop ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(while_) - def visit_while_iteration(self, iteration): + def visit_while_iteration(self, iteration: 'WhileIteration'): """Implements traversing through single WHILE loop iteration. This is only used with the result side model because on the running side @@ -365,7 +405,7 @@ def visit_while_iteration(self, iteration): iteration.body.visit(self) self.end_while_iteration(iteration) - def start_while_iteration(self, iteration): + def start_while_iteration(self, iteration: 'WhileIteration'): """Called when a WHILE loop iteration starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -374,21 +414,21 @@ def start_while_iteration(self, iteration): """ return self.start_body_item(iteration) - def end_while_iteration(self, iteration): + def end_while_iteration(self, iteration: 'WhileIteration'): """Called when a WHILE loop iteration ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(iteration) - def visit_return(self, return_): + def visit_return(self, return_: Return): """Visits a RETURN elements.""" if self.start_return(return_) is not False: if hasattr(return_, 'body'): return_.body.visit(self) self.end_return(return_) - def start_return(self, return_): + def start_return(self, return_: Return): """Called when a RETURN element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -397,21 +437,21 @@ def start_return(self, return_): """ return self.start_body_item(return_) - def end_return(self, return_): + def end_return(self, return_: Return): """Called when a RETURN element ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(return_) - def visit_continue(self, continue_): + def visit_continue(self, continue_: Continue): """Visits CONTINUE elements.""" if self.start_continue(continue_) is not False: if hasattr(continue_, 'body'): continue_.body.visit(self) self.end_continue(continue_) - def start_continue(self, continue_): + def start_continue(self, continue_: Continue): """Called when a CONTINUE element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -420,21 +460,21 @@ def start_continue(self, continue_): """ return self.start_body_item(continue_) - def end_continue(self, continue_): + def end_continue(self, continue_: Continue): """Called when a CONTINUE element ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(continue_) - def visit_break(self, break_): + def visit_break(self, break_: Break): """Visits BREAK elements.""" if self.start_break(break_) is not False: if hasattr(break_, 'body'): break_.body.visit(self) self.end_break(break_) - def start_break(self, break_): + def start_break(self, break_: Break): """Called when a BREAK element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -443,39 +483,39 @@ def start_break(self, break_): """ return self.start_body_item(break_) - def end_break(self, break_): + def end_break(self, break_: Break): """Called when a BREAK element ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(break_) - def visit_message(self, msg): + def visit_message(self, message: Message): """Implements visiting messages. Can be overridden to allow modifying the passed in ``msg`` without calling :meth:`start_message` or :meth:`end_message`. """ - if self.start_message(msg) is not False: - self.end_message(msg) + if self.start_message(message) is not False: + self.end_message(message) - def start_message(self, msg): + def start_message(self, message: Message): """Called when a message starts. By default, calls :meth:`start_body_item` which, by default, does nothing. Can return explicit ``False`` to stop visiting. """ - return self.start_body_item(msg) + return self.start_body_item(message) - def end_message(self, msg): + def end_message(self, message: Message): """Called when a message ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ - self.end_body_item(msg) + self.end_body_item(message) - def start_body_item(self, item): + def start_body_item(self, item: BodyItem): """Called, by default, when keywords, messages or control structures start. More specific :meth:`start_keyword`, :meth:`start_message`, `:meth:`start_for`, @@ -487,7 +527,7 @@ def start_body_item(self, item): """ pass - def end_body_item(self, item): + def end_body_item(self, item: BodyItem): """Called, by default, when keywords, messages or control structures end. More specific :meth:`end_keyword`, :meth:`end_message`, `:meth:`end_for`, From ed6a473a8875ecaa7172d8dcd5edf7976c984dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 14 Feb 2023 20:00:01 +0200 Subject: [PATCH 0382/1592] API doc enhancements. Most importantly, mention that running and result model objects can be imported via `robot.running.model` and `robot.result.model`, respectivaly. Fixes #4569. --- doc/api/code_examples/check_test_times.py | 5 +++-- src/robot/result/__init__.py | 4 ++-- src/robot/result/model.py | 6 +++++- src/robot/running/__init__.py | 23 +++++++++++++---------- src/robot/running/builder/builders.py | 7 ++++--- src/robot/running/model.py | 9 +++++---- 6 files changed, 32 insertions(+), 22 deletions(-) diff --git a/doc/api/code_examples/check_test_times.py b/doc/api/code_examples/check_test_times.py index 85790bd6af0..5ac3181a33b 100644 --- a/doc/api/code_examples/check_test_times.py +++ b/doc/api/code_examples/check_test_times.py @@ -11,14 +11,15 @@ import sys from robot.api import ExecutionResult, ResultVisitor +from robot.result.model import TestCase class ExecutionTimeChecker(ResultVisitor): - def __init__(self, max_seconds): + def __init__(self, max_seconds: float): self.max_milliseconds = max_seconds * 1000 - def visit_test(self, test): + def visit_test(self, test: TestCase): if test.status == 'PASS' and test.elapsedtime > self.max_milliseconds: test.status = 'FAIL' test.message = 'Test execution took too long.' diff --git a/src/robot/result/__init__.py b/src/robot/result/__init__.py index e22809a4630..51014a04eb5 100644 --- a/src/robot/result/__init__.py +++ b/src/robot/result/__init__.py @@ -20,8 +20,8 @@ :class:`~.ResultVisitor` abstract class, that eases further processing the results. -The model objects in the :mod:`~.model` module can also be considered to be -part of the public API, because they can be found inside the :class:`~.Result` +The model objects in the :mod:`robot.result.model` module can also be considered +to be part of the public API, because they can be found inside the :class:`~.Result` object. They can also be inspected and modified as part of the normal test execution by `pre-Rebot modifiers`__ and `listeners`__. diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 2b26e09025a..7c8fcf7870c 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -16,7 +16,7 @@ """Module implementing result related model objects. During test execution these objects are created internally by various runners. -At that time they can inspected and modified by listeners__. +At that time they can be inspected and modified by listeners__. When results are parsed from XML output files after execution to be able to create logs and reports, these objects are created by the @@ -27,6 +27,10 @@ by custom scripts and tools. In such usage it is often easiest to inspect and modify these objects using the :mod:`visitor interface <robot.model.visitor>`. +If classes defined here are needed, for example, as type hints, they can +be imported directly from this :mod:`robot.running.model` module. This +module is considered stable. + __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#programmatic-modification-of-results diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index b53795189d7..00d39be8881 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -15,20 +15,23 @@ """Implements the core test execution logic. -The main public entry points of this package are of the following two classes: - -* :class:`~robot.running.builder.builders.TestSuiteBuilder` for creating - executable test suites based on existing test case files and directories. +The main public entry points of this package are of the following: * :class:`~robot.running.model.TestSuite` for creating an executable test suite structure programmatically. -It is recommended to import both of these classes via the :mod:`robot.api` -package like in the examples below. Also :class:`~robot.running.model.TestCase` -and :class:`~robot.running.model.Keyword` classes used internally by the -:class:`~robot.running.model.TestSuite` class are part of the public API. -In those rare cases where these classes are needed directly, they can be -imported from this package. +* Classes used by :class:`~robot.running.model.TestSuite`, such as + :class:`~robot.running.model.TestCase` and :class:`~robot.running.model.Keyword`, + that are defined in the :mod:`robot.running.model` module. + +* :class:`~robot.running.builder.builders.TestSuiteBuilder` for creating + executable test suites based on existing test case files and directories. The + :meth:`TestSuite.from_file_system <robot.running.model.TestSuite.from_file_system>` + classmethod can be used for that purpose as well. + +The classes mentioned above can be imported via the :mod:`robot.api` package +as the examples below demonstrate. If other model objects are needed, they +can be imported from the :mod:`robot.running.model` module. Examples -------- diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index a7cf5a8810e..75e8369513d 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -36,14 +36,15 @@ class TestSuiteBuilder: - Inspect the suite to see, for example, what tests it has or what tags tests have. This can be more convenient than using the lower level - :mod:`~robot.parsing` APIs but does not allow saving modified data - back to the disk. + :mod:`~robot.parsing` APIs. Both modifying the suite and inspecting what data it contains are easiest done by using the :mod:`~robot.model.visitor` interface. This class is part of the public API and should be imported via the - :mod:`robot.api` package. + :mod:`robot.api` package. An alternative is using the + :meth:`TestSuite.from_file_system <robot.running.model.TestSuite.from_file_system>` + classmethod that uses this class internally. """ def __init__(self, included_suites=None, included_extensions=('.robot', '.rbt'), diff --git a/src/robot/running/model.py b/src/robot/running/model.py index b36c138e57d..0845a252e63 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -16,8 +16,8 @@ """Module implementing test execution related model objects. When tests are executed normally, these objects are created based on the test -data on the file system by :class:`~.builder.TestSuiteBuilder`, but external -tools can also create an executable test suite model structure directly. +data on the file system by :class:`~robot.running.builder.builders.TestSuiteBuilder`, +but external tools can also create an executable test suite model structure directly. Regardless the approach to create it, the model is executed by calling :meth:`~TestSuite.run` method of the root test suite. See the :mod:`robot.running` package level documentation for more information and @@ -26,8 +26,9 @@ The most important classes defined in this module are :class:`TestSuite`, :class:`TestCase` and :class:`Keyword`. When tests are executed, these objects can be inspected and modified by `pre-run modifiers`__ and `listeners`__. -The aforementioned objects are considered stable, but other objects in this -module may still be changed in the future major releases. +These three classes are exposed via the :mod:`robot.api` package. If other +classes are needed, they can be imported directly from this +:mod:`robot.running.model` module. This module is considered stable. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#programmatic-modification-of-results __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface From 150a01986aabc31b8d5173e2d1fcdcaf2677c4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 14 Feb 2023 20:11:55 +0200 Subject: [PATCH 0383/1592] Update version in deprecation warning --- atest/robot/tags/-tag_syntax.robot | 2 +- src/robot/running/builder/transformers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/atest/robot/tags/-tag_syntax.robot b/atest/robot/tags/-tag_syntax.robot index ed5685688fc..3ee42d89bae 100644 --- a/atest/robot/tags/-tag_syntax.robot +++ b/atest/robot/tags/-tag_syntax.robot @@ -31,6 +31,6 @@ Check Deprecation Warning [Arguments] ${index} ${source} ${lineno} ${tag} Error in file ${index} ${source} ${lineno} ... Settings tags starting with a hyphen using the '[Tags]' setting is deprecated. - ... In Robot Framework 6.1 this syntax will be used for removing tags. + ... In Robot Framework 7.0 this syntax will be used for removing tags. ... Escape '${tag}' like '\\${tag}' to use the literal value and to avoid this warning. ... level=WARN pattern=False diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index fb3c84f04eb..0d1a32d7cdf 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -583,7 +583,7 @@ def deprecate_tags_starting_with_hyphen(node, source): LOGGER.warn( f"Error in file '{source}' on line {node.lineno}: " f"Settings tags starting with a hyphen using the '[Tags]' setting " - f"is deprecated. In Robot Framework 6.1 this syntax will be used " + f"is deprecated. In Robot Framework 7.0 this syntax will be used " f"for removing tags. Escape '{tag}' like '\\{tag}' to use the " f"literal value and to avoid this warning." ) From 6ede2fc7a7cc4762e1053b70989bb3e59b857fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 14 Feb 2023 23:58:09 +0200 Subject: [PATCH 0384/1592] Small typing fix to dynamic API #4567 Apparently Mypy's stubgen doesn't like new `int|str` union syntax and explicit `Union` needs to be used instead. Defining `Type` only once outside `if/else` simplifies the code. --- src/robot/api/interfaces.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index c425d4c0e21..9458e1499ac 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -47,15 +47,8 @@ # Need to use version check and not try/except to support Mypy's stubgen. if sys.version_info >= (3, 10): from types import UnionType - Type = (type # Actual type. - | str # Type name or alias. - | UnionType # Union syntax (e.g. `int | float`). - | tuple[ # Tuple of types. Behaves like union. - type | str, ... - ]) else: - # Same as above but without UnionType. - Type = Union[type, str, Tuple[Union[type, str], ...]] + UnionType = type Name = str PositArgs = List[Any] @@ -68,6 +61,12 @@ Tuple[str, Any] # Name and default like `('arg', 1)`. ] ] +Type = Union[ + type, # Actual type. + str, # Type name or alias. + UnionType, # Union syntax (e.g. `int | float`). + Tuple[Union[type, str], ...] # Tuple of types. Behaves like union. +] Types = Union[ Dict[str, Type], # Types by name. List[ # Types by position. From 97b001939ef33bf8ad0defcf184240575dadd4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Feb 2023 19:47:47 +0200 Subject: [PATCH 0385/1592] Refactor - Explicit base classes - Better method names - f-strigs --- src/robot/running/arguments/argumentparser.py | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/src/robot/running/arguments/argumentparser.py b/src/robot/running/arguments/argumentparser.py index 4f3009ddffe..b4d9d49db0f 100644 --- a/src/robot/running/arguments/argumentparser.py +++ b/src/robot/running/arguments/argumentparser.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC, abstractmethod from inspect import isclass, signature, Parameter from typing import get_type_hints @@ -23,12 +24,13 @@ from .argumentspec import ArgumentSpec -class _ArgumentParser: +class ArgumentParser(ABC): def __init__(self, type='Keyword', error_reporter=None): self._type = type self._error_reporter = error_reporter + @abstractmethod def parse(self, source, name=None): raise NotImplementedError @@ -36,10 +38,10 @@ def _report_error(self, error): if self._error_reporter: self._error_reporter(error) else: - raise DataError('Invalid argument specification: %s' % error) + raise DataError(f'Invalid argument specification: {error}') -class PythonArgumentParser(_ArgumentParser): +class PythonArgumentParser(ArgumentParser): def parse(self, handler, name=None): spec = ArgumentSpec(name, self._type) @@ -93,7 +95,7 @@ def _get_type_hints(self, handler): return getattr(handler, '__annotations__', {}) -class _ArgumentSpecParser(_ArgumentParser): +class ArgumentSpecParser(ArgumentParser): def parse(self, argspec, name=None): spec = ArgumentSpec(name, self._type) @@ -106,13 +108,13 @@ def parse(self, argspec, name=None): arg, default = arg arg = self._add_arg(spec, arg, named_only) spec.defaults[arg] = default - elif self._is_kwargs(arg): - spec.var_named = self._format_kwargs(arg) - elif self._is_varargs(arg): + elif self._is_var_named(arg): + spec.var_named = self._format_var_named(arg) + elif self._is_var_positional(arg): if named_only: self._report_error('Cannot have multiple varargs.') - if not self._is_kw_only_separator(arg): - spec.var_positional = self._format_varargs(arg) + if not self._is_named_only_separator(arg): + spec.var_positional = self._format_var_positional(arg) named_only = True elif spec.defaults and not named_only: self._report_error('Non-default argument after default arguments.') @@ -120,22 +122,28 @@ def parse(self, argspec, name=None): self._add_arg(spec, arg, named_only) return spec + @abstractmethod def _validate_arg(self, arg): raise NotImplementedError - def _is_kwargs(self, arg): + @abstractmethod + def _is_var_named(self, arg): raise NotImplementedError - def _format_kwargs(self, kwargs): + @abstractmethod + def _format_var_named(self, kwargs): raise NotImplementedError - def _is_kw_only_separator(self, arg): + @abstractmethod + def _is_named_only_separator(self, arg): raise NotImplementedError - def _is_varargs(self, arg): + @abstractmethod + def _is_var_positional(self, arg): raise NotImplementedError - def _format_varargs(self, varargs): + @abstractmethod + def _format_var_positional(self, varargs): raise NotImplementedError def _format_arg(self, arg): @@ -148,12 +156,12 @@ def _add_arg(self, spec, arg, named_only=False): return arg -class DynamicArgumentParser(_ArgumentSpecParser): +class DynamicArgumentParser(ArgumentSpecParser): def _validate_arg(self, arg): if isinstance(arg, tuple): if self._is_invalid_tuple(arg): - self._report_error('Invalid argument "%s".' % (arg,)) + self._report_error(f'Invalid argument "{arg}".') if len(arg) == 1: return arg[0] return arg @@ -166,49 +174,49 @@ def _is_invalid_tuple(self, arg): or not is_string(arg[0]) or (arg[0].startswith('*') and len(arg) > 1)) - def _is_kwargs(self, arg): - return arg.startswith('**') + def _is_var_named(self, arg): + return arg[:2] == '**' - def _format_kwargs(self, kwargs): + def _format_var_named(self, kwargs): return kwargs[2:] - def _is_varargs(self, arg): - return arg.startswith('*') + def _is_var_positional(self, arg): + return arg and arg[0] == '*' - def _is_kw_only_separator(self, arg): + def _is_named_only_separator(self, arg): return arg == '*' - def _format_varargs(self, varargs): + def _format_var_positional(self, varargs): return varargs[1:] -class UserKeywordArgumentParser(_ArgumentSpecParser): +class UserKeywordArgumentParser(ArgumentSpecParser): def _validate_arg(self, arg): arg, default = split_from_equals(arg) if not (is_assign(arg) or arg == '@{}'): - self._report_error("Invalid argument syntax '%s'." % arg) + self._report_error(f"Invalid argument syntax '{arg}'.") if default is None: return arg if not is_scalar_assign(arg): typ = 'list' if arg[0] == '@' else 'dictionary' - self._report_error("Only normal arguments accept default values, " - "%s arguments like '%s' do not." % (typ, arg)) + self._report_error(f"Only normal arguments accept default values, " + f"{typ} arguments like '{arg}' do not.") return arg, default - def _is_kwargs(self, arg): + def _is_var_named(self, arg): return arg and arg[0] == '&' - def _format_kwargs(self, kwargs): + def _format_var_named(self, kwargs): return kwargs[2:-1] - def _is_varargs(self, arg): + def _is_var_positional(self, arg): return arg and arg[0] == '@' - def _is_kw_only_separator(self, arg): + def _is_named_only_separator(self, arg): return arg == '@{}' - def _format_varargs(self, varargs): + def _format_var_positional(self, varargs): return varargs[2:-1] def _format_arg(self, arg): From 572f1e412a14c125e2098ce79e37dd45c2c0f990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Feb 2023 20:17:30 +0200 Subject: [PATCH 0386/1592] Try to fix flakey test. --- .../standard_libraries/datetime/get_current_date.robot | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/atest/testdata/standard_libraries/datetime/get_current_date.robot b/atest/testdata/standard_libraries/datetime/get_current_date.robot index b7fa1df08ab..160faf79a47 100644 --- a/atest/testdata/standard_libraries/datetime/get_current_date.robot +++ b/atest/testdata/standard_libraries/datetime/get_current_date.robot @@ -53,8 +53,8 @@ Result format custom timestamp Result format epoch ${result} = Get Current Date result_format=epoch - ${expected} = Evaluate time.time() modules=time - Should Be True 0 <= ${expected} - ${result} < 1 + # Round `time.time()` to same precision as `datetime` that `Get Current Date` uses. + Should Be True 0 <= round(time.time(), 6) - ${result} < 1 Local and UTC epoch times are same ${local} = Get Current Date local result_format=epoch @@ -71,5 +71,5 @@ Result format datetime *** Keywords *** Compare Datatimes [Arguments] ${dt1} ${dt2} ${difference}=0 - ${result} = Evaluate $dt2 - $dt1 - datetime.timedelta(0, ${difference}) modules=datetime + ${result} = Evaluate $dt2 - $dt1 - datetime.timedelta(0, ${difference}) Should Be True 0 <= ${result.total_seconds()} < 1 From 0cbbf3b54ce4012c273f542a4196e88f75a1c8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Feb 2023 21:03:08 +0200 Subject: [PATCH 0387/1592] Remove BodyItem.has_setup/teardown. All BodyItem subclasses, includin If and Message, having these methods is odd and adds noise. Better to only have them in TestCase, TestSuite and result side Keyword that actually can have setup/teardown. The only downside is that generic code handling body items need to use `getattr(x, 'has_setup', False)` instead of `x.has_setup`. I consider that a smaller issue than unrelated objects having these methods. --- src/robot/model/body.py | 15 ++++----------- src/robot/model/visitor.py | 2 +- src/robot/reporting/jsmodelbuilders.py | 2 +- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index f9e5b353596..357ab1b59a2 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -58,22 +58,15 @@ def id(self): def _get_id(self, parent): steps = [] - if parent.has_setup: + if getattr(parent, 'has_setup', False): steps.append(parent.setup) if hasattr(parent, 'body'): steps.extend(step for step in parent.body.flatten() if step.type != self.MESSAGE) - if parent.has_teardown: + if getattr(parent, 'has_teardown', False): steps.append(parent.teardown) - return '%s-k%d' % (parent.id, steps.index(self) + 1) - - @property - def has_setup(self): - return False - - @property - def has_teardown(self): - return False + my_id = steps.index(self) + 1 + return f'{parent.id}-k{my_id}' def to_dict(self): raise NotImplementedError diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 36c2bcf07f1..19aed19526b 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -185,7 +185,7 @@ def visit_keyword(self, keyword: Keyword): if self.start_keyword(keyword) is not False: if hasattr(keyword, 'body'): keyword.body.visit(self) - if keyword.has_teardown: + if getattr(keyword, 'has_teardown', False): keyword.teardown.visit(self) self.end_keyword(keyword) diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index c6c8d681a42..a1277948c40 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -153,7 +153,7 @@ def build(self, item, split=False): def build_keyword(self, kw, split=False): self._context.check_expansion(kw) items = kw.body.flatten() - if kw.has_teardown: + if getattr(kw, 'has_teardown', False): items.append(kw.teardown) with self._context.prune_input(kw.body): return (KEYWORD_TYPES[kw.type], From c9b8c3f3d12d0d4e189ae8e683322d600b8a2561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 16 Feb 2023 11:16:00 +0200 Subject: [PATCH 0388/1592] Expose robot.running.model classes via robot.running. robot.result.model classes were already exposed via robot.result. Also enhance related documentation. Relatd to type hints in visitor API (#4569) and listener API (#4568). --- src/robot/result/__init__.py | 24 +++++++++------------- src/robot/result/model.py | 3 +-- src/robot/running/__init__.py | 38 +++++++++++++++++++---------------- src/robot/running/model.py | 28 +++++++++++++------------- 4 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/robot/result/__init__.py b/src/robot/result/__init__.py index 51014a04eb5..0fb8342568a 100644 --- a/src/robot/result/__init__.py +++ b/src/robot/result/__init__.py @@ -18,19 +18,15 @@ The main public API of this package consists of the :func:`~.ExecutionResult` factory method, that returns :class:`~.Result` objects, and of the :class:`~.ResultVisitor` abstract class, that eases further processing -the results. +the results. It is recommended to import these public entry-points via the +:mod:`robot.api` package like in the example below. -The model objects in the :mod:`robot.result.model` module can also be considered -to be part of the public API, because they can be found inside the :class:`~.Result` -object. They can also be inspected and modified as part of the normal test -execution by `pre-Rebot modifiers`__ and `listeners`__. - -It is highly recommended to import the public entry-points via the -:mod:`robot.api` package like in the example below. In those rare cases -where the aforementioned model objects are needed directly, they can be -imported from this package. - -This package is considered stable. +The model objects defined in the :mod:`robot.result.model` module are also +part of the public API. They are used inside the :class:`~.Result` object, +and they can also be inspected and modified as part of the normal test +execution by using `pre-Rebot modifiers`__ and `listeners`__. These model +objects are not exposed via :mod:`robot.api`, but they can be imported +from :mod:`robot.result` if needed. Example ------- @@ -42,7 +38,7 @@ """ from .executionresult import Result -from .model import (For, ForIteration, While, WhileIteration, If, IfBranch, Keyword, - Message, TestCase, TestSuite, Try, TryBranch, Return, Continue, Break) +from .model import (Break, Continue, For, ForIteration, If, IfBranch, Keyword, Message, + Return, TestCase, TestSuite, Try, TryBranch, While, WhileIteration) from .resultbuilder import ExecutionResult, ExecutionResultBuilder from .visitor import ResultVisitor diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 7c8fcf7870c..2a504db07a9 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -28,8 +28,7 @@ modify these objects using the :mod:`visitor interface <robot.model.visitor>`. If classes defined here are needed, for example, as type hints, they can -be imported directly from this :mod:`robot.running.model` module. This -module is considered stable. +be imported via the :mod:`robot.running` module. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#programmatic-modification-of-results diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index 00d39be8881..64e12f50bb8 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -15,23 +15,28 @@ """Implements the core test execution logic. -The main public entry points of this package are of the following: +The public API of this module consists of the following objects: * :class:`~robot.running.model.TestSuite` for creating an executable test suite structure programmatically. +* :class:`~robot.running.builder.builders.TestSuiteBuilder` for creating + executable test suites based on data on a file system. + Instead of using this class directly, it is possible to use the + :meth:`TestSuite.from_file_system <robot.running.model.TestSuite.from_file_system>` + classmethod that uses it internally. + * Classes used by :class:`~robot.running.model.TestSuite`, such as :class:`~robot.running.model.TestCase` and :class:`~robot.running.model.Keyword`, that are defined in the :mod:`robot.running.model` module. -* :class:`~robot.running.builder.builders.TestSuiteBuilder` for creating - executable test suites based on existing test case files and directories. The - :meth:`TestSuite.from_file_system <robot.running.model.TestSuite.from_file_system>` - classmethod can be used for that purpose as well. +:class:`~robot.running.model.TestSuite` and +:class:`~robot.running.builder.builders.TestSuiteBuilder` can be imported via +the :mod:`robot.api` package. If other classes are needed directly, they can be +imported via :mod:`robot.running`. -The classes mentioned above can be imported via the :mod:`robot.api` package -as the examples below demonstrate. If other model objects are needed, they -can be imported from the :mod:`robot.running.model` module. +.. note:: Prior to Robot Framework 6.1, only some classes in + :mod:`robot.running.model` were exposed via :mod:`robot.running`. Examples -------- @@ -48,15 +53,13 @@ [Setup] Set Environment Variable SKYNET activated Environment Variable Should Be Set SKYNET -We can easily parse and create an executable test suite based on the above file -using the :class:`~robot.running.builder.TestSuiteBuilder` class as follows:: +We can easily create an executable test suite based on the above file:: - from robot.api import TestSuiteBuilder + from robot.api import TestSuite - suite = TestSuiteBuilder().build('path/to/activate_skynet.robot') + suite = TestSuite.from_file_system('path/to/activate_skynet.robot') -That was easy. Let's next generate the same test suite from scratch -using the :class:`~robot.running.model.TestSuite` class:: +That was easy. Let's next generate the same test suite from scratch:: from robot.api import TestSuite @@ -99,10 +102,11 @@ """ from .arguments import ArgInfo, ArgumentSpec, TypeConverter -from .builder import TestSuiteBuilder, ResourceFileBuilder +from .builder import ResourceFileBuilder, TestSuiteBuilder from .context import EXECUTION_CONTEXTS -from .model import Keyword, TestCase, TestSuite +from .model import (Break, Continue, For, If, IfBranch, Keyword, Return, TestCase, + TestSuite, Try, TryBranch, While) +from .runkwregister import RUN_KW_REGISTER from .testlibraries import TestLibrary from .usererrorhandler import UserErrorHandler from .userkeyword import UserLibrary -from .runkwregister import RUN_KW_REGISTER diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 0845a252e63..a2ea42e2958 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -15,20 +15,20 @@ """Module implementing test execution related model objects. -When tests are executed normally, these objects are created based on the test -data on the file system by :class:`~robot.running.builder.builders.TestSuiteBuilder`, -but external tools can also create an executable test suite model structure directly. -Regardless the approach to create it, the model is executed by calling -:meth:`~TestSuite.run` method of the root test suite. See the -:mod:`robot.running` package level documentation for more information and -examples. - -The most important classes defined in this module are :class:`TestSuite`, -:class:`TestCase` and :class:`Keyword`. When tests are executed, these objects -can be inspected and modified by `pre-run modifiers`__ and `listeners`__. -These three classes are exposed via the :mod:`robot.api` package. If other -classes are needed, they can be imported directly from this -:mod:`robot.running.model` module. This module is considered stable. +When tests are executed by Robot Framework, a :class:`TestSuite` structure using +classes defined in this module is created by +:class:`~robot.running.builder.builders.TestSuiteBuilder` +based on data on a file system. In addition to that, external tools can +create executable suite structures programmatically. + +Regardless the approach to construct it, a :class:`TestSuite` object is executed +by calling its :meth:`~TestSuite.run` method as shown in the example in +the :mod:`robot.running` package level documentation. When a suite is run, +test, keywords, and other objects it contains can be inspected and modified +by using `pre-run modifiers`__ and `listeners`__. + +The :class:`TestSuite` class is exposed via the :mod:`robot.api` package. If other +classes are needed, they can be imported from :mod:`robot.running`. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#programmatic-modification-of-results __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface From b272862ce6a177cd7fdd5d1622f48a093721630d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 10:55:16 +0200 Subject: [PATCH 0389/1592] Better workaround to support nullable types. This workaround for Pydantic not supporting nullable types works also with objects, not only with base types. Workaround created by @PrettyWood (thanks for sharing!) and got from https://github.com/pydantic/pydantic/issues/1270#issuecomment-729555558 --- doc/schema/libdoc_json_schema.py | 39 ++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/doc/schema/libdoc_json_schema.py b/doc/schema/libdoc_json_schema.py index 5b4bffb5e50..36f2d06fc96 100755 --- a/doc/schema/libdoc_json_schema.py +++ b/doc/schema/libdoc_json_schema.py @@ -13,7 +13,30 @@ from pathlib import Path from typing import List, Optional, Union -from pydantic import BaseModel, Field, PositiveInt +from pydantic import BaseModel as PydanticBaseModel, Field, PositiveInt + + +class BaseModel(PydanticBaseModel): + + # Workaround for Pydantic not supporting nullable types. + # https://github.com/pydantic/pydantic/issues/1270#issuecomment-729555558 + class Config: + @staticmethod + def schema_extra(schema, model): + for prop, value in schema.get('properties', {}).items(): + # retrieve right field from alias or name + field = [x for x in model.__fields__.values() if x.alias == prop][0] + if field.allow_none: + # only one type e.g. {'type': 'integer'} + if 'type' in value: + value['anyOf'] = [{'type': value.pop('type')}] + # only one $ref e.g. from other model + elif '$ref' in value: + if issubclass(field.type_, PydanticBaseModel): + # add 'title' in schema to have the exact same behaviour as the rest + value['title'] = field.type_.__config__.title or field.type_.__name__ + value['anyOf'] = [{'$ref': value.pop('$ref')}] + value['anyOf'].append({'type': 'null'}) class SpecVersion(int, Enum): @@ -64,13 +87,6 @@ class Argument(BaseModel): required: bool repr: str - # Workaround for Pydantic not supporting nullable types. - # https://github.com/samuelcolvin/pydantic/issues/1270 - class Config: - @staticmethod - def schema_extra(schema, model): - schema['properties']['defaultValue']['type'] = ['string', 'null'] - class Keyword(BaseModel): name: str @@ -102,13 +118,6 @@ class TypedDictItem(BaseModel): type: str required: Union[bool, None] # This is overridden below. - # Workaround for Pydantic not supporting nullable types. - # https://github.com/samuelcolvin/pydantic/issues/1270 - class Config: - @staticmethod - def schema_extra(schema, model): - schema['properties']['required']['type'] = ['boolean', 'null'] - class TypeDoc(BaseModel): type: TypeDocType From 398bf40e070f341ef7972a612695ad46f6bc74c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 11:02:05 +0200 Subject: [PATCH 0390/1592] Remove unused code. --- atest/robot/libdoc/LibDocLib.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/atest/robot/libdoc/LibDocLib.py b/atest/robot/libdoc/LibDocLib.py index dc439e74862..e36ea13e94f 100644 --- a/atest/robot/libdoc/LibDocLib.py +++ b/atest/robot/libdoc/LibDocLib.py @@ -63,14 +63,6 @@ def validate_json_spec(self, path): with open(path) as f: self.json_schema.validate(json.load(f)) - def relative_source(self, path, start): - if not exists(path): - return path - try: - return relpath(path, start) - except ValueError: - return normpath(path) - def get_repr_from_arg_model(self, model): return str(ArgInfo(kind=model['kind'], name=model['name'], From 6e6f3a595d800ff43e792c4a7c582e7bf6abc131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 11:20:34 +0200 Subject: [PATCH 0391/1592] Libdoc: Properly support parametrized types like list[int]. This turned out to be somewhat bigger task than anticipated, but now everything seems to work fine with specs and prototype implementation in HTML outputs looks ok as well. #4538 Todo: - Finish HTML output changes. - Add tests for backwards compatibility at least with RF 5 and 6. --- atest/robot/libdoc/LibDocLib.py | 4 +- atest/robot/libdoc/datatypes_json-xml.robot | 41 ++-- atest/robot/libdoc/datatypes_py-json.robot | 6 +- atest/robot/libdoc/datatypes_py-xml.robot | 17 +- atest/robot/libdoc/invalid_usage.robot | 4 +- atest/robot/libdoc/libdoc_resource.robot | 57 +++-- atest/robot/libdoc/type_annotations.robot | 7 +- atest/testdata/libdoc/Annotations.py | 12 +- atest/testdata/libdoc/DataTypesLibrary.json | 238 ++++++++++++++++++-- atest/testdata/libdoc/DataTypesLibrary.py | 2 +- atest/testdata/libdoc/DynamicLibrary.json | 61 ++++- doc/schema/libdoc.json | 114 ++++++++-- doc/schema/libdoc.xsd | 19 +- doc/schema/libdoc_json_schema.py | 14 +- src/robot/libdocpkg/jsonbuilder.py | 23 +- src/robot/libdocpkg/model.py | 18 +- src/robot/libdocpkg/robotbuilder.py | 18 +- src/robot/libdocpkg/xmlbuilder.py | 34 ++- src/robot/libdocpkg/xmlwriter.py | 27 ++- src/robot/running/__init__.py | 2 +- src/robot/running/arguments/__init__.py | 2 +- src/robot/running/arguments/argumentspec.py | 96 ++++++-- src/robot/utils/__init__.py | 6 +- src/robot/utils/robottypes.py | 26 ++- 24 files changed, 687 insertions(+), 161 deletions(-) diff --git a/atest/robot/libdoc/LibDocLib.py b/atest/robot/libdoc/LibDocLib.py index e36ea13e94f..cc3c3d61004 100644 --- a/atest/robot/libdoc/LibDocLib.py +++ b/atest/robot/libdoc/LibDocLib.py @@ -66,11 +66,11 @@ def validate_json_spec(self, path): def get_repr_from_arg_model(self, model): return str(ArgInfo(kind=model['kind'], name=model['name'], - types=tuple(model['type']), + type=model['type'] or ArgInfo.NOTSET, default=model['default'] or ArgInfo.NOTSET)) def get_repr_from_json_arg_model(self, model): return str(ArgInfo(kind=model['kind'], name=model['name'], - types=tuple(model['types']), + type=model['type'] or ArgInfo.NOTSET, default=model['defaultValue'] or ArgInfo.NOTSET)) diff --git a/atest/robot/libdoc/datatypes_json-xml.robot b/atest/robot/libdoc/datatypes_json-xml.robot index 7b9e5f37e49..c83c3c9de68 100644 --- a/atest/robot/libdoc/datatypes_json-xml.robot +++ b/atest/robot/libdoc/datatypes_json-xml.robot @@ -38,35 +38,44 @@ Custom ... <p>Class doc is used when converter method has no doc.</p> Accepted types - Accepted Types Should Be 1 Standard boolean + Accepted Types Should Be 0 Standard Any + ... Any + Accepted Types Should Be 2 Standard boolean ... string integer float None - Accepted Types Should Be 2 Custom CustomType + Accepted Types Should Be 3 Custom CustomType ... string integer - Accepted Types Should Be 3 Custom CustomType2 - Accepted Types Should Be 6 TypedDict GeoLocation + Accepted Types Should Be 4 Custom CustomType2 + Accepted Types Should Be 7 TypedDict GeoLocation ... string - Accepted Types Should Be 0 Enum AssertionOperator + Accepted Types Should Be 1 Enum AssertionOperator ... string - Accepted Types Should Be 10 Enum Small + Accepted Types Should Be 11 Enum Small ... string integer Usages - Usages Should Be 1 Standard boolean + Usages Should Be 2 Standard boolean ... Funny Unions - Usages Should Be 2 Custom CustomType + Usages Should Be 3 Custom CustomType ... Custom - Usages Should be 6 TypedDict GeoLocation + Usages Should be 7 TypedDict GeoLocation ... Funny Unions Set Location - Usages Should Be 10 Enum Small + Usages Should Be 11 Enum Small ... __init__ Funny Unions + Usages Should Be 12 Standard string + ... Assert Something Funny Unions Typing Types Typedoc links in arguments - Typedoc links should be 0 1 AssertionOperator None + Typedoc links should be 0 1 Union: + ... AssertionOperator None Typedoc links should be 0 2 str:string Typedoc links should be 1 0 CustomType Typedoc links should be 1 1 CustomType2 - Typedoc links should be 2 0 bool:boolean int:integer float str:string AssertionOperator Small GeoLocation None - Typedoc links should be 4 0 List[str]:list - Typedoc links should be 4 1 Dict[str, int]:dictionary - Typedoc links should be 4 2 Any: - Typedoc links should be 4 3 List[Any]:list + Typedoc links should be 2 0 Union: + ... bool:boolean int:integer float str:string AssertionOperator Small GeoLocation None + Typedoc links should be 4 0 List:list + ... str:string + Typedoc links should be 4 1 Dict:dictionary + ... str:string int:integer + Typedoc links should be 4 2 Any + Typedoc links should be 4 3 List:list + ... Any diff --git a/atest/robot/libdoc/datatypes_py-json.robot b/atest/robot/libdoc/datatypes_py-json.robot index 68db343fc6c..f9443b50d9c 100644 --- a/atest/robot/libdoc/datatypes_py-json.robot +++ b/atest/robot/libdoc/datatypes_py-json.robot @@ -149,10 +149,10 @@ Typedoc links in arguments ${MODEL}[keywords][1][args][2][typedocs] {'CustomType': 'CustomType'} ${MODEL}[keywords][1][args][3][typedocs] {} ${MODEL}[keywords][2][args][0][typedocs] {'bool': 'boolean', 'int': 'integer', 'float': 'float', 'str': 'string', 'AssertionOperator': 'AssertionOperator', 'Small': 'Small', 'GeoLocation': 'GeoLocation', 'None': 'None'} - ${MODEL}[keywords][4][args][0][typedocs] {'List[str]': 'list'} - ${MODEL}[keywords][4][args][1][typedocs] {'Dict[str, int]': 'dictionary'} + ${MODEL}[keywords][4][args][0][typedocs] {'List': 'list', 'str': 'string'} + ${MODEL}[keywords][4][args][1][typedocs] {'Dict': 'dictionary', 'str': 'string', 'int': 'integer'} ${MODEL}[keywords][4][args][2][typedocs] {'Any': 'Any'} - ${MODEL}[keywords][4][args][3][typedocs] {'List[Any]': 'list'} + ${MODEL}[keywords][4][args][3][typedocs] {'List': 'list', 'Any': 'Any'} *** Keywords *** Verify Argument Models diff --git a/atest/robot/libdoc/datatypes_py-xml.robot b/atest/robot/libdoc/datatypes_py-xml.robot index 2a48240a3ca..5ace2e3f96e 100644 --- a/atest/robot/libdoc/datatypes_py-xml.robot +++ b/atest/robot/libdoc/datatypes_py-xml.robot @@ -96,14 +96,19 @@ Usages ... __init__ Funny Unions Typedoc links in arguments - Typedoc links should be 0 1 AssertionOperator None + Typedoc links should be 0 1 Union: + ... AssertionOperator None Typedoc links should be 0 2 str:string Typedoc links should be 1 0 CustomType Typedoc links should be 1 1 CustomType2 Typedoc links should be 1 2 CustomType Typedoc links should be 1 3 Unknown: - Typedoc links should be 2 0 bool:boolean int:integer float str:string AssertionOperator Small GeoLocation None - Typedoc links should be 4 0 List[str]:list - Typedoc links should be 4 1 Dict[str, int]:dictionary - Typedoc links should be 4 2 Any:Any - Typedoc links should be 4 3 List[Any]:list + Typedoc links should be 2 0 Union: + ... bool:boolean int:integer float str:string AssertionOperator Small GeoLocation None + Typedoc links should be 4 0 List:list + ... str:string + Typedoc links should be 4 1 Dict:dictionary + ... str:string int:integer + Typedoc links should be 4 2 Any + Typedoc links should be 4 3 List:list + ... Any diff --git a/atest/robot/libdoc/invalid_usage.robot b/atest/robot/libdoc/invalid_usage.robot index 062099b59fa..252fbc18197 100644 --- a/atest/robot/libdoc/invalid_usage.robot +++ b/atest/robot/libdoc/invalid_usage.robot @@ -79,8 +79,8 @@ Invalid output file ... Remove Directory ${OUT HTML} AND ... Remove Directory ${OUT XML} -invalid Spec File version - ${TESTDATADIR}/OldSpec.xml ${OUT XML} Invalid spec file version 'None'. Supported versions are 3 and 4. +Invalid Spec File version + ${TESTDATADIR}/OldSpec.xml ${OUT XML} Invalid spec file version 'None'. Supported versions are 3, 4 and 5. *** Keywords *** Run libdoc and verify error diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index 83e9572955a..1da69cc2571 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -102,7 +102,7 @@ Generated Should Be Element Attribute Should Be ${LIBDOC} generated ${generated} Spec version should be correct - Element Attribute Should Be ${LIBDOC} specversion 4 + Element Attribute Should Be ${LIBDOC} specversion 5 Should Have No Init ${inits} = Get Elements ${LIBDOC} xpath=inits/init @@ -146,7 +146,14 @@ Verify Arguments Structure ${required}= Get Element Attribute ${arg_elem} required ${repr}= Get Element Attribute ${arg_elem} repr ${name}= Get Element Optional Text ${arg_elem} name - ${type}= Get Elements Texts ${arg_elem} type + ${types}= Get Elements ${arg_elem} type + IF not $types + ${type}= Set Variable ${EMPTY} + ELSE IF len($types) == 1 + ${type}= Get Type ${types}[0] + ELSE + Fail Cannot have more than one <type> element + END ${default}= Get Element Optional Text ${arg_elem} default ${arg_model}= Create Dictionary ... kind=${kind} @@ -159,6 +166,24 @@ Verify Arguments Structure END Should Be Equal ${{len($arg_elems)}} ${{len($expected)}} +Get Type + [Arguments] ${elem} + ${children} = Get Elements ${elem} type + ${nested} = Create List + FOR ${child} IN @{children} + ${type} = Get Type ${child} + Append To List ${nested} ${type} + END + ${type} = Get Element Attribute ${elem} name + IF $elem.get('union') == 'true' + ${type} = Catenate SEPARATOR=${SPACE}|${SPACE} @{nested} + ELSE IF $nested + ${args} = Catenate SEPARATOR=,${SPACE} @{nested} + ${type} = Set Variable ${type}\[${args}] + END + Should Be Equal ${elem.text} ${type} + RETURN ${type} + Get Element Optional Text [Arguments] ${source} ${xpath} ${elems}= Get Elements ${source} ${xpath} @@ -341,15 +366,21 @@ Accepted Types Should Be END Typedoc links should be - [Arguments] ${kw} ${arg} @{typedocs} - ${types} = Get Elements ${LIBDOC} keywords/kw[${${kw} + 1}]/arguments/arg[${${arg} + 1}]/type - Length Should Be ${types} ${{len($typedocs)}} - FOR ${type} ${typedoc} IN ZIP ${types} ${typedocs} - IF ':' in $typedoc - ${typename} ${typedoc} = Split String ${typedoc} : - ELSE - ${typename} = Set Variable ${typedoc} - END - Element Text Should Be ${type} ${typename} - Element Attribute Should Be ${type} typedoc ${{$typedoc or None}} + [Arguments] ${kw} ${arg} ${typedoc} @{nested typedocs} + ${type} = Get Element ${LIBDOC} keywords/kw[${${kw} + 1}]/arguments/arg[${${arg} + 1}]/type + Typedoc link should be ${type} ${typedoc} + ${nested} = Get Elements ${type} type + Length Should Be ${nested} ${{len($nested_typedocs)}} + FOR ${type} ${typedoc} IN ZIP ${nested} ${nested typedocs} + Typedoc link should be ${type} ${typedoc} + END + +Typedoc link should be + [Arguments] ${type} ${typedoc} + IF ':' in $typedoc + ${typename} ${typedoc} = Split String ${typedoc} : + ELSE + ${typename} = Set Variable ${typedoc} END + Element Attribute Should Be ${type} name ${typename} + Element Attribute Should Be ${type} typedoc ${{$typedoc or None}} diff --git a/atest/robot/libdoc/type_annotations.robot b/atest/robot/libdoc/type_annotations.robot index a27100e3125..8013d6b7136 100644 --- a/atest/robot/libdoc/type_annotations.robot +++ b/atest/robot/libdoc/type_annotations.robot @@ -35,7 +35,10 @@ Union from typing Keyword Arguments Should Be 8 a: int | str | list | tuple Keyword Arguments Should Be 9 a: int | str | list | tuple | None = None +Nested + Keyword Arguments Should Be 10 a: List[int] b: List[int | float] c: Tuple[Tuple[UnknownType], Dict[str, Tuple[float]]] + Union syntax [Tags] require-py3.10 - Keyword Arguments Should Be 10 a: int | str | list | tuple - Keyword Arguments Should Be 11 a: int | str | list | tuple | None = None + Keyword Arguments Should Be 11 a: int | str | list | tuple + Keyword Arguments Should Be 12 a: int | str | list | tuple | None = None diff --git a/atest/testdata/libdoc/Annotations.py b/atest/testdata/libdoc/Annotations.py index 9798ee6c236..7a7cad5b3a4 100644 --- a/atest/testdata/libdoc/Annotations.py +++ b/atest/testdata/libdoc/Annotations.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, List, Union +from typing import Any, Dict, List, Union, Tuple class UnknownType: @@ -75,13 +75,19 @@ def J_union_from_typing_with_default(a: Union[int, str, Union[list, tuple]] = No pass +def K_nested(a: List[int], + b: List[Union[int, float]], + c: Tuple[Tuple[UnknownType], Dict[str, Tuple[float]]]): + pass + + try: exec(''' -def K_union_syntax(a: int | str | list | tuple): +def L_union_syntax(a: int | str | list | tuple): pass -def K_union_syntax_with_default(a: int | str | list | tuple = None): +def M_union_syntax_with_default(a: int | str | list | tuple = None): pass ''') except TypeError: # Python < 3.10 diff --git a/atest/testdata/libdoc/DataTypesLibrary.json b/atest/testdata/libdoc/DataTypesLibrary.json index d48c97fa3f5..095ec9d15f8 100644 --- a/atest/testdata/libdoc/DataTypesLibrary.json +++ b/atest/testdata/libdoc/DataTypesLibrary.json @@ -1,14 +1,14 @@ { - "specversion": 1, + "specversion": 2, "name": "DataTypesLibrary", - "doc": "<p>This Library has Data Types.</p>\n<p>It has some in <code>__init__</code> and others in the <a href=\"#Keywords\" class=\"name\">Keywords</a>.</p>\n<p>The DataTypes are the following that should be linked. <span class=\"name\">HttpCredentials</span> , <a href=\"#GeoLocation\" class=\"name\">GeoLocation</a> , <a href=\"#Small\" class=\"name\">Small</a> and <a href=\"#AssertionOperator\" class=\"name\">AssertionOperator</a>.</p>", + "doc": "<p>This Library has Data Types.</p>\n<p>It has some in <code>__init__</code> and others in the <a href=\"#Keywords\" class=\"name\">Keywords</a>.</p>\n<p>The DataTypes are the following that should be linked. <span class=\"name\">HttpCredentials</span> , <a href=\"#type-GeoLocation\" class=\"name\">GeoLocation</a> , <a href=\"#type-Small\" class=\"name\">Small</a> and <a href=\"#type-AssertionOperator\" class=\"name\">AssertionOperator</a>.</p>", "version": "", - "generated": "2022-02-10 21:21:53", + "generated": "2023-02-27T14:41:19+00:00", "type": "LIBRARY", "scope": "TEST", "docFormat": "HTML", "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DataTypesLibrary.py", - "lineno": 84, + "lineno": 88, "tags": [], "inits": [ { @@ -16,6 +16,12 @@ "args": [ { "name": "credentials", + "type": { + "name": "Small", + "typedoc": "Small", + "nested": [], + "union": false + }, "types": [ "Small" ], @@ -28,11 +34,11 @@ "repr": "credentials: Small = one" } ], - "doc": "<p>This is the init Docs.</p>\n<p>It links to <a href=\"#Set%20Location\" class=\"name\">Set Location</a> keyword and to <a href=\"#GeoLocation\" class=\"name\">GeoLocation</a> data type.</p>", + "doc": "<p>This is the init Docs.</p>\n<p>It links to <a href=\"#Set%20Location\" class=\"name\">Set Location</a> keyword and to <a href=\"#type-GeoLocation\" class=\"name\">GeoLocation</a> data type.</p>", "shortdoc": "This is the init Docs.", "tags": [], "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DataTypesLibrary.py", - "lineno": 93 + "lineno": 97 } ], "keywords": [ @@ -41,6 +47,7 @@ "args": [ { "name": "value", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -50,6 +57,25 @@ }, { "name": "operator", + "type": { + "name": "Union", + "typedoc": null, + "nested": [ + { + "name": "AssertionOperator", + "typedoc": "AssertionOperator", + "nested": [], + "union": false + }, + { + "name": "None", + "typedoc": "None", + "nested": [], + "union": false + } + ], + "union": true + }, "types": [ "AssertionOperator", "None" @@ -65,6 +91,12 @@ }, { "name": "exp", + "type": { + "name": "str", + "typedoc": "string", + "nested": [], + "union": false + }, "types": [ "str" ], @@ -77,17 +109,23 @@ "repr": "exp: str = something?" } ], - "doc": "<p>This links to <a href=\"#AssertionOperator\" class=\"name\">AssertionOperator</a> .</p>\n<p>This is the next Line that links to 'Set Location` .</p>", + "doc": "<p>This links to <a href=\"#type-AssertionOperator\" class=\"name\">AssertionOperator</a> .</p>\n<p>This is the next Line that links to <a href=\"#Set%20Location\" class=\"name\">Set Location</a> .</p>", "shortdoc": "This links to `AssertionOperator` .", "tags": [], "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DataTypesLibrary.py", - "lineno": 103 + "lineno": 107 }, { "name": "Custom", "args": [ { "name": "arg", + "type": { + "name": "CustomType", + "typedoc": "CustomType", + "nested": [], + "union": false + }, "types": [ "CustomType" ], @@ -101,6 +139,12 @@ }, { "name": "arg2", + "type": { + "name": "CustomType2", + "typedoc": "CustomType2", + "nested": [], + "union": false + }, "types": [ "CustomType2" ], @@ -114,6 +158,12 @@ }, { "name": "arg3", + "type": { + "name": "CustomType", + "typedoc": "CustomType", + "nested": [], + "union": false + }, "types": [ "CustomType" ], @@ -124,19 +174,91 @@ "kind": "POSITIONAL_OR_NAMED", "required": true, "repr": "arg3: CustomType" + }, + { + "name": "arg4", + "type": { + "name": "Unknown", + "typedoc": null, + "nested": [], + "union": false + }, + "types": [ + "Unknown" + ], + "typedocs": {}, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "arg4: Unknown" } ], "doc": "", "shortdoc": "", "tags": [], "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DataTypesLibrary.py", - "lineno": 127 + "lineno": 131 }, { "name": "Funny Unions", "args": [ { "name": "funny", + "type": { + "name": "Union", + "typedoc": null, + "nested": [ + { + "name": "bool", + "typedoc": "boolean", + "nested": [], + "union": false + }, + { + "name": "int", + "typedoc": "integer", + "nested": [], + "union": false + }, + { + "name": "float", + "typedoc": "float", + "nested": [], + "union": false + }, + { + "name": "str", + "typedoc": "string", + "nested": [], + "union": false + }, + { + "name": "AssertionOperator", + "typedoc": "AssertionOperator", + "nested": [], + "union": false + }, + { + "name": "Small", + "typedoc": "Small", + "nested": [], + "union": false + }, + { + "name": "GeoLocation", + "typedoc": "GeoLocation", + "nested": [], + "union": false + }, + { + "name": "None", + "typedoc": "None", + "nested": [], + "union": false + } + ], + "union": true + }, "types": [ "bool", "int", @@ -167,13 +289,19 @@ "shortdoc": "", "tags": [], "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DataTypesLibrary.py", - "lineno": 110 + "lineno": 114 }, { "name": "Set Location", "args": [ { "name": "location", + "type": { + "name": "GeoLocation", + "typedoc": "GeoLocation", + "nested": [], + "union": false + }, "types": [ "GeoLocation" ], @@ -190,18 +318,32 @@ "shortdoc": "", "tags": [], "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DataTypesLibrary.py", - "lineno": 100 + "lineno": 104 }, { "name": "Typing Types", "args": [ { "name": "list_of_str", + "type": { + "name": "List", + "typedoc": "list", + "nested": [ + { + "name": "str", + "typedoc": "string", + "nested": [], + "union": false + } + ], + "union": false + }, "types": [ "List[str]" ], "typedocs": { - "List[str]": "list" + "List": "list", + "str": "string" }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", @@ -210,11 +352,32 @@ }, { "name": "dict_str_int", + "type": { + "name": "Dict", + "typedoc": "dictionary", + "nested": [ + { + "name": "str", + "typedoc": "string", + "nested": [], + "union": false + }, + { + "name": "int", + "typedoc": "integer", + "nested": [], + "union": false + } + ], + "union": false + }, "types": [ "Dict[str, int]" ], "typedocs": { - "Dict[str, int]": "dictionary" + "Dict": "dictionary", + "str": "string", + "int": "integer" }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", @@ -223,10 +386,18 @@ }, { "name": "whatever", + "type": { + "name": "Any", + "typedoc": "Any", + "nested": [], + "union": false + }, "types": [ "Any" ], - "typedocs": {}, + "typedocs": { + "Any": "Any" + }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -234,11 +405,25 @@ }, { "name": "args", + "type": { + "name": "List", + "typedoc": "list", + "nested": [ + { + "name": "Any", + "typedoc": "Any", + "nested": [], + "union": false + } + ], + "union": false + }, "types": [ "List[Any]" ], "typedocs": { - "List[Any]": "list" + "List": "list", + "Any": "Any" }, "defaultValue": null, "kind": "VAR_POSITIONAL", @@ -250,7 +435,7 @@ "shortdoc": "", "tags": [], "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DataTypesLibrary.py", - "lineno": 124 + "lineno": 128 } ], "dataTypes": { @@ -336,6 +521,17 @@ ] }, "typedocs": [ + { + "type": "Standard", + "name": "Any", + "doc": "<p>Any value is accepted. No conversion is done.</p>", + "usages": [ + "Typing Types" + ], + "accepts": [ + "Any" + ] + }, { "type": "Enum", "name": "AssertionOperator", @@ -412,7 +608,7 @@ { "type": "Standard", "name": "dictionary", - "doc": "<p>Strings must be Python <a href=\"https://docs.python.org/library/stdtypes.html#dict\">dictionary</a> literals. They are converted to actual dictionaries using the <a href=\"https://docs.python.org/library/ast.html#ast.literal_eval\">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including dictionaries and other containers.</p>\n<p>Examples: <code>{'a': 1, 'b': 2}</code>, <code>{'key': 1, 'nested': {'key': 2}}</code></p>", + "doc": "<p>Strings must be Python <a href=\"https://docs.python.org/library/stdtypes.html#dict\">dictionary</a> literals. They are converted to actual dictionaries using the <a href=\"https://docs.python.org/library/ast.html#ast.literal_eval\">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including dictionaries and other containers.</p>\n<p>If the type has nested types like <code>dict[str, int]</code>, items are converted to those types automatically. This in new in Robot Framework 6.0.</p>\n<p>Examples: <code>{'a': 1, 'b': 2}</code>, <code>{'key': 1, 'nested': {'key': 2}}</code></p>", "usages": [ "Typing Types" ], @@ -467,7 +663,8 @@ "name": "integer", "doc": "<p>Conversion is done using Python's <a href=\"https://docs.python.org/library/functions.html#int\">int</a> built-in function. Floating point numbers are accepted only if they can be represented as integers exactly. For example, <code>1.0</code> is accepted and <code>1.1</code> is not.</p>\n<p>Starting from RF 4.1, it is possible to use hexadecimal, octal and binary numbers by prefixing values with <code>0x</code>, <code>0o</code> and <code>0b</code>, respectively.</p>\n<p>Starting from RF 4.1, spaces and underscores can be used as visual separators for digit grouping purposes.</p>\n<p>Examples: <code>42</code>, <code>-1</code>, <code>0b1010</code>, <code>10 000 000</code>, <code>0xBAD_C0FFEE</code></p>", "usages": [ - "Funny Unions" + "Funny Unions", + "Typing Types" ], "accepts": [ "string", @@ -477,7 +674,7 @@ { "type": "Standard", "name": "list", - "doc": "<p>Strings must be Python <a href=\"https://docs.python.org/library/stdtypes.html#list\">list</a> literals. They are converted to actual lists using the <a href=\"https://docs.python.org/library/ast.html#ast.literal_eval\">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including lists and other containers.</p>\n<p>Examples: <code>['one', 'two']</code>, <code>[('one', 1), ('two', 2)]</code></p>", + "doc": "<p>Strings must be Python <a href=\"https://docs.python.org/library/stdtypes.html#list\">list</a> literals. They are converted to actual lists using the <a href=\"https://docs.python.org/library/ast.html#ast.literal_eval\">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including lists and other containers.</p>\n<p>If the type has nested types like <code>list[int]</code>, items are converted to those types automatically. This in new in Robot Framework 6.0.</p>\n<p>Examples: <code>['one', 'two']</code>, <code>[('one', 1), ('two', 2)]</code></p>", "usages": [ "Typing Types" ], @@ -535,7 +732,8 @@ "doc": "<p>All arguments are converted to Unicode strings.</p>", "usages": [ "Assert Something", - "Funny Unions" + "Funny Unions", + "Typing Types" ], "accepts": [ "Any" diff --git a/atest/testdata/libdoc/DataTypesLibrary.py b/atest/testdata/libdoc/DataTypesLibrary.py index 7cceddcfec5..92c81537132 100644 --- a/atest/testdata/libdoc/DataTypesLibrary.py +++ b/atest/testdata/libdoc/DataTypesLibrary.py @@ -1,5 +1,5 @@ from enum import Enum, IntEnum -from typing import Optional, Union, Dict, Any, List +from typing import Any, Dict, List, Optional, Union try: from typing_extensions import TypedDict except ImportError: diff --git a/atest/testdata/libdoc/DynamicLibrary.json b/atest/testdata/libdoc/DynamicLibrary.json index ba8a52e60ed..8cd0047fdd0 100644 --- a/atest/testdata/libdoc/DynamicLibrary.json +++ b/atest/testdata/libdoc/DynamicLibrary.json @@ -1,9 +1,9 @@ { - "specversion": 1, + "specversion": 2, "name": "DynamicLibrary", "doc": "<p>Dummy documentation for <span class=\"name\">__intro__</span>.</p>", "version": "0.1", - "generated": "2022-02-10 21:21:43", + "generated": "2023-02-27T15:47:24+00:00", "type": "LIBRARY", "scope": "TEST", "docFormat": "HTML", @@ -21,6 +21,7 @@ "args": [ { "name": "arg1", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -30,6 +31,7 @@ }, { "name": "arg2", + "type": null, "types": [], "typedocs": {}, "defaultValue": "These args are shown in docs", @@ -52,8 +54,6 @@ "doc": "<p>Dummy documentation for <a href=\"#0\" class=\"name\">0</a>.</p>\n<p>Neither <a href=\"#Keyword%201\" class=\"name\">Keyword 1</a> or <a href=\"#KW%202\" class=\"name\">KW 2</a> do anything really interesting. They do, however, accept some <span class=\"name\">arguments</span>. Neither <a href=\"#Introduction\" class=\"name\">introduction</a> nor <a href=\"#Importing\" class=\"name\">importing</a> contain any more information.</p>\n<p>Examples:</p>\n<table border=\"1\">\n<tr>\n<td>Keyword 1</td>\n<td>arg</td>\n<td></td>\n</tr>\n<tr>\n<td>KW 2</td>\n<td>arg</td>\n<td>arg 2</td>\n</tr>\n<tr>\n<td>KW 2</td>\n<td>arg</td>\n<td>arg 3</td>\n</tr>\n</table>\n<hr>\n<p><a href=\"http://robotframework.org\">http://robotframework.org</a></p>", "shortdoc": "Dummy documentation for `0`.", "tags": [], - "private": true, - "deprecated": true, "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/DynamicLibrary.py", "lineno": -1 }, @@ -62,6 +62,7 @@ "args": [ { "name": "old", + "type": null, "types": [], "typedocs": {}, "defaultValue": "style", @@ -71,6 +72,7 @@ }, { "name": "new", + "type": null, "types": [], "typedocs": {}, "defaultValue": "style", @@ -80,6 +82,7 @@ }, { "name": "cool", + "type": null, "types": [], "typedocs": {}, "defaultValue": "True", @@ -117,6 +120,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -126,6 +130,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -145,6 +150,7 @@ "args": [ { "name": "arg1", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -164,6 +170,7 @@ "args": [ { "name": "", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -173,6 +180,7 @@ }, { "name": "kwo", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -182,6 +190,7 @@ }, { "name": "another", + "type": null, "types": [], "typedocs": {}, "defaultValue": "default", @@ -201,6 +210,7 @@ "args": [ { "name": "arg1", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -210,6 +220,7 @@ }, { "name": "arg2", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -229,6 +240,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -238,6 +250,7 @@ }, { "name": "a", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -247,6 +260,7 @@ }, { "name": "b", + "type": null, "types": [], "typedocs": {}, "defaultValue": "2", @@ -256,6 +270,7 @@ }, { "name": "c", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -265,6 +280,7 @@ }, { "name": "kws", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -284,6 +300,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -293,6 +310,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -312,6 +330,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -321,6 +340,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -340,6 +360,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -349,6 +370,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -368,6 +390,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -377,6 +400,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -399,6 +423,7 @@ "args": [ { "name": "arg1", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -408,6 +433,7 @@ }, { "name": "arg2", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -417,6 +443,7 @@ }, { "name": "arg3", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -426,6 +453,7 @@ }, { "name": "arg4", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -435,6 +463,7 @@ }, { "name": "arg5", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -444,6 +473,7 @@ }, { "name": "arg6", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -453,6 +483,7 @@ }, { "name": "arg7", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -462,6 +493,7 @@ }, { "name": "arg8", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -484,6 +516,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -493,6 +526,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -512,6 +546,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -521,6 +556,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -540,6 +576,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -549,6 +586,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -568,6 +606,7 @@ "args": [ { "name": "varargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -577,6 +616,7 @@ }, { "name": "kwargs", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -599,6 +639,12 @@ "args": [ { "name": "integer", + "type": { + "name": "int", + "typedoc": "integer", + "nested": [], + "union": false + }, "types": [ "int" ], @@ -612,6 +658,7 @@ }, { "name": "no type", + "type": null, "types": [], "typedocs": {}, "defaultValue": null, @@ -621,6 +668,12 @@ }, { "name": "boolean", + "type": { + "name": "bool", + "typedoc": "boolean", + "nested": [], + "union": false + }, "types": [ "bool" ], diff --git a/doc/schema/libdoc.json b/doc/schema/libdoc.json index cf16a13e23d..80f90b91b74 100644 --- a/doc/schema/libdoc.json +++ b/doc/schema/libdoc.json @@ -100,7 +100,7 @@ "title": "SpecVersion", "description": "Version of the spec.", "enum": [ - 1 + 2 ], "type": "integer" }, @@ -135,6 +135,44 @@ ], "type": "string" }, + "ArgumentType": { + "title": "ArgumentType", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "typedoc": { + "title": "Typedoc", + "description": "Map type to info in 'typedocs'.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nested": { + "title": "Nested", + "type": "array", + "items": { + "$ref": "#/definitions/ArgumentType" + } + }, + "union": { + "title": "Union", + "type": "boolean" + } + }, + "required": [ + "name", + "nested", + "union" + ] + }, "ArgumentKind": { "title": "ArgumentKind", "description": "Argument kind: positional, named, vararg, etc.", @@ -158,8 +196,20 @@ "title": "Name", "type": "string" }, + "type": { + "title": "ArgumentType", + "anyOf": [ + { + "$ref": "#/definitions/ArgumentType" + }, + { + "type": "null" + } + ] + }, "types": { "title": "Types", + "description": "Deprecated. Use 'type' instead.", "type": "array", "items": { "type": "string" @@ -167,15 +217,19 @@ }, "typedocs": { "title": "Typedocs", - "description": "Maps types to type information in 'typedocs'.", + "description": "Deprecated. Use 'type' instead.", "type": "object" }, "defaultValue": { "title": "Defaultvalue", "description": "Possible default value or 'null'.", - "type": [ - "string", - "null" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ] }, "kind": { @@ -231,11 +285,25 @@ }, "private": { "title": "Private", - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, "deprecated": { "title": "Deprecated", - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, "source": { "title": "Source", @@ -300,9 +368,13 @@ }, "required": { "title": "Required", - "type": [ - "boolean", - "null" + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } ] } }, @@ -345,18 +417,32 @@ "members": { "title": "Members", "description": "Used only with Enum type.", - "type": "array", "items": { "$ref": "#/definitions/EnumMember" - } + }, + "anyOf": [ + { + "type": "array" + }, + { + "type": "null" + } + ] }, "items": { "title": "Items", "description": "Used only with TypedDict type.", - "type": "array", "items": { "$ref": "#/definitions/TypedDictItem" - } + }, + "anyOf": [ + { + "type": "array" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/doc/schema/libdoc.xsd b/doc/schema/libdoc.xsd index a7e2fd07462..860b89e6f55 100644 --- a/doc/schema/libdoc.xsd +++ b/doc/schema/libdoc.xsd @@ -174,8 +174,8 @@ </xs:simpleType> <xs:simpleType name="SpecVersion"> <xs:restriction base="xs:integer"> - <xs:minInclusive value="4" /> - <xs:maxInclusive value="4" /> + <xs:minInclusive value="5" /> + <xs:maxInclusive value="5" /> </xs:restriction> </xs:simpleType> <xs:simpleType name="LibraryScope"> @@ -188,19 +188,20 @@ <xs:complexType name="Argument"> <xs:sequence> <xs:element name="name" type="xs:string" minOccurs="0" maxOccurs="1" /> - <xs:element name="type" type="ArgumentType" minOccurs="0" maxOccurs="unbounded" /> + <xs:element name="type" type="ArgumentType" minOccurs="0" maxOccurs="1" /> <xs:element name="default" type="xs:string" minOccurs="0" maxOccurs="1" /> </xs:sequence> <xs:attribute name="kind" type="Kind" use="required" /> <xs:attribute name="required" type="xs:boolean" use="required" /> <xs:attribute name="repr" type="xs:string" use="required" /> </xs:complexType> - <xs:complexType name="ArgumentType"> - <xs:simpleContent> - <xs:extension base="xs:string"> - <xs:attribute name="typedoc" type="xs:string" /> - </xs:extension> - </xs:simpleContent> + <xs:complexType name="ArgumentType" mixed="true"> + <xs:sequence> + <xs:element name="type" type="ArgumentType" minOccurs="0" maxOccurs="unbounded" /> + </xs:sequence> + <xs:attribute name="name" type="xs:string" use="required" /> + <xs:attribute name="typedoc" type="xs:string" /> + <xs:attribute name="union" type="xs:boolean" /> </xs:complexType> <xs:simpleType name="Kind"> <xs:restriction base="xs:string"> diff --git a/doc/schema/libdoc_json_schema.py b/doc/schema/libdoc_json_schema.py index 36f2d06fc96..8bd4e38c20d 100755 --- a/doc/schema/libdoc_json_schema.py +++ b/doc/schema/libdoc_json_schema.py @@ -41,7 +41,7 @@ def schema_extra(schema, model): class SpecVersion(int, Enum): """Version of the spec.""" - VERSION = 1 + VERSION = 2 class DocumentationType(str, Enum): @@ -77,11 +77,19 @@ class ArgumentKind(str, Enum): VAR_NAMED = 'VAR_NAMED' +class ArgumentType(BaseModel): + name: str + typedoc: Union[str, None] = Field(description="Map type to info in 'typedocs'.") + nested: List['ArgumentType'] + union: bool + + class Argument(BaseModel): """Keyword argument.""" name: str - types: List[str] - typedocs: dict = Field(description="Maps types to type information in 'typedocs'.") + type: Union[ArgumentType, None] + types: List[str] = Field(description="Deprecated. Use 'type' instead.") + typedocs: dict = Field(description="Deprecated. Use 'type' instead.") defaultValue: Union[str, None] = Field(description="Possible default value or 'null'.") kind: ArgumentKind required: bool diff --git a/src/robot/libdocpkg/jsonbuilder.py b/src/robot/libdocpkg/jsonbuilder.py index 2a8657862f5..9062cd98dc9 100644 --- a/src/robot/libdocpkg/jsonbuilder.py +++ b/src/robot/libdocpkg/jsonbuilder.py @@ -83,11 +83,24 @@ def _create_arguments(self, arguments, kw: KeywordDoc): default = arg.get('defaultValue') if default is not None: spec.defaults[name] = default - arg_types = arg['types'] - if not spec.types: - spec.types = {} - spec.types[name] = tuple(arg_types) - kw.type_docs[name] = arg.get('typedocs', {}) + if arg.get('type'): + type_docs = {} + type_info = self._parse_modern_type_info(arg['type'], type_docs) + else: # Compatibility with RF < 6.1. + type_docs = arg.get('typedocs', {}) + type_info = tuple(arg['types']) + if type_info: + if not spec.types: + spec.types = {} + spec.types[name] = type_info + kw.type_docs[name] = type_docs + + def _parse_modern_type_info(self, data, type_docs): + if data.get('typedoc'): + type_docs[data['name']] = data['typedoc'] + return {'name': data['name'], + 'nested': [self._parse_modern_type_info(nested, type_docs) + for nested in data.get('nested', ())]} def _parse_type_docs(self, type_docs): for data in type_docs: diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 69c9f73344e..c3bde49f3e4 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -18,7 +18,7 @@ from itertools import chain from robot.model import Tags -from robot.running import ArgumentSpec +from robot.running import ArgInfo, ArgumentSpec, TypeInfo from robot.utils import getshortdoc, Sortable, setter from .htmlutils import DocFormatter, DocToHtml, HtmlToText @@ -113,7 +113,7 @@ def convert_docs_to_html(self): def to_dictionary(self, include_private=False, theme=None): data = { - 'specversion': 1, + 'specversion': 2, 'name': self.name, 'doc': self.doc, 'version': self.version, @@ -201,13 +201,23 @@ def to_dictionary(self): data['deprecated'] = True return data - def _arg_to_dict(self, arg): + def _arg_to_dict(self, arg: ArgInfo): + type_docs = self.type_docs.get(arg.name, {}) return { 'name': arg.name, + 'type': self._type_to_dict(arg.type, type_docs), 'types': arg.types_reprs, - 'typedocs': self.type_docs.get(arg.name, {}), + 'typedocs': type_docs, 'defaultValue': arg.default_repr, 'kind': arg.kind, 'required': arg.required, 'repr': str(arg) } + + def _type_to_dict(self, type: TypeInfo, type_docs: dict): + if not type: + return None + return {'name': type.name, + 'typedoc': type_docs.get(type.name), + 'nested': [self._type_to_dict(t, type_docs) for t in type.nested], + 'union': type.is_union} diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index 63522e20672..5cd4f4e34f5 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -18,9 +18,9 @@ import re from robot.errors import DataError -from robot.running import (ResourceFileBuilder, TestLibrary, TestSuiteBuilder, - UserLibrary, UserErrorHandler) -from robot.utils import is_string, split_tags_from_doc, type_repr, unescape +from robot.running import (ArgInfo, ResourceFileBuilder, TestLibrary, TestSuiteBuilder, + TypeInfo, UserLibrary, UserErrorHandler) +from robot.utils import is_string, split_tags_from_doc, unescape from robot.variables import search_variable from .datatypes import TypeDoc @@ -70,15 +70,21 @@ def _get_type_docs(self, keywords, custom_converters): for kw in keywords: for arg in kw.args: kw.type_docs[arg.name] = {} - for typ in arg.types: - type_doc = TypeDoc.for_type(typ, custom_converters) + for type_info in self._yield_type_info(arg.type): + type_doc = TypeDoc.for_type(type_info.type, custom_converters) if type_doc: - kw.type_docs[arg.name][type_repr(typ)] = type_doc.name + kw.type_docs[arg.name][type_info.name] = type_doc.name type_docs.setdefault(type_doc, set()).add(kw.name) for type_doc, usages in type_docs.items(): type_doc.usages = sorted(usages, key=str.lower) return set(type_docs) + def _yield_type_info(self, info: TypeInfo): + if not info.is_union: + yield info + for nested in info.nested: + yield from self._yield_type_info(nested) + class ResourceDocBuilder: type = 'RESOURCE' diff --git a/src/robot/libdocpkg/xmlbuilder.py b/src/robot/libdocpkg/xmlbuilder.py index c39d20a46ea..fd622e8819d 100644 --- a/src/robot/libdocpkg/xmlbuilder.py +++ b/src/robot/libdocpkg/xmlbuilder.py @@ -52,9 +52,9 @@ def _parse_spec(self, path): if root.tag != 'keywordspec': raise DataError(f"Invalid spec file '{path}'.") version = root.get('specversion') - if version not in ('3', '4'): + if version not in ('3', '4', '5'): raise DataError(f"Invalid spec file version '{version}'. " - f"Supported versions are 3 and 4.") + f"Supported versions are 3, 4 and 5.") return root def _create_keywords(self, spec, path, lib_source): @@ -94,15 +94,33 @@ def _create_arguments(self, elem, kw: KeywordDoc): spec.defaults[name] = default_elem.text or '' if not spec.types: spec.types = {} - types = [] type_docs = {} - for typ in arg.findall('type'): - types.append(typ.text) - if typ.get('typedoc'): - type_docs[typ.text] = typ.get('typedoc') - spec.types[name] = tuple(types) + type_elems = arg.findall('type') + if len(type_elems) == 1 and 'name' in type_elems[0].attrib: + type_info = self._parse_modern_type_info(type_elems[0], type_docs) + else: + type_info = self._parse_legacy_type_info(type_elems, type_docs) + if type_info: + spec.types[name] = type_info kw.type_docs[name] = type_docs + def _parse_modern_type_info(self, type_elem, type_docs): + name = type_elem.get('name') + if type_elem.get('typedoc'): + type_docs[name] = type_elem.get('typedoc') + nested = tuple(self._parse_modern_type_info(child, type_docs) + for child in type_elem.findall('type')) + return {'name': name, 'nested': nested} + + def _parse_legacy_type_info(self, type_elems, type_docs): + types = [] + for elem in type_elems: + name = elem.text + types.append(name) + if elem.get('typedoc'): + type_docs[name] = elem.get('typedoc') + return types + def _parse_type_docs(self, spec): for elem in spec.findall('typedocs/type'): doc = TypeDoc(elem.get('type'), elem.get('name'), elem.find('doc').text, diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index 3bdfe53bf2a..fff9a134b28 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from robot.running import TypeInfo from robot.utils import XmlWriter from .output import get_generation_time @@ -37,7 +38,7 @@ def _write_start(self, libdoc, writer): 'format': libdoc.doc_format, 'scope': libdoc.scope, 'generated': get_generation_time(), - 'specversion': '4'} + 'specversion': '5'} self._add_source_info(attrs, libdoc) writer.start('keywordspec', attrs) writer.element('version', libdoc.version) @@ -77,18 +78,27 @@ def _write_arguments(self, kw, writer): 'repr': str(arg)}) if arg.name: writer.element('name', arg.name) - type_docs = kw.type_docs[arg.name] - for type_repr in arg.types_reprs: - if type_repr in type_docs: - attrs = {'typedoc': type_docs[type_repr]} - else: - attrs = {} - writer.element('type', type_repr, attrs) + if arg.type: + self._write_type_info(arg.type, kw.type_docs[arg.name], writer) if arg.default is not arg.NOTSET: writer.element('default', arg.default_repr) writer.end('arg') writer.end('arguments') + def _write_type_info(self, type_info: TypeInfo, type_docs: dict, writer, top=True): + attrs = {'name': type_info.name} + if type_info.is_union: + attrs['union'] = 'true' + if type_info.name in type_docs: + attrs['typedoc'] = type_docs[type_info.name] + # Writing content, and omitting newlines, is backwards compatibility with + # specs created using RF < 6.1. TODO: Remove in RF 7. + writer.start('type', attrs, newline=False) + writer.content(str(type_info)) + for nested in type_info.nested: + self._write_type_info(nested, type_docs, writer, top=False) + writer.end('type', newline=top) + def _get_start_attrs(self, kw, lib_source): attrs = {'name': kw.name} if kw.private: @@ -98,6 +108,7 @@ def _get_start_attrs(self, kw, lib_source): self._add_source_info(attrs, kw, lib_source) return attrs + # Write legacy 'datatypes'. TODO: Remove in RF 7. def _write_data_types(self, types, writer): enums = sorted(t for t in types if t.type == 'Enum') typed_dicts = sorted(t for t in types if t.type == 'TypedDict') diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index 64e12f50bb8..e0580a16ecf 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -101,7 +101,7 @@ ResultWriter('skynet.xml').write_results() """ -from .arguments import ArgInfo, ArgumentSpec, TypeConverter +from .arguments import ArgInfo, ArgumentSpec, TypeConverter, TypeInfo from .builder import ResourceFileBuilder, TestSuiteBuilder from .context import EXECUTION_CONTEXTS from .model import (Break, Continue, For, If, IfBranch, Keyword, Return, TestCase, diff --git a/src/robot/running/arguments/__init__.py b/src/robot/running/arguments/__init__.py index 274595bf067..90ff7451d6b 100644 --- a/src/robot/running/arguments/__init__.py +++ b/src/robot/running/arguments/__init__.py @@ -16,7 +16,7 @@ from .argumentmapper import DefaultValue from .argumentparser import (DynamicArgumentParser, PythonArgumentParser, UserKeywordArgumentParser) -from .argumentspec import ArgumentSpec, ArgInfo +from .argumentspec import ArgInfo, ArgumentSpec, TypeInfo from .embedded import EmbeddedArguments from .customconverters import CustomArgumentConverters from .typeconverters import TypeConverter diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index 5946b44d607..bd2ba967dc0 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -15,8 +15,9 @@ import sys from enum import Enum +from typing import Union, Tuple -from robot.utils import is_union, safe_str, setter, type_repr +from robot.utils import has_args, is_union, safe_str, setter, type_repr from .argumentconverter import ArgumentConverter from .argumentmapper import ArgumentMapper @@ -115,6 +116,7 @@ def __str__(self): class ArgInfo: + """Contains argument information. Only used by Libdoc.""" NOTSET = object() POSITIONAL_ONLY = 'POSITIONAL_ONLY' POSITIONAL_ONLY_MARKER = 'POSITIONAL_ONLY_MARKER' @@ -124,22 +126,12 @@ class ArgInfo: NAMED_ONLY = 'NAMED_ONLY' VAR_NAMED = 'VAR_NAMED' - def __init__(self, kind, name='', types=NOTSET, default=NOTSET): + def __init__(self, kind, name='', type=NOTSET, default=NOTSET): self.kind = kind self.name = name - self.types = types + self.type = TypeInfo.from_type(type) self.default = default - @setter - def types(self, typ): - if not typ or typ is self.NOTSET: - return tuple() - if isinstance(typ, tuple): - return typ - if is_union(typ): - return typ.__args__ - return (typ,) - @property def required(self): if self.kind in (self.POSITIONAL_ONLY, @@ -150,7 +142,12 @@ def required(self): @property def types_reprs(self): - return [type_repr(t) for t in self.types] + """Deprecated. Use :attr:`type` instead.""" + if not self.type: + return [] + if self.type.is_union: + return [str(t) for t in self.type.nested] + return [str(self.type)] @property def default_repr(self): @@ -170,11 +167,76 @@ def __str__(self): ret = '*' + ret elif self.kind == self.VAR_NAMED: ret = '**' + ret - if self.types: - ret = '%s: %s' % (ret, ' | '.join(self.types_reprs)) + if self.type: + ret = f'{ret}: {self.type}' default_sep = ' = ' else: default_sep = '=' if self.default is not self.NOTSET: - ret = '%s%s%s' % (ret, default_sep, self.default_repr) + ret = f'{ret}{default_sep}{self.default_repr}' return ret + + +Type = Union[type, str, tuple, type(ArgInfo.NOTSET)] + + +class TypeInfo: + """Represents argument type. Only used by Libdoc. + + With unions and parametrized types, :attr:`nested` contains nested types. + """ + NOTSET = ArgInfo.NOTSET + + def __init__(self, type: Type = NOTSET, nested: Tuple['TypeInfo'] = ()): + self.type = type + self.nested = nested + + @property + def name(self) -> str: + if isinstance(self.type, str): + return self.type + return type_repr(self.type, nested=False) + + @property + def is_union(self) -> bool: + if isinstance(self.type, str): + return self.type == 'Union' + return is_union(self.type, allow_tuple=True) + + @classmethod + def from_type(cls, type: Type) -> 'TypeInfo': + if type is cls.NOTSET: + return cls() + if isinstance(type, dict): + return cls.from_dict(type) + if isinstance(type, (tuple, list)): + if not type: + return cls() + if len(type) == 1: + return cls(type[0]) + nested = tuple(cls.from_type(t) for t in type) + return cls('Union', nested) + if has_args(type): + nested = tuple(cls.from_type(t) for t in type.__args__) + return cls(type, nested) + return cls(type) + + @classmethod + def from_dict(cls, data: dict) -> 'TypeInfo': + if not data: + return cls() + nested = tuple(cls.from_dict(n) for n in data['nested']) + return cls(data['name'], nested) + + def __str__(self): + if self.is_union: + return ' | '.join(str(n) for n in self.nested) + if isinstance(self.type, str): + if self.nested: + nested = ', '.join(str(n) for n in self.nested) + return f'{self.name}[{nested}]' + return self.name + return type_repr(self.type) + + def __bool__(self): + return self.type is not self.NOTSET diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index a217483b703..e878b7cbd5e 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -62,9 +62,9 @@ get_time, get_timestamp, secs_to_timestamp, secs_to_timestr, timestamp_to_secs, timestr_to_secs, parse_time) -from .robottypes import (is_bytes, is_dict_like, is_falsy, is_integer, is_list_like, - is_number, is_pathlike, is_string, is_truthy, is_union, - type_name, type_repr, typeddict_types) +from .robottypes import (has_args, is_bytes, is_dict_like, is_falsy, is_integer, + is_list_like, is_number, is_pathlike, is_string, is_truthy, + is_union, type_name, type_repr, typeddict_types) from .setter import setter, SetterAwareType from .sortable import Sortable from .text import (cut_assign_value, cut_long_message, format_assign_message, diff --git a/src/robot/utils/robottypes.py b/src/robot/utils/robottypes.py index 99f3bb9b4b1..e82c13ddbe4 100644 --- a/src/robot/utils/robottypes.py +++ b/src/robot/utils/robottypes.py @@ -108,7 +108,7 @@ def type_name(item, capitalize=False): return name.capitalize() if capitalize and name.islower() else name -def type_repr(typ): +def type_repr(typ, nested=True): """Return string representation for types. Aims to look as much as the source code as possible. For example, 'List[Any]' @@ -121,9 +121,9 @@ def type_repr(typ): if typ is Any: # Needed with Python 3.6, with newer `Any._name` exists. return 'Any' if is_union(typ): - return ' | '.join(type_repr(a) for a in typ.__args__) + return ' | '.join(type_repr(a) for a in typ.__args__) if nested else 'Union' name = _get_type_name(typ) - if _has_args(typ): + if nested and has_args(typ): args = ', '.join(type_repr(a) for a in typ.__args__) return f'{name}[{args}]' return name @@ -137,13 +137,19 @@ def _get_type_name(typ): return str(typ) -def _has_args(typ): - args = getattr(typ, '__args__', ()) - # __args__ contains TypeVars when accessed directly from typing.List and other - # such types withPython 3.7-3.8. With Python 3.6 __args__ is None in that case - # and with Python 3.9+ it doesn't exist at all. When using like List[int].__args__ - # everything works the same way regardless the version. - return args and not all(isinstance(t, TypeVar) for t in args) +def has_args(type): + """Helper to check has type valid ``__args__``. + + ``__args__`` contains TypeVars when accessed directly from ``typing.List`` and + other such types with Python 3.7-3.8. With Python 3.6 ``__args__`` is None + in that case and with Python 3.9+ it doesn't exist at all. When using like + ``List[int].__args__``, everything works the same way regardless the version. + + This helper can be removed in favor of using ``hasattr(type, '__args__')`` + when we support only Python 3.9 and newer. + """ + args = getattr(type, '__args__', None) + return args and not all(isinstance(a, TypeVar) for a in args) def is_truthy(item): From 843e3eb6f7a003870a77b48ec6ac7ffb1471dc3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 14:31:39 +0200 Subject: [PATCH 0392/1592] Libdoc backwards compatibility tests. Especially validate that type info is handled properly. How it's represented has changed since RF 4, latest due to #4538. --- .../libdoc/backwards_compatibility.robot | 120 ++++++ .../libdoc/BackwardsCompatibility-4.0.json | 209 ++++++++++ .../libdoc/BackwardsCompatibility-4.0.xml | 111 ++++++ .../libdoc/BackwardsCompatibility-5.0.json | 310 +++++++++++++++ .../libdoc/BackwardsCompatibility-5.0.xml | 185 +++++++++ .../libdoc/BackwardsCompatibility-6.1.json | 359 ++++++++++++++++++ .../libdoc/BackwardsCompatibility-6.1.xml | 184 +++++++++ .../testdata/libdoc/BackwardsCompatibility.py | 48 +++ 8 files changed, 1526 insertions(+) create mode 100644 atest/robot/libdoc/backwards_compatibility.robot create mode 100644 atest/testdata/libdoc/BackwardsCompatibility-4.0.json create mode 100644 atest/testdata/libdoc/BackwardsCompatibility-4.0.xml create mode 100644 atest/testdata/libdoc/BackwardsCompatibility-5.0.json create mode 100644 atest/testdata/libdoc/BackwardsCompatibility-5.0.xml create mode 100644 atest/testdata/libdoc/BackwardsCompatibility-6.1.json create mode 100644 atest/testdata/libdoc/BackwardsCompatibility-6.1.xml create mode 100644 atest/testdata/libdoc/BackwardsCompatibility.py diff --git a/atest/robot/libdoc/backwards_compatibility.robot b/atest/robot/libdoc/backwards_compatibility.robot new file mode 100644 index 00000000000..40d822a1c0f --- /dev/null +++ b/atest/robot/libdoc/backwards_compatibility.robot @@ -0,0 +1,120 @@ +*** Settings *** +Documentation Test that Libdoc can read old XML and JSON spec files. +Test Template Generate and validate +Resource libdoc_resource.robot + +*** Variables *** +${BASE} ${TESTDATADIR}/BackwardsCompatibility + +*** Test Cases *** +Latest + ${BASE}.py + +RF 6.1 XML + ${BASE}-6.1.xml + +RF 6.1 JSON + ${BASE}-6.1.json + +RF 5.0 XML + ${BASE}-5.0.xml + +RF 5.0 JSON + ${BASE}-5.0.json + +RF 4.0 XML + ${BASE}-4.0.xml legacy=True + +RF 4.0 JSON + ${BASE}-4.0.json legacy=True + +*** Keywords *** +Generate and validate + [Arguments] ${source} ${legacy}=False + # JSON source files must be generated using RAW format as well. + Run Libdoc And Parse Output --specdocformat RAW ${source} + Validate ${legacy} ${source.endswith('.xml')} + +Validate + [Arguments] ${legacy}=False ${xml}=True + [Tags] robot:recursive-continue-on-failure + Validate library ${legacy} and ${xml} + Validate keyword 'Simple' + Validate keyword 'Arguments' + Validate keyword 'Types' + Validate keyword 'Special Types' + Validate keyword 'Union' + Validate typedocs ${legacy} + +Validate library + [Arguments] ${buggy source}=False + Name Should Be BackwardsCompatibility + Version Should Be 1.0 + Doc Should Start With Library for testing backwards compatibility.\n + Type Should Be LIBRARY + Scope Should Be GLOBAL + Generated Should Be Defined + Spec Version Should Be Correct + Should Have No Init + Keyword Count Should Be 5 + Lineno Should Be 1 + IF ${buggy source} + ${dir} ${file} = Split Path ${BASE}.py + Source Should Be ${file} + ELSE + Source Should Be ${BASE}.py + END + +Validate keyword 'Simple' + Keyword Name Should Be 1 Simple + Keyword Doc Should Be 1 Some doc. + Keyword Tags Should Be 1 example + Keyword Lineno Should Be 1 27 + Keyword Arguments Should Be 1 + +Validate keyword 'Arguments' + Keyword Name Should Be 0 Arguments + Keyword Doc Should Be 0 ${EMPTY} + Keyword Tags Should Be 0 + Keyword Lineno Should Be 0 35 + Keyword Arguments Should Be 0 a b=2 *c d=4 e **f + +Validate keyword 'Types' + Keyword Name Should Be 3 Types + Keyword Doc Should Be 3 ${EMPTY} + Keyword Tags Should Be 3 + Keyword Lineno Should Be 3 39 + Keyword Arguments Should Be 3 a: int b: bool = True + +Validate keyword 'Special Types' + Keyword Name Should Be 2 Special Types + Keyword Doc Should Be 2 ${EMPTY} + Keyword Tags Should Be 2 + Keyword Lineno Should Be 2 43 + Keyword Arguments Should Be 2 a: Color b: Size + +Validate keyword 'Union' + Keyword Name Should Be 4 Union + Keyword Doc Should Be 4 ${EMPTY} + Keyword Tags Should Be 4 + Keyword Lineno Should Be 4 47 + Keyword Arguments Should Be 4 a: int | bool + +Validate typedocs + [Arguments] ${legacy}=False + DataType Enum Should Be 0 Color RGB colors. + ... {"name": "RED", "value": "R"} + ... {"name": "GREEN", "value": "G"} + ... {"name": "BLUE", "value": "B"} + DataType TypedDict Should Be 0 Size Some size. + ... {"key": "width", "type": "int", "required": "true"} + ... {"key": "height", "type": "int", "required": "true"} + IF ${legacy} + Usages Should Be 0 Enum Color + Usages Should Be 1 TypedDict Size + ELSE + DataType Standard Should Be 0 boolean Strings ``TRUE``, + Usages Should Be 0 Standard boolean Types Union + Usages Should Be 1 Enum Color Special Types + Usages Should Be 3 TypedDict Size Special Types + END diff --git a/atest/testdata/libdoc/BackwardsCompatibility-4.0.json b/atest/testdata/libdoc/BackwardsCompatibility-4.0.json new file mode 100644 index 00000000000..de3fb1e487b --- /dev/null +++ b/atest/testdata/libdoc/BackwardsCompatibility-4.0.json @@ -0,0 +1,209 @@ +{ + "name": "BackwardsCompatibility", + "doc": "Library for testing backwards compatibility.\n\nEspecially testing argument type information that has been changing after RF 4.\nExamples are only using features compatible with all tested versions.", + "version": "1.0", + "generated": "2023-02-28 14:13:40", + "type": "LIBRARY", + "scope": "GLOBAL", + "docFormat": "ROBOT", + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 1, + "tags": [ + "example" + ], + "inits": [], + "keywords": [ + { + "name": "Arguments", + "args": [ + { + "name": "a", + "types": [], + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a" + }, + { + "name": "b", + "types": [], + "defaultValue": "2", + "kind": "POSITIONAL_OR_NAMED", + "required": false, + "repr": "b=2" + }, + { + "name": "c", + "types": [], + "defaultValue": null, + "kind": "VAR_POSITIONAL", + "required": false, + "repr": "*c" + }, + { + "name": "d", + "types": [], + "defaultValue": "4", + "kind": "NAMED_ONLY", + "required": false, + "repr": "d=4" + }, + { + "name": "e", + "types": [], + "defaultValue": null, + "kind": "NAMED_ONLY", + "required": true, + "repr": "e" + }, + { + "name": "f", + "types": [], + "defaultValue": null, + "kind": "VAR_NAMED", + "required": false, + "repr": "**f" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 35 + }, + { + "name": "Simple", + "args": [], + "doc": "Some doc.", + "shortdoc": "Some doc.", + "tags": [ + "example" + ], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 27 + }, + { + "name": "Special Types", + "args": [ + { + "name": "a", + "types": [ + "Color" + ], + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: Color" + }, + { + "name": "b", + "types": [ + "Size" + ], + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "b: Size" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 43 + }, + { + "name": "Types", + "args": [ + { + "name": "a", + "types": [ + "int" + ], + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: int" + }, + { + "name": "b", + "types": [ + "bool" + ], + "defaultValue": "True", + "kind": "POSITIONAL_OR_NAMED", + "required": false, + "repr": "b: bool = True" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 39 + }, + { + "name": "Union", + "args": [ + { + "name": "a", + "types": [ + "int", + "bool" + ], + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: int | bool" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 47 + } + ], + "dataTypes": { + "enums": [ + { + "name": "Color", + "type": "Enum", + "doc": "RGB colors.", + "members": [ + { + "name": "RED", + "value": "R" + }, + { + "name": "GREEN", + "value": "G" + }, + { + "name": "BLUE", + "value": "B" + } + ] + } + ], + "typedDicts": [ + { + "name": "Size", + "type": "TypedDict", + "doc": "Some size.", + "items": [ + { + "key": "width", + "type": "int", + "required": true + }, + { + "key": "height", + "type": "int", + "required": true + } + ] + } + ] + } +} \ No newline at end of file diff --git a/atest/testdata/libdoc/BackwardsCompatibility-4.0.xml b/atest/testdata/libdoc/BackwardsCompatibility-4.0.xml new file mode 100644 index 00000000000..c59370e5d98 --- /dev/null +++ b/atest/testdata/libdoc/BackwardsCompatibility-4.0.xml @@ -0,0 +1,111 @@ +<?xml version="1.0" encoding="UTF-8"?> +<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:07Z" specversion="3" source="BackwardsCompatibility.py" lineno="1"> +<version>1.0</version> +<doc>Library for testing backwards compatibility. + +Especially testing argument type information that has been changing after RF 4. +Examples are only using features compatible with all tested versions.</doc> +<tags> +<tag>example</tag> +</tags> +<inits> +</inits> +<keywords> +<kw name="Arguments" lineno="35"> +<arguments repr="a, b=2, *c, d=4, e, **f"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a"> +<name>a</name> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="false" repr="b=2"> +<name>b</name> +<default>2</default> +</arg> +<arg kind="VAR_POSITIONAL" required="false" repr="*c"> +<name>c</name> +</arg> +<arg kind="NAMED_ONLY" required="false" repr="d=4"> +<name>d</name> +<default>4</default> +</arg> +<arg kind="NAMED_ONLY" required="true" repr="e"> +<name>e</name> +</arg> +<arg kind="VAR_NAMED" required="false" repr="**f"> +<name>f</name> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Simple" lineno="27"> +<arguments repr=""> +</arguments> +<doc>Some doc.</doc> +<shortdoc>Some doc.</shortdoc> +<tags> +<tag>example</tag> +</tags> +</kw> +<kw name="Special Types" lineno="43"> +<arguments repr="a: Color, b: Size"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: Color"> +<name>a</name> +<type>Color</type> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="b: Size"> +<name>b</name> +<type>Size</type> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Types" lineno="39"> +<arguments repr="a: int, b: bool = True"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int"> +<name>a</name> +<type>int</type> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="false" repr="b: bool = True"> +<name>b</name> +<type>bool</type> +<default>True</default> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Union" lineno="47"> +<arguments repr="a: int | bool"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | bool"> +<name>a</name> +<type>int</type> +<type>bool</type> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +</keywords> +<datatypes> +<enums> +<enum name="Color"> +<doc>RGB colors.</doc> +<members> +<member name="RED" value="R"/> +<member name="GREEN" value="G"/> +<member name="BLUE" value="B"/> +</members> +</enum> +</enums> +<typeddicts> +<typeddict name="Size"> +<doc>Some size.</doc> +<items> +<item key="width" type="int" required="true"/> +<item key="height" type="int" required="true"/> +</items> +</typeddict> +</typeddicts> +</datatypes> +</keywordspec> diff --git a/atest/testdata/libdoc/BackwardsCompatibility-5.0.json b/atest/testdata/libdoc/BackwardsCompatibility-5.0.json new file mode 100644 index 00000000000..58d3643b63b --- /dev/null +++ b/atest/testdata/libdoc/BackwardsCompatibility-5.0.json @@ -0,0 +1,310 @@ +{ + "specversion": 1, + "name": "BackwardsCompatibility", + "doc": "Library for testing backwards compatibility.\n\nEspecially testing argument type information that has been changing after RF 4.\nExamples are only using features compatible with all tested versions.", + "version": "1.0", + "generated": "2023-02-28 14:14:04", + "type": "LIBRARY", + "scope": "GLOBAL", + "docFormat": "ROBOT", + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 1, + "tags": [ + "example" + ], + "inits": [], + "keywords": [ + { + "name": "Arguments", + "args": [ + { + "name": "a", + "types": [], + "typedocs": {}, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a" + }, + { + "name": "b", + "types": [], + "typedocs": {}, + "defaultValue": "2", + "kind": "POSITIONAL_OR_NAMED", + "required": false, + "repr": "b=2" + }, + { + "name": "c", + "types": [], + "typedocs": {}, + "defaultValue": null, + "kind": "VAR_POSITIONAL", + "required": false, + "repr": "*c" + }, + { + "name": "d", + "types": [], + "typedocs": {}, + "defaultValue": "4", + "kind": "NAMED_ONLY", + "required": false, + "repr": "d=4" + }, + { + "name": "e", + "types": [], + "typedocs": {}, + "defaultValue": null, + "kind": "NAMED_ONLY", + "required": true, + "repr": "e" + }, + { + "name": "f", + "types": [], + "typedocs": {}, + "defaultValue": null, + "kind": "VAR_NAMED", + "required": false, + "repr": "**f" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 35 + }, + { + "name": "Simple", + "args": [], + "doc": "Some doc.", + "shortdoc": "Some doc.", + "tags": [ + "example" + ], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 27 + }, + { + "name": "Special Types", + "args": [ + { + "name": "a", + "types": [ + "Color" + ], + "typedocs": { + "Color": "Color" + }, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: Color" + }, + { + "name": "b", + "types": [ + "Size" + ], + "typedocs": { + "Size": "Size" + }, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "b: Size" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 43 + }, + { + "name": "Types", + "args": [ + { + "name": "a", + "types": [ + "int" + ], + "typedocs": { + "int": "integer" + }, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: int" + }, + { + "name": "b", + "types": [ + "bool" + ], + "typedocs": { + "bool": "boolean" + }, + "defaultValue": "True", + "kind": "POSITIONAL_OR_NAMED", + "required": false, + "repr": "b: bool = True" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 39 + }, + { + "name": "Union", + "args": [ + { + "name": "a", + "types": [ + "int", + "bool" + ], + "typedocs": { + "int": "integer", + "bool": "boolean" + }, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: int | bool" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 47 + } + ], + "dataTypes": { + "enums": [ + { + "type": "Enum", + "name": "Color", + "doc": "RGB colors.", + "members": [ + { + "name": "RED", + "value": "R" + }, + { + "name": "GREEN", + "value": "G" + }, + { + "name": "BLUE", + "value": "B" + } + ] + } + ], + "typedDicts": [ + { + "type": "TypedDict", + "name": "Size", + "doc": "Some size.", + "items": [ + { + "key": "width", + "type": "int", + "required": true + }, + { + "key": "height", + "type": "int", + "required": true + } + ] + } + ] + }, + "typedocs": [ + { + "type": "Standard", + "name": "boolean", + "doc": "Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``,\nthe empty string as well as strings ``FALSE``, ``NO``, ``OFF`` and ``0``\nare converted to Boolean ``False``, and the string ``NONE`` is converted\nto the Python ``None`` object. Other strings and other accepted values are\npassed as-is, allowing keywords to handle them specially if\nneeded. All string comparisons are case-insensitive.\n\nExamples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``),\n``example`` (used as-is)\n", + "usages": [ + "Types", + "Union" + ], + "accepts": [ + "string", + "integer", + "float", + "None" + ] + }, + { + "type": "Enum", + "name": "Color", + "doc": "RGB colors.", + "usages": [ + "Special Types" + ], + "accepts": [ + "string" + ], + "members": [ + { + "name": "RED", + "value": "R" + }, + { + "name": "GREEN", + "value": "G" + }, + { + "name": "BLUE", + "value": "B" + } + ] + }, + { + "type": "Standard", + "name": "integer", + "doc": "Conversion is done using Python's [https://docs.python.org/library/functions.html#int|int]\nbuilt-in function. Floating point\nnumbers are accepted only if they can be represented as integers exactly.\nFor example, ``1.0`` is accepted and ``1.1`` is not.\n\nStarting from RF 4.1, it is possible to use hexadecimal, octal and binary\nnumbers by prefixing values with ``0x``, ``0o`` and ``0b``, respectively.\n\nStarting from RF 4.1, spaces and underscores can be used as visual separators\nfor digit grouping purposes.\n\nExamples: ``42``, ``-1``, ``0b1010``, ``10 000 000``, ``0xBAD_C0FFEE``\n", + "usages": [ + "Types", + "Union" + ], + "accepts": [ + "string", + "float" + ] + }, + { + "type": "TypedDict", + "name": "Size", + "doc": "Some size.", + "usages": [ + "Special Types" + ], + "accepts": [ + "string" + ], + "items": [ + { + "key": "width", + "type": "int", + "required": true + }, + { + "key": "height", + "type": "int", + "required": true + } + ] + } + ] +} \ No newline at end of file diff --git a/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml b/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml new file mode 100644 index 00000000000..193ca99c14d --- /dev/null +++ b/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml @@ -0,0 +1,185 @@ +<?xml version="1.0" encoding="UTF-8"?> +<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:25Z" specversion="4" source="/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py" lineno="1"> +<version>1.0</version> +<doc>Library for testing backwards compatibility. + +Especially testing argument type information that has been changing after RF 4. +Examples are only using features compatible with all tested versions.</doc> +<tags> +<tag>example</tag> +</tags> +<inits> +</inits> +<keywords> +<kw name="Arguments" lineno="35"> +<arguments repr="a, b=2, *c, d=4, e, **f"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a"> +<name>a</name> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="false" repr="b=2"> +<name>b</name> +<default>2</default> +</arg> +<arg kind="VAR_POSITIONAL" required="false" repr="*c"> +<name>c</name> +</arg> +<arg kind="NAMED_ONLY" required="false" repr="d=4"> +<name>d</name> +<default>4</default> +</arg> +<arg kind="NAMED_ONLY" required="true" repr="e"> +<name>e</name> +</arg> +<arg kind="VAR_NAMED" required="false" repr="**f"> +<name>f</name> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Simple" lineno="27"> +<arguments repr=""> +</arguments> +<doc>Some doc.</doc> +<shortdoc>Some doc.</shortdoc> +<tags> +<tag>example</tag> +</tags> +</kw> +<kw name="Special Types" lineno="43"> +<arguments repr="a: Color, b: Size"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: Color"> +<name>a</name> +<type typedoc="Color">Color</type> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="b: Size"> +<name>b</name> +<type typedoc="Size">Size</type> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Types" lineno="39"> +<arguments repr="a: int, b: bool = True"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int"> +<name>a</name> +<type typedoc="integer">int</type> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="false" repr="b: bool = True"> +<name>b</name> +<type typedoc="boolean">bool</type> +<default>True</default> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Union" lineno="47"> +<arguments repr="a: int | bool"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | bool"> +<name>a</name> +<type typedoc="integer">int</type> +<type typedoc="boolean">bool</type> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +</keywords> +<datatypes> +<enums> +<enum name="Color"> +<doc>RGB colors.</doc> +<members> +<member name="RED" value="R"/> +<member name="GREEN" value="G"/> +<member name="BLUE" value="B"/> +</members> +</enum> +</enums> +<typeddicts> +<typeddict name="Size"> +<doc>Some size.</doc> +<items> +<item key="width" type="int" required="true"/> +<item key="height" type="int" required="true"/> +</items> +</typeddict> +</typeddicts> +</datatypes> +<typedocs> +<type name="boolean" type="Standard"> +<doc>Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``, +the empty string as well as strings ``FALSE``, ``NO``, ``OFF`` and ``0`` +are converted to Boolean ``False``, and the string ``NONE`` is converted +to the Python ``None`` object. Other strings and other accepted values are +passed as-is, allowing keywords to handle them specially if +needed. All string comparisons are case-insensitive. + +Examples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``), +``example`` (used as-is) +</doc> +<accepts> +<type>string</type> +<type>integer</type> +<type>float</type> +<type>None</type> +</accepts> +<usages> +<usage>Types</usage> +<usage>Union</usage> +</usages> +</type> +<type name="Color" type="Enum"> +<doc>RGB colors.</doc> +<accepts> +<type>string</type> +</accepts> +<usages> +<usage>Special Types</usage> +</usages> +<members> +<member name="RED" value="R"/> +<member name="GREEN" value="G"/> +<member name="BLUE" value="B"/> +</members> +</type> +<type name="integer" type="Standard"> +<doc>Conversion is done using Python's [https://docs.python.org/library/functions.html#int|int] +built-in function. Floating point +numbers are accepted only if they can be represented as integers exactly. +For example, ``1.0`` is accepted and ``1.1`` is not. + +Starting from RF 4.1, it is possible to use hexadecimal, octal and binary +numbers by prefixing values with ``0x``, ``0o`` and ``0b``, respectively. + +Starting from RF 4.1, spaces and underscores can be used as visual separators +for digit grouping purposes. + +Examples: ``42``, ``-1``, ``0b1010``, ``10 000 000``, ``0xBAD_C0FFEE`` +</doc> +<accepts> +<type>string</type> +<type>float</type> +</accepts> +<usages> +<usage>Types</usage> +<usage>Union</usage> +</usages> +</type> +<type name="Size" type="TypedDict"> +<doc>Some size.</doc> +<accepts> +<type>string</type> +</accepts> +<usages> +<usage>Special Types</usage> +</usages> +<items> +<item key="width" type="int" required="true"/> +<item key="height" type="int" required="true"/> +</items> +</type> +</typedocs> +</keywordspec> diff --git a/atest/testdata/libdoc/BackwardsCompatibility-6.1.json b/atest/testdata/libdoc/BackwardsCompatibility-6.1.json new file mode 100644 index 00000000000..4db89fc3053 --- /dev/null +++ b/atest/testdata/libdoc/BackwardsCompatibility-6.1.json @@ -0,0 +1,359 @@ +{ + "specversion": 2, + "name": "BackwardsCompatibility", + "doc": "Library for testing backwards compatibility.\n\nEspecially testing argument type information that has been changing after RF 4.\nExamples are only using features compatible with all tested versions.", + "version": "1.0", + "generated": "2023-02-28T12:14:16+00:00", + "type": "LIBRARY", + "scope": "GLOBAL", + "docFormat": "ROBOT", + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 1, + "tags": [ + "example" + ], + "inits": [], + "keywords": [ + { + "name": "Arguments", + "args": [ + { + "name": "a", + "type": null, + "types": [], + "typedocs": {}, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a" + }, + { + "name": "b", + "type": null, + "types": [], + "typedocs": {}, + "defaultValue": "2", + "kind": "POSITIONAL_OR_NAMED", + "required": false, + "repr": "b=2" + }, + { + "name": "c", + "type": null, + "types": [], + "typedocs": {}, + "defaultValue": null, + "kind": "VAR_POSITIONAL", + "required": false, + "repr": "*c" + }, + { + "name": "d", + "type": null, + "types": [], + "typedocs": {}, + "defaultValue": "4", + "kind": "NAMED_ONLY", + "required": false, + "repr": "d=4" + }, + { + "name": "e", + "type": null, + "types": [], + "typedocs": {}, + "defaultValue": null, + "kind": "NAMED_ONLY", + "required": true, + "repr": "e" + }, + { + "name": "f", + "type": null, + "types": [], + "typedocs": {}, + "defaultValue": null, + "kind": "VAR_NAMED", + "required": false, + "repr": "**f" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 35 + }, + { + "name": "Simple", + "args": [], + "doc": "Some doc.", + "shortdoc": "Some doc.", + "tags": [ + "example" + ], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 27 + }, + { + "name": "Special Types", + "args": [ + { + "name": "a", + "type": { + "name": "Color", + "typedoc": "Color", + "nested": [], + "union": false + }, + "types": [ + "Color" + ], + "typedocs": { + "Color": "Color" + }, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: Color" + }, + { + "name": "b", + "type": { + "name": "Size", + "typedoc": "Size", + "nested": [], + "union": false + }, + "types": [ + "Size" + ], + "typedocs": { + "Size": "Size" + }, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "b: Size" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 43 + }, + { + "name": "Types", + "args": [ + { + "name": "a", + "type": { + "name": "int", + "typedoc": "integer", + "nested": [], + "union": false + }, + "types": [ + "int" + ], + "typedocs": { + "int": "integer" + }, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: int" + }, + { + "name": "b", + "type": { + "name": "bool", + "typedoc": "boolean", + "nested": [], + "union": false + }, + "types": [ + "bool" + ], + "typedocs": { + "bool": "boolean" + }, + "defaultValue": "True", + "kind": "POSITIONAL_OR_NAMED", + "required": false, + "repr": "b: bool = True" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 39 + }, + { + "name": "Union", + "args": [ + { + "name": "a", + "type": { + "name": "Union", + "typedoc": null, + "nested": [ + { + "name": "int", + "typedoc": "integer", + "nested": [], + "union": false + }, + { + "name": "bool", + "typedoc": "boolean", + "nested": [], + "union": false + } + ], + "union": true + }, + "types": [ + "int", + "bool" + ], + "typedocs": { + "int": "integer", + "bool": "boolean" + }, + "defaultValue": null, + "kind": "POSITIONAL_OR_NAMED", + "required": true, + "repr": "a: int | bool" + } + ], + "doc": "", + "shortdoc": "", + "tags": [], + "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "lineno": 47 + } + ], + "dataTypes": { + "enums": [ + { + "type": "Enum", + "name": "Color", + "doc": "RGB colors.", + "members": [ + { + "name": "RED", + "value": "R" + }, + { + "name": "GREEN", + "value": "G" + }, + { + "name": "BLUE", + "value": "B" + } + ] + } + ], + "typedDicts": [ + { + "type": "TypedDict", + "name": "Size", + "doc": "Some size.", + "items": [ + { + "key": "width", + "type": "int", + "required": true + }, + { + "key": "height", + "type": "int", + "required": true + } + ] + } + ] + }, + "typedocs": [ + { + "type": "Standard", + "name": "boolean", + "doc": "Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``,\nthe empty string as well as strings ``FALSE``, ``NO``, ``OFF`` and ``0``\nare converted to Boolean ``False``, and the string ``NONE`` is converted\nto the Python ``None`` object. Other strings and other accepted values are\npassed as-is, allowing keywords to handle them specially if\nneeded. All string comparisons are case-insensitive.\n\nExamples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``),\n``example`` (used as-is)\n", + "usages": [ + "Types", + "Union" + ], + "accepts": [ + "string", + "integer", + "float", + "None" + ] + }, + { + "type": "Enum", + "name": "Color", + "doc": "RGB colors.", + "usages": [ + "Special Types" + ], + "accepts": [ + "string" + ], + "members": [ + { + "name": "RED", + "value": "R" + }, + { + "name": "GREEN", + "value": "G" + }, + { + "name": "BLUE", + "value": "B" + } + ] + }, + { + "type": "Standard", + "name": "integer", + "doc": "Conversion is done using Python's [https://docs.python.org/library/functions.html#int|int]\nbuilt-in function. Floating point\nnumbers are accepted only if they can be represented as integers exactly.\nFor example, ``1.0`` is accepted and ``1.1`` is not.\n\nStarting from RF 4.1, it is possible to use hexadecimal, octal and binary\nnumbers by prefixing values with ``0x``, ``0o`` and ``0b``, respectively.\n\nStarting from RF 4.1, spaces and underscores can be used as visual separators\nfor digit grouping purposes.\n\nExamples: ``42``, ``-1``, ``0b1010``, ``10 000 000``, ``0xBAD_C0FFEE``\n", + "usages": [ + "Types", + "Union" + ], + "accepts": [ + "string", + "float" + ] + }, + { + "type": "TypedDict", + "name": "Size", + "doc": "Some size.", + "usages": [ + "Special Types" + ], + "accepts": [ + "string" + ], + "items": [ + { + "key": "width", + "type": "int", + "required": true + }, + { + "key": "height", + "type": "int", + "required": true + } + ] + } + ] +} \ No newline at end of file diff --git a/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml b/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml new file mode 100644 index 00000000000..56777f1c675 --- /dev/null +++ b/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml @@ -0,0 +1,184 @@ +<?xml version="1.0" encoding="UTF-8"?> +<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:39+00:00" specversion="5" source="/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py" lineno="1"> +<version>1.0</version> +<doc>Library for testing backwards compatibility. + +Especially testing argument type information that has been changing after RF 4. +Examples are only using features compatible with all tested versions.</doc> +<tags> +<tag>example</tag> +</tags> +<inits> +</inits> +<keywords> +<kw name="Arguments" lineno="35"> +<arguments repr="a, b=2, *c, d=4, e, **f"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a"> +<name>a</name> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="false" repr="b=2"> +<name>b</name> +<default>2</default> +</arg> +<arg kind="VAR_POSITIONAL" required="false" repr="*c"> +<name>c</name> +</arg> +<arg kind="NAMED_ONLY" required="false" repr="d=4"> +<name>d</name> +<default>4</default> +</arg> +<arg kind="NAMED_ONLY" required="true" repr="e"> +<name>e</name> +</arg> +<arg kind="VAR_NAMED" required="false" repr="**f"> +<name>f</name> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Simple" lineno="27"> +<arguments repr=""> +</arguments> +<doc>Some doc.</doc> +<shortdoc>Some doc.</shortdoc> +<tags> +<tag>example</tag> +</tags> +</kw> +<kw name="Special Types" lineno="43"> +<arguments repr="a: Color, b: Size"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: Color"> +<name>a</name> +<type name="Color" typedoc="Color">Color</type> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="b: Size"> +<name>b</name> +<type name="Size" typedoc="Size">Size</type> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Types" lineno="39"> +<arguments repr="a: int, b: bool = True"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int"> +<name>a</name> +<type name="int" typedoc="integer">int</type> +</arg> +<arg kind="POSITIONAL_OR_NAMED" required="false" repr="b: bool = True"> +<name>b</name> +<type name="bool" typedoc="boolean">bool</type> +<default>True</default> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +<kw name="Union" lineno="47"> +<arguments repr="a: int | bool"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | bool"> +<name>a</name> +<type name="Union" union="true">int | bool<type name="int" typedoc="integer">int</type><type name="bool" typedoc="boolean">bool</type></type> +</arg> +</arguments> +<doc/> +<shortdoc/> +</kw> +</keywords> +<datatypes> +<enums> +<enum name="Color"> +<doc>RGB colors.</doc> +<members> +<member name="RED" value="R"/> +<member name="GREEN" value="G"/> +<member name="BLUE" value="B"/> +</members> +</enum> +</enums> +<typeddicts> +<typeddict name="Size"> +<doc>Some size.</doc> +<items> +<item key="width" type="int" required="true"/> +<item key="height" type="int" required="true"/> +</items> +</typeddict> +</typeddicts> +</datatypes> +<typedocs> +<type name="boolean" type="Standard"> +<doc>Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``, +the empty string as well as strings ``FALSE``, ``NO``, ``OFF`` and ``0`` +are converted to Boolean ``False``, and the string ``NONE`` is converted +to the Python ``None`` object. Other strings and other accepted values are +passed as-is, allowing keywords to handle them specially if +needed. All string comparisons are case-insensitive. + +Examples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``), +``example`` (used as-is) +</doc> +<accepts> +<type>string</type> +<type>integer</type> +<type>float</type> +<type>None</type> +</accepts> +<usages> +<usage>Types</usage> +<usage>Union</usage> +</usages> +</type> +<type name="Color" type="Enum"> +<doc>RGB colors.</doc> +<accepts> +<type>string</type> +</accepts> +<usages> +<usage>Special Types</usage> +</usages> +<members> +<member name="RED" value="R"/> +<member name="GREEN" value="G"/> +<member name="BLUE" value="B"/> +</members> +</type> +<type name="integer" type="Standard"> +<doc>Conversion is done using Python's [https://docs.python.org/library/functions.html#int|int] +built-in function. Floating point +numbers are accepted only if they can be represented as integers exactly. +For example, ``1.0`` is accepted and ``1.1`` is not. + +Starting from RF 4.1, it is possible to use hexadecimal, octal and binary +numbers by prefixing values with ``0x``, ``0o`` and ``0b``, respectively. + +Starting from RF 4.1, spaces and underscores can be used as visual separators +for digit grouping purposes. + +Examples: ``42``, ``-1``, ``0b1010``, ``10 000 000``, ``0xBAD_C0FFEE`` +</doc> +<accepts> +<type>string</type> +<type>float</type> +</accepts> +<usages> +<usage>Types</usage> +<usage>Union</usage> +</usages> +</type> +<type name="Size" type="TypedDict"> +<doc>Some size.</doc> +<accepts> +<type>string</type> +</accepts> +<usages> +<usage>Special Types</usage> +</usages> +<items> +<item key="width" type="int" required="true"/> +<item key="height" type="int" required="true"/> +</items> +</type> +</typedocs> +</keywordspec> diff --git a/atest/testdata/libdoc/BackwardsCompatibility.py b/atest/testdata/libdoc/BackwardsCompatibility.py new file mode 100644 index 00000000000..3a0e74151d2 --- /dev/null +++ b/atest/testdata/libdoc/BackwardsCompatibility.py @@ -0,0 +1,48 @@ +"""Library for testing backwards compatibility. + +Especially testing argument type information that has been changing after RF 4. +Examples are only using features compatible with all tested versions. +""" + +import enum +import typing + + +ROBOT_LIBRARY_VERSION = '1.0' + + +class Color(enum.Enum): + """RGB colors.""" + RED = 'R' + GREEN = 'G' + BLUE = 'B' + + +class Size(typing.TypedDict): + """Some size.""" + width: int + height: int + + +def simple(): + """Some doc. + + Tags: example + """ + pass + + +def arguments(a, b=2, *c, d=4, e, **f): + pass + + +def types(a: int, b: bool = True): + pass + + +def special_types(a: Color, b: Size): + pass + + +def union(a: typing.Union[int, bool]): + pass From 701a6830a4e786ece35324f8990cfa7efc092e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 14:51:06 +0200 Subject: [PATCH 0393/1592] Libdoc: Show parameterized types properly in HTML #4538 Earlier, for example, `list[int]` was just a single type with a link to type info about lists. Now `list` is a link to list info and `int` is a link to integer info. Specs were enhanced already earlier. This change uses enhanced specs to show info in HTML. Also showing unions was changed as part of this so that `|` is used instead of `or`. The motivation was to make syntax look like Python both with parameterized types and with unions. Info is now shown like this: a: int | float # union, ok b: list [ int ] # one arg, perhaps too much spaces c: list [ int | float ] # union arg, also lot of spaces d: dict [ str , int ] # two args, space before comma is odd There are some arguably unnecessary spaces. Removing them isn't easy with our current templating system, so it might be best to just leave them as they are. --- src/robot/htmldata/libdoc/libdoc.css | 17 +------------ src/robot/htmldata/libdoc/libdoc.html | 35 ++++++++++++++++++++------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/robot/htmldata/libdoc/libdoc.css b/src/robot/htmldata/libdoc/libdoc.css index 1eb8e41b229..6352b9b86ae 100644 --- a/src/robot/htmldata/libdoc/libdoc.css +++ b/src/robot/htmldata/libdoc/libdoc.css @@ -591,6 +591,7 @@ h4 { font-size: 1.1em; } +.arg-type, span.type, a.type { font-size: 1em; @@ -598,22 +599,6 @@ a.type { padding: 0 0 } -.arg-type span.or::after { - content: ' or '; -} - -span.or { - background: none; - font-size: 0.7em; - font-family: system-ui, -apple-system, sans-serif; -} - - -.arg-type span.or:nth-last-of-type(1) { - display: none; -} - - .typed-dict-item .td-type::after { content: ','; } diff --git a/src/robot/htmldata/libdoc/libdoc.html b/src/robot/htmldata/libdoc/libdoc.html index 9c9bd32bfad..de829cc5ea3 100644 --- a/src/robot/htmldata/libdoc/libdoc.html +++ b/src/robot/htmldata/libdoc/libdoc.html @@ -572,20 +572,37 @@ <h4>Documentation</h4> </div> {{/if}} -{{if types.length}} +{{if type}} <span class="arg-type"> - {{each types}} - {{if $value in $data.typedocs}} - <<a style="cursor: pointer;" class="type" title="Click to show type information" onclick="showModal(document.querySelector('#type-modal-${$data.typedocs[$value]}'))">${$value}</a>> - {{else}} - <<span class="type">${$value}</span>> - {{/if}} - <span class="or"></span> - {{/each}} + {{tmpl(type) 'type-info-template'}} </span> {{/if}} </script> +<script type="text/x-jquery-tmpl" id="type-info-template"> + <!-- Store last index to variable. Checking it the 'each' loop didn't work. --> + ${$item.lastIndex=nested.length-1, ""} + {{if union}} + {{each nested}} + {{tmpl($value) 'type-info-template'}} + {{if $index < $item.lastIndex}}|{{/if}} + {{/each}} + {{else}} + {{if typedoc}} + <a style="cursor: pointer;" class="type" title="Click to show type information" onclick="showModal(document.querySelector('#type-modal-${typedoc}'))">${name}</a> + {{else}} + <span class="type">${name}</span> + {{/if}} + {{if nested.length}} + [ + {{each nested}} + {{tmpl($value) 'type-info-template'}} + {{if $index < $item.lastIndex}},{{/if}} + {{/each}} + ] + {{/if}} + {{/if}} +</script> <script type="text/x-jquery-tmpl" id="data-types-template"> {{if typedocs.length}} From d47077c6d999a20f18cf79d5b877edc22d26088d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 16:32:32 +0200 Subject: [PATCH 0394/1592] Fix Libdoc backwards compatibility tests on CI. Due to file paths present in data files, tests literally succeeded only on my machine. Also little cleanup. --- .../libdoc/backwards_compatibility.robot | 37 +++++++++---------- .../libdoc/BackwardsCompatibility-4.0.json | 4 +- .../libdoc/BackwardsCompatibility-5.0.json | 4 +- .../libdoc/BackwardsCompatibility-5.0.xml | 2 +- .../libdoc/BackwardsCompatibility-6.1.json | 4 +- .../libdoc/BackwardsCompatibility-6.1.xml | 2 +- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/atest/robot/libdoc/backwards_compatibility.robot b/atest/robot/libdoc/backwards_compatibility.robot index 40d822a1c0f..d1b968573a0 100644 --- a/atest/robot/libdoc/backwards_compatibility.robot +++ b/atest/robot/libdoc/backwards_compatibility.robot @@ -8,7 +8,7 @@ ${BASE} ${TESTDATADIR}/BackwardsCompatibility *** Test Cases *** Latest - ${BASE}.py + ${BASE}.py source=${BASE}.py RF 6.1 XML ${BASE}-6.1.xml @@ -23,47 +23,42 @@ RF 5.0 JSON ${BASE}-5.0.json RF 4.0 XML - ${BASE}-4.0.xml legacy=True + ${BASE}-4.0.xml datatypes=True RF 4.0 JSON - ${BASE}-4.0.json legacy=True + ${BASE}-4.0.json datatypes=True *** Keywords *** Generate and validate - [Arguments] ${source} ${legacy}=False + [Arguments] ${path} ${source}=BackwardsCompatibility.py ${datatypes}=False # JSON source files must be generated using RAW format as well. - Run Libdoc And Parse Output --specdocformat RAW ${source} - Validate ${legacy} ${source.endswith('.xml')} + Run Libdoc And Parse Output --specdocformat RAW ${path} + Validate ${source} ${datatypes} Validate - [Arguments] ${legacy}=False ${xml}=True + [Arguments] ${source} ${datatypes}=False [Tags] robot:recursive-continue-on-failure - Validate library ${legacy} and ${xml} + Validate library ${source} Validate keyword 'Simple' Validate keyword 'Arguments' Validate keyword 'Types' Validate keyword 'Special Types' Validate keyword 'Union' - Validate typedocs ${legacy} + Validate typedocs ${datatypes} Validate library - [Arguments] ${buggy source}=False + [Arguments] ${source} Name Should Be BackwardsCompatibility Version Should Be 1.0 Doc Should Start With Library for testing backwards compatibility.\n Type Should Be LIBRARY Scope Should Be GLOBAL + Source Should Be ${source} + Lineno Should Be 1 Generated Should Be Defined Spec Version Should Be Correct Should Have No Init Keyword Count Should Be 5 - Lineno Should Be 1 - IF ${buggy source} - ${dir} ${file} = Split Path ${BASE}.py - Source Should Be ${file} - ELSE - Source Should Be ${BASE}.py - END Validate keyword 'Simple' Keyword Name Should Be 1 Simple @@ -101,7 +96,7 @@ Validate keyword 'Union' Keyword Arguments Should Be 4 a: int | bool Validate typedocs - [Arguments] ${legacy}=False + [Arguments] ${datatypes}=False DataType Enum Should Be 0 Color RGB colors. ... {"name": "RED", "value": "R"} ... {"name": "GREEN", "value": "G"} @@ -109,12 +104,14 @@ Validate typedocs DataType TypedDict Should Be 0 Size Some size. ... {"key": "width", "type": "int", "required": "true"} ... {"key": "height", "type": "int", "required": "true"} - IF ${legacy} + IF ${datatypes} Usages Should Be 0 Enum Color Usages Should Be 1 TypedDict Size ELSE - DataType Standard Should Be 0 boolean Strings ``TRUE``, + DataType Standard Should Be 0 boolean Strings ``TRUE``, ``YES``, + DataType Standard Should Be 1 integer Conversion is done using Usages Should Be 0 Standard boolean Types Union Usages Should Be 1 Enum Color Special Types + Usages Should Be 2 Standard integer Types Union Usages Should Be 3 TypedDict Size Special Types END diff --git a/atest/testdata/libdoc/BackwardsCompatibility-4.0.json b/atest/testdata/libdoc/BackwardsCompatibility-4.0.json index de3fb1e487b..d834c0c6cde 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-4.0.json +++ b/atest/testdata/libdoc/BackwardsCompatibility-4.0.json @@ -6,7 +6,7 @@ "type": "LIBRARY", "scope": "GLOBAL", "docFormat": "ROBOT", - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "source": "BackwardsCompatibility.py", "lineno": 1, "tags": [ "example" @@ -206,4 +206,4 @@ } ] } -} \ No newline at end of file +} diff --git a/atest/testdata/libdoc/BackwardsCompatibility-5.0.json b/atest/testdata/libdoc/BackwardsCompatibility-5.0.json index 58d3643b63b..5b9a163ab83 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-5.0.json +++ b/atest/testdata/libdoc/BackwardsCompatibility-5.0.json @@ -7,7 +7,7 @@ "type": "LIBRARY", "scope": "GLOBAL", "docFormat": "ROBOT", - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "source": "BackwardsCompatibility.py", "lineno": 1, "tags": [ "example" @@ -307,4 +307,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml b/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml index 193ca99c14d..e4649a772c3 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml +++ b/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:25Z" specversion="4" source="/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py" lineno="1"> +<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:25Z" specversion="4" source="BackwardsCompatibility.py" lineno="1"> <version>1.0</version> <doc>Library for testing backwards compatibility. diff --git a/atest/testdata/libdoc/BackwardsCompatibility-6.1.json b/atest/testdata/libdoc/BackwardsCompatibility-6.1.json index 4db89fc3053..11482d7918b 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-6.1.json +++ b/atest/testdata/libdoc/BackwardsCompatibility-6.1.json @@ -7,7 +7,7 @@ "type": "LIBRARY", "scope": "GLOBAL", "docFormat": "ROBOT", - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", + "source": "BackwardsCompatibility.py", "lineno": 1, "tags": [ "example" @@ -356,4 +356,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml b/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml index 56777f1c675..2c66eb672e6 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml +++ b/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:39+00:00" specversion="5" source="/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py" lineno="1"> +<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:39+00:00" specversion="5" source="BackwardsCompatibility.py" lineno="1"> <version>1.0</version> <doc>Library for testing backwards compatibility. From aa1ac6b8d660f1e171a8a2f6eb1b459fd2f73b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 16:36:08 +0200 Subject: [PATCH 0395/1592] Log kw doc: Fix reference to `len` option. Fixes #4663. --- src/robot/libraries/BuiltIn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 680e6e204a1..773ac2d0a79 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -3002,7 +3002,7 @@ def log(self, message, level='INFO', html=False, console=False, See `Log Many` if you want to log multiple messages in one go, and `Log To Console` if you only want to write to the console. - Formatter options ``type`` and ``log`` are new in Robot Framework 5.0. + Formatter options ``type`` and ``len`` are new in Robot Framework 5.0. """ # TODO: Remove `repr` altogether in RF 7.0. It was deprecated in RF 5.0. if repr == 'DEPRECATED': From 2b6c7646f4e328e2114054170f7e6873510a5b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 17:00:18 +0200 Subject: [PATCH 0396/1592] Add BuiltIn.robot_running and BuiltIn.dry_run_active New propertys for libraries and other extensions. Fixes #4666. --- .../builtin/builtin_propertys.robot | 11 ++++++++++ .../builtin/BuiltInPropertys.py | 12 ++++++++++ .../builtin/builtin_propertys.robot | 9 ++++++++ src/robot/libraries/BuiltIn.py | 22 +++++++++++++++++++ utest/api/test_using_libraries.py | 9 ++++++++ 5 files changed, 63 insertions(+) create mode 100644 atest/robot/standard_libraries/builtin/builtin_propertys.robot create mode 100644 atest/testdata/standard_libraries/builtin/BuiltInPropertys.py create mode 100644 atest/testdata/standard_libraries/builtin/builtin_propertys.robot diff --git a/atest/robot/standard_libraries/builtin/builtin_propertys.robot b/atest/robot/standard_libraries/builtin/builtin_propertys.robot new file mode 100644 index 00000000000..623baaf6a52 --- /dev/null +++ b/atest/robot/standard_libraries/builtin/builtin_propertys.robot @@ -0,0 +1,11 @@ +*** Settings *** +Resource atest_resource.robot + +*** Test Cases *** +Normal run + Run Tests ${EMPTY} standard_libraries/builtin/builtin_propertys.robot + Check Test Case Test propertys + +Dry-run + Run Tests --dryrun --variable DRYRUN:True standard_libraries/builtin/builtin_propertys.robot + Check Test Case Test propertys diff --git a/atest/testdata/standard_libraries/builtin/BuiltInPropertys.py b/atest/testdata/standard_libraries/builtin/BuiltInPropertys.py new file mode 100644 index 00000000000..efe53cc9bc9 --- /dev/null +++ b/atest/testdata/standard_libraries/builtin/BuiltInPropertys.py @@ -0,0 +1,12 @@ +from robot.libraries.BuiltIn import BuiltIn + + +class BuiltInPropertys: + + def __init__(self, dry_run=False): + assert BuiltIn().robot_running is True + assert BuiltIn().dry_run_active is dry_run + + def keyword(self): + assert BuiltIn().robot_running is True + assert BuiltIn().dry_run_active is False diff --git a/atest/testdata/standard_libraries/builtin/builtin_propertys.robot b/atest/testdata/standard_libraries/builtin/builtin_propertys.robot new file mode 100644 index 00000000000..b50af54771a --- /dev/null +++ b/atest/testdata/standard_libraries/builtin/builtin_propertys.robot @@ -0,0 +1,9 @@ +*** Settings *** +Library BuiltInPropertys.py ${DRYRUN} + +*** Variables *** +${DRYRUN} False + +*** Test Cases *** +Test propertys + Keyword diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 773ac2d0a79..5f5eb4fa0b8 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -54,6 +54,28 @@ def decorator(method): class _BuiltInBase: + @property + def robot_running(self) -> bool: + """Return True/False depending on is Robot Framework running or not. + + Can be used by libraries and other extensions. + + New in Robot Framework 6.1. + """ + return EXECUTION_CONTEXTS.current is not None + + @property + def dry_run_active(self) -> bool: + """Return True/False depending on is dry-run active or not. + + Can be used by libraries and other extensions. Notice that library + keywords are not run at all in dry-run, but library ``__init__`` + can utilize this information. + + New in Robot Framework 6.1. + """ + return self.robot_running and self._context.dry_run + @property def _context(self): return self._get_context() diff --git a/utest/api/test_using_libraries.py b/utest/api/test_using_libraries.py index 94b798131bd..802b86c6c1e 100644 --- a/utest/api/test_using_libraries.py +++ b/utest/api/test_using_libraries.py @@ -26,6 +26,15 @@ def test_suite_doc_and_metadata(self): BuiltIn().set_suite_metadata, 'name', 'value') +class TestBuiltInPropertys(unittest.TestCase): + + def test_robot_running(self): + assert_equal(BuiltIn().robot_running, False) + + def test_dry_run_active(self): + assert_equal(BuiltIn().dry_run_active, False) + + class TestDateTime(unittest.TestCase): def test_date_seconds(self): From 9ecdfa3060a08add94c1cb367db47b784ffab0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 17:06:47 +0200 Subject: [PATCH 0397/1592] Python 3.6 fix related to handling bare Union (#4638) In Python 3.6 bare Union has `__args__` but it's None which causes problems. Our handy `has_args` utility handles such special cases. --- src/robot/running/arguments/typeconverters.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 8cc98512a9a..8182ca4edf1 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -26,8 +26,9 @@ from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time -from robot.utils import (eq, get_error_message, is_string, is_union, plural_or_not as s, - safe_str, seq2str, type_name, type_repr, typeddict_types) +from robot.utils import (eq, get_error_message, has_args, is_string, is_union, + plural_or_not as s, safe_str, seq2str, type_name, type_repr, + typeddict_types) NoneType = type(None) @@ -666,7 +667,9 @@ def _get_types(self, union): return () if isinstance(union, tuple): return union - return getattr(union, '__args__', ()) + if has_args(union): + return union.__args__ + return () @property def type_name(self): From 263c2f14651520a0c81417fde3f86b8a3df4ac25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Feb 2023 18:38:18 +0200 Subject: [PATCH 0398/1592] Python version compatibility fixes for Libdoc tests - Use `typing_extension.TypedDict` instead of `typing.TypedDict` where appropriate. - Avoid `Union[int, bool]` that's not properly handled by Python 3.6. --- .../libdoc/backwards_compatibility.robot | 26 ++++------ .../libdoc/BackwardsCompatibility-4.0.json | 26 +++++----- .../libdoc/BackwardsCompatibility-4.0.xml | 18 +++---- .../libdoc/BackwardsCompatibility-5.0.json | 43 ++++++++++------- .../libdoc/BackwardsCompatibility-5.0.xml | 36 ++++++++++---- .../libdoc/BackwardsCompatibility-6.1.json | 47 ++++++++++++------- .../libdoc/BackwardsCompatibility-6.1.xml | 36 ++++++++++---- .../testdata/libdoc/BackwardsCompatibility.py | 17 +++++-- 8 files changed, 152 insertions(+), 97 deletions(-) diff --git a/atest/robot/libdoc/backwards_compatibility.robot b/atest/robot/libdoc/backwards_compatibility.robot index d1b968573a0..ffa1086feb3 100644 --- a/atest/robot/libdoc/backwards_compatibility.robot +++ b/atest/robot/libdoc/backwards_compatibility.robot @@ -53,6 +53,7 @@ Validate library Doc Should Start With Library for testing backwards compatibility.\n Type Should Be LIBRARY Scope Should Be GLOBAL + Format Should Be ROBOT Source Should Be ${source} Lineno Should Be 1 Generated Should Be Defined @@ -64,36 +65,27 @@ Validate keyword 'Simple' Keyword Name Should Be 1 Simple Keyword Doc Should Be 1 Some doc. Keyword Tags Should Be 1 example - Keyword Lineno Should Be 1 27 + Keyword Lineno Should Be 1 34 Keyword Arguments Should Be 1 Validate keyword 'Arguments' Keyword Name Should Be 0 Arguments Keyword Doc Should Be 0 ${EMPTY} Keyword Tags Should Be 0 - Keyword Lineno Should Be 0 35 + Keyword Lineno Should Be 0 42 Keyword Arguments Should Be 0 a b=2 *c d=4 e **f Validate keyword 'Types' Keyword Name Should Be 3 Types - Keyword Doc Should Be 3 ${EMPTY} - Keyword Tags Should Be 3 - Keyword Lineno Should Be 3 39 Keyword Arguments Should Be 3 a: int b: bool = True Validate keyword 'Special Types' Keyword Name Should Be 2 Special Types - Keyword Doc Should Be 2 ${EMPTY} - Keyword Tags Should Be 2 - Keyword Lineno Should Be 2 43 Keyword Arguments Should Be 2 a: Color b: Size Validate keyword 'Union' Keyword Name Should Be 4 Union - Keyword Doc Should Be 4 ${EMPTY} - Keyword Tags Should Be 4 - Keyword Lineno Should Be 4 47 - Keyword Arguments Should Be 4 a: int | bool + Keyword Arguments Should Be 4 a: int | float Validate typedocs [Arguments] ${datatypes}=False @@ -109,9 +101,11 @@ Validate typedocs Usages Should Be 1 TypedDict Size ELSE DataType Standard Should Be 0 boolean Strings ``TRUE``, ``YES``, - DataType Standard Should Be 1 integer Conversion is done using - Usages Should Be 0 Standard boolean Types Union + DataType Standard Should Be 1 float Conversion is done using + DataType Standard Should Be 2 integer Conversion is done using + Usages Should Be 0 Standard boolean Types Usages Should Be 1 Enum Color Special Types - Usages Should Be 2 Standard integer Types Union - Usages Should Be 3 TypedDict Size Special Types + Usages Should Be 2 Standard float Union + Usages Should Be 3 Standard integer Types Union + Usages Should Be 4 TypedDict Size Special Types END diff --git a/atest/testdata/libdoc/BackwardsCompatibility-4.0.json b/atest/testdata/libdoc/BackwardsCompatibility-4.0.json index d834c0c6cde..43b884d79a0 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-4.0.json +++ b/atest/testdata/libdoc/BackwardsCompatibility-4.0.json @@ -2,7 +2,7 @@ "name": "BackwardsCompatibility", "doc": "Library for testing backwards compatibility.\n\nEspecially testing argument type information that has been changing after RF 4.\nExamples are only using features compatible with all tested versions.", "version": "1.0", - "generated": "2023-02-28 14:13:40", + "generated": "2023-02-28 18:30:35", "type": "LIBRARY", "scope": "GLOBAL", "docFormat": "ROBOT", @@ -68,8 +68,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 35 + "source": "BackwardsCompatibility.py", + "lineno": 42 }, { "name": "Simple", @@ -79,8 +79,8 @@ "tags": [ "example" ], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 27 + "source": "BackwardsCompatibility.py", + "lineno": 34 }, { "name": "Special Types", @@ -109,8 +109,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 43 + "source": "BackwardsCompatibility.py", + "lineno": 50 }, { "name": "Types", @@ -139,8 +139,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 39 + "source": "BackwardsCompatibility.py", + "lineno": 46 }, { "name": "Union", @@ -149,19 +149,19 @@ "name": "a", "types": [ "int", - "bool" + "float" ], "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, - "repr": "a: int | bool" + "repr": "a: int | float" } ], "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 47 + "source": "BackwardsCompatibility.py", + "lineno": 54 } ], "dataTypes": { diff --git a/atest/testdata/libdoc/BackwardsCompatibility-4.0.xml b/atest/testdata/libdoc/BackwardsCompatibility-4.0.xml index c59370e5d98..e8599153c67 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-4.0.xml +++ b/atest/testdata/libdoc/BackwardsCompatibility-4.0.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:07Z" specversion="3" source="BackwardsCompatibility.py" lineno="1"> +<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T16:14:50Z" specversion="3" source="BackwardsCompatibility.py" lineno="1"> <version>1.0</version> <doc>Library for testing backwards compatibility. @@ -11,7 +11,7 @@ Examples are only using features compatible with all tested versions.</doc> <inits> </inits> <keywords> -<kw name="Arguments" lineno="35"> +<kw name="Arguments" lineno="42"> <arguments repr="a, b=2, *c, d=4, e, **f"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a"> <name>a</name> @@ -37,7 +37,7 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Simple" lineno="27"> +<kw name="Simple" lineno="34"> <arguments repr=""> </arguments> <doc>Some doc.</doc> @@ -46,7 +46,7 @@ Examples are only using features compatible with all tested versions.</doc> <tag>example</tag> </tags> </kw> -<kw name="Special Types" lineno="43"> +<kw name="Special Types" lineno="50"> <arguments repr="a: Color, b: Size"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: Color"> <name>a</name> @@ -60,7 +60,7 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Types" lineno="39"> +<kw name="Types" lineno="46"> <arguments repr="a: int, b: bool = True"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int"> <name>a</name> @@ -75,12 +75,12 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Union" lineno="47"> -<arguments repr="a: int | bool"> -<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | bool"> +<kw name="Union" lineno="54"> +<arguments repr="a: int | float"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | float"> <name>a</name> <type>int</type> -<type>bool</type> +<type>float</type> </arg> </arguments> <doc/> diff --git a/atest/testdata/libdoc/BackwardsCompatibility-5.0.json b/atest/testdata/libdoc/BackwardsCompatibility-5.0.json index 5b9a163ab83..7cf578d7c31 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-5.0.json +++ b/atest/testdata/libdoc/BackwardsCompatibility-5.0.json @@ -3,7 +3,7 @@ "name": "BackwardsCompatibility", "doc": "Library for testing backwards compatibility.\n\nEspecially testing argument type information that has been changing after RF 4.\nExamples are only using features compatible with all tested versions.", "version": "1.0", - "generated": "2023-02-28 14:14:04", + "generated": "2023-02-28 18:30:43", "type": "LIBRARY", "scope": "GLOBAL", "docFormat": "ROBOT", @@ -75,8 +75,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 35 + "source": "BackwardsCompatibility.py", + "lineno": 42 }, { "name": "Simple", @@ -86,8 +86,8 @@ "tags": [ "example" ], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 27 + "source": "BackwardsCompatibility.py", + "lineno": 34 }, { "name": "Special Types", @@ -122,8 +122,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 43 + "source": "BackwardsCompatibility.py", + "lineno": 50 }, { "name": "Types", @@ -158,8 +158,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 39 + "source": "BackwardsCompatibility.py", + "lineno": 46 }, { "name": "Union", @@ -168,23 +168,23 @@ "name": "a", "types": [ "int", - "bool" + "float" ], "typedocs": { "int": "integer", - "bool": "boolean" + "float": "float" }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, - "repr": "a: int | bool" + "repr": "a: int | float" } ], "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 47 + "source": "BackwardsCompatibility.py", + "lineno": 54 } ], "dataTypes": { @@ -235,8 +235,7 @@ "name": "boolean", "doc": "Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``,\nthe empty string as well as strings ``FALSE``, ``NO``, ``OFF`` and ``0``\nare converted to Boolean ``False``, and the string ``NONE`` is converted\nto the Python ``None`` object. Other strings and other accepted values are\npassed as-is, allowing keywords to handle them specially if\nneeded. All string comparisons are case-insensitive.\n\nExamples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``),\n``example`` (used as-is)\n", "usages": [ - "Types", - "Union" + "Types" ], "accepts": [ "string", @@ -270,6 +269,18 @@ } ] }, + { + "type": "Standard", + "name": "float", + "doc": "Conversion is done using Python's\n[https://docs.python.org/library/functions.html#float|float] built-in function.\n\nStarting from RF 4.1, spaces and underscores can be used as visual separators\nfor digit grouping purposes.\n\nExamples: ``3.14``, ``2.9979e8``, ``10 000.000 01``\n", + "usages": [ + "Union" + ], + "accepts": [ + "string", + "Real" + ] + }, { "type": "Standard", "name": "integer", diff --git a/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml b/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml index e4649a772c3..23675373534 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml +++ b/atest/testdata/libdoc/BackwardsCompatibility-5.0.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:25Z" specversion="4" source="BackwardsCompatibility.py" lineno="1"> +<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T16:16:11Z" specversion="4" source="BackwardsCompatibility.py" lineno="1"> <version>1.0</version> <doc>Library for testing backwards compatibility. @@ -11,7 +11,7 @@ Examples are only using features compatible with all tested versions.</doc> <inits> </inits> <keywords> -<kw name="Arguments" lineno="35"> +<kw name="Arguments" lineno="42"> <arguments repr="a, b=2, *c, d=4, e, **f"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a"> <name>a</name> @@ -37,7 +37,7 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Simple" lineno="27"> +<kw name="Simple" lineno="34"> <arguments repr=""> </arguments> <doc>Some doc.</doc> @@ -46,7 +46,7 @@ Examples are only using features compatible with all tested versions.</doc> <tag>example</tag> </tags> </kw> -<kw name="Special Types" lineno="43"> +<kw name="Special Types" lineno="50"> <arguments repr="a: Color, b: Size"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: Color"> <name>a</name> @@ -60,7 +60,7 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Types" lineno="39"> +<kw name="Types" lineno="46"> <arguments repr="a: int, b: bool = True"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int"> <name>a</name> @@ -75,12 +75,12 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Union" lineno="47"> -<arguments repr="a: int | bool"> -<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | bool"> +<kw name="Union" lineno="54"> +<arguments repr="a: int | float"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | float"> <name>a</name> <type typedoc="integer">int</type> -<type typedoc="boolean">bool</type> +<type typedoc="float">float</type> </arg> </arguments> <doc/> @@ -128,7 +128,6 @@ Examples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``), </accepts> <usages> <usage>Types</usage> -<usage>Union</usage> </usages> </type> <type name="Color" type="Enum"> @@ -145,6 +144,23 @@ Examples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``), <member name="BLUE" value="B"/> </members> </type> +<type name="float" type="Standard"> +<doc>Conversion is done using Python's +[https://docs.python.org/library/functions.html#float|float] built-in function. + +Starting from RF 4.1, spaces and underscores can be used as visual separators +for digit grouping purposes. + +Examples: ``3.14``, ``2.9979e8``, ``10 000.000 01`` +</doc> +<accepts> +<type>string</type> +<type>Real</type> +</accepts> +<usages> +<usage>Union</usage> +</usages> +</type> <type name="integer" type="Standard"> <doc>Conversion is done using Python's [https://docs.python.org/library/functions.html#int|int] built-in function. Floating point diff --git a/atest/testdata/libdoc/BackwardsCompatibility-6.1.json b/atest/testdata/libdoc/BackwardsCompatibility-6.1.json index 11482d7918b..b5dde92e6fb 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-6.1.json +++ b/atest/testdata/libdoc/BackwardsCompatibility-6.1.json @@ -3,7 +3,7 @@ "name": "BackwardsCompatibility", "doc": "Library for testing backwards compatibility.\n\nEspecially testing argument type information that has been changing after RF 4.\nExamples are only using features compatible with all tested versions.", "version": "1.0", - "generated": "2023-02-28T12:14:16+00:00", + "generated": "2023-02-28T16:25:49+00:00", "type": "LIBRARY", "scope": "GLOBAL", "docFormat": "ROBOT", @@ -81,8 +81,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 35 + "source": "BackwardsCompatibility.py", + "lineno": 42 }, { "name": "Simple", @@ -92,8 +92,8 @@ "tags": [ "example" ], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 27 + "source": "BackwardsCompatibility.py", + "lineno": 34 }, { "name": "Special Types", @@ -140,8 +140,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 43 + "source": "BackwardsCompatibility.py", + "lineno": 50 }, { "name": "Types", @@ -188,8 +188,8 @@ "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 39 + "source": "BackwardsCompatibility.py", + "lineno": 46 }, { "name": "Union", @@ -207,8 +207,8 @@ "union": false }, { - "name": "bool", - "typedoc": "boolean", + "name": "float", + "typedoc": "float", "nested": [], "union": false } @@ -217,23 +217,23 @@ }, "types": [ "int", - "bool" + "float" ], "typedocs": { "int": "integer", - "bool": "boolean" + "float": "float" }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, - "repr": "a: int | bool" + "repr": "a: int | float" } ], "doc": "", "shortdoc": "", "tags": [], - "source": "/home/peke/Devel/robotframework/atest/testdata/libdoc/BackwardsCompatibility.py", - "lineno": 47 + "source": "BackwardsCompatibility.py", + "lineno": 54 } ], "dataTypes": { @@ -284,8 +284,7 @@ "name": "boolean", "doc": "Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``,\nthe empty string as well as strings ``FALSE``, ``NO``, ``OFF`` and ``0``\nare converted to Boolean ``False``, and the string ``NONE`` is converted\nto the Python ``None`` object. Other strings and other accepted values are\npassed as-is, allowing keywords to handle them specially if\nneeded. All string comparisons are case-insensitive.\n\nExamples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``),\n``example`` (used as-is)\n", "usages": [ - "Types", - "Union" + "Types" ], "accepts": [ "string", @@ -319,6 +318,18 @@ } ] }, + { + "type": "Standard", + "name": "float", + "doc": "Conversion is done using Python's\n[https://docs.python.org/library/functions.html#float|float] built-in function.\n\nStarting from RF 4.1, spaces and underscores can be used as visual separators\nfor digit grouping purposes.\n\nExamples: ``3.14``, ``2.9979e8``, ``10 000.000 01``\n", + "usages": [ + "Union" + ], + "accepts": [ + "string", + "Real" + ] + }, { "type": "Standard", "name": "integer", diff --git a/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml b/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml index 2c66eb672e6..8fe3a21ba8d 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml +++ b/atest/testdata/libdoc/BackwardsCompatibility-6.1.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T12:05:39+00:00" specversion="5" source="BackwardsCompatibility.py" lineno="1"> +<keywordspec name="BackwardsCompatibility" type="LIBRARY" format="ROBOT" scope="GLOBAL" generated="2023-02-28T16:10:30+00:00" specversion="5" source="BackwardsCompatibility.py" lineno="1"> <version>1.0</version> <doc>Library for testing backwards compatibility. @@ -11,7 +11,7 @@ Examples are only using features compatible with all tested versions.</doc> <inits> </inits> <keywords> -<kw name="Arguments" lineno="35"> +<kw name="Arguments" lineno="42"> <arguments repr="a, b=2, *c, d=4, e, **f"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a"> <name>a</name> @@ -37,7 +37,7 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Simple" lineno="27"> +<kw name="Simple" lineno="34"> <arguments repr=""> </arguments> <doc>Some doc.</doc> @@ -46,7 +46,7 @@ Examples are only using features compatible with all tested versions.</doc> <tag>example</tag> </tags> </kw> -<kw name="Special Types" lineno="43"> +<kw name="Special Types" lineno="50"> <arguments repr="a: Color, b: Size"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: Color"> <name>a</name> @@ -60,7 +60,7 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Types" lineno="39"> +<kw name="Types" lineno="46"> <arguments repr="a: int, b: bool = True"> <arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int"> <name>a</name> @@ -75,11 +75,11 @@ Examples are only using features compatible with all tested versions.</doc> <doc/> <shortdoc/> </kw> -<kw name="Union" lineno="47"> -<arguments repr="a: int | bool"> -<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | bool"> +<kw name="Union" lineno="54"> +<arguments repr="a: int | float"> +<arg kind="POSITIONAL_OR_NAMED" required="true" repr="a: int | float"> <name>a</name> -<type name="Union" union="true">int | bool<type name="int" typedoc="integer">int</type><type name="bool" typedoc="boolean">bool</type></type> +<type name="Union" union="true">int | float<type name="int" typedoc="integer">int</type><type name="float" typedoc="float">float</type></type> </arg> </arguments> <doc/> @@ -127,7 +127,6 @@ Examples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``), </accepts> <usages> <usage>Types</usage> -<usage>Union</usage> </usages> </type> <type name="Color" type="Enum"> @@ -144,6 +143,23 @@ Examples: ``TRUE`` (converted to ``True``), ``off`` (converted to ``False``), <member name="BLUE" value="B"/> </members> </type> +<type name="float" type="Standard"> +<doc>Conversion is done using Python's +[https://docs.python.org/library/functions.html#float|float] built-in function. + +Starting from RF 4.1, spaces and underscores can be used as visual separators +for digit grouping purposes. + +Examples: ``3.14``, ``2.9979e8``, ``10 000.000 01`` +</doc> +<accepts> +<type>string</type> +<type>Real</type> +</accepts> +<usages> +<usage>Union</usage> +</usages> +</type> <type name="integer" type="Standard"> <doc>Conversion is done using Python's [https://docs.python.org/library/functions.html#int|int] built-in function. Floating point diff --git a/atest/testdata/libdoc/BackwardsCompatibility.py b/atest/testdata/libdoc/BackwardsCompatibility.py index 3a0e74151d2..caf49841afe 100644 --- a/atest/testdata/libdoc/BackwardsCompatibility.py +++ b/atest/testdata/libdoc/BackwardsCompatibility.py @@ -4,21 +4,28 @@ Examples are only using features compatible with all tested versions. """ -import enum -import typing +from enum import Enum +from typing import Union +try: + from typing_extensions import TypedDict +except ImportError: + from typing import TypedDict ROBOT_LIBRARY_VERSION = '1.0' -class Color(enum.Enum): +__all__ = ['simple', 'arguments', 'types', 'special_types', 'union'] + + +class Color(Enum): """RGB colors.""" RED = 'R' GREEN = 'G' BLUE = 'B' -class Size(typing.TypedDict): +class Size(TypedDict): """Some size.""" width: int height: int @@ -44,5 +51,5 @@ def special_types(a: Color, b: Size): pass -def union(a: typing.Union[int, bool]): +def union(a: Union[int, float]): pass From 7558d8d392590021344adcbd5d5531d9c0cb9c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 1 Mar 2023 12:52:06 +0200 Subject: [PATCH 0399/1592] Minor cleanup/tuning. The motivaiton was trying to make finding keyword handlers a bit faster to address #4659. Didn't seem to have any measurable effect, but I consider this code better anyway. --- src/robot/libraries/BuiltIn.py | 17 +++++++-------- src/robot/running/handlerstore.py | 2 ++ src/robot/running/namespace.py | 36 +++++++++++++------------------ 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 5f5eb4fa0b8..f10e55f3f2d 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1865,23 +1865,20 @@ def run_keyword(self, name, *args): can be a variable and thus set dynamically, e.g. from a return value of another keyword or from the command line. """ - ctx = self._context - if (is_string(name) - and not ctx.dry_run - and not self._accepts_embedded_arguments(name)): - name, args = self._replace_variables_in_name([name] + list(args)) if not is_string(name): raise RuntimeError('Keyword name must be a string.') + ctx = self._context + if not (ctx.dry_run or self._accepts_embedded_arguments(name, ctx)): + name, args = self._replace_variables_in_name([name] + list(args)) parent = ctx.steps[-1] if ctx.steps else (ctx.test or ctx.suite) kw = Keyword(name, args=args, parent=parent, lineno=getattr(parent, 'lineno', None)) return kw.run(ctx) - def _accepts_embedded_arguments(self, name): + def _accepts_embedded_arguments(self, name, ctx): if '{' in name: - runner = self._context.get_runner(name) - if hasattr(runner, 'embedded_args'): - return True + runner = ctx.get_runner(name) + return runner and hasattr(runner, 'embedded_args') return False def _replace_variables_in_name(self, name_and_args): @@ -1890,6 +1887,8 @@ def _replace_variables_in_name(self, name_and_args): if not resolved: raise DataError(f'Keyword name missing: Given arguments {name_and_args} ' f'resolved to an empty list.') + if not is_string(resolved[0]): + raise RuntimeError('Keyword name must be a string.') return resolved[0], resolved[1:] @run_keyword_variant(resolve=0, dry_run=True) diff --git a/src/robot/running/handlerstore.py b/src/robot/running/handlerstore.py index 11f65e5dd55..36eab2df16a 100644 --- a/src/robot/running/handlerstore.py +++ b/src/robot/running/handlerstore.py @@ -47,6 +47,8 @@ def __len__(self): def __contains__(self, name): if name in self._normal: return True + if not self._embedded: + return False return any(template.matches(name) for template in self._embedded) def __getitem__(self, name): diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 65df6e0f28b..cc0de429f1d 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -311,10 +311,8 @@ def _get_bdd_style_runner(self, name, prefixes): return None def _get_implicit_runner(self, name): - runner = self._get_runner_from_resource_files(name) - if not runner: - runner = self._get_runner_from_libraries(name) - return runner + return (self._get_runner_from_resource_files(name) or + self._get_runner_from_libraries(name)) def _get_runner_from_suite_file(self, name): if name not in self.user_keywords.handlers: @@ -377,8 +375,8 @@ def _get_runner_from_resource_files(self, name): handlers = self._select_best_matches(handlers) if len(handlers) > 1: handlers = self._filter_based_on_search_order(handlers) - if len(handlers) != 1: - self._raise_multiple_keywords_found(handlers, name) + if len(handlers) > 1: + self._raise_multiple_keywords_found(handlers, name) return handlers[0].create_runner(name, self.languages) def _get_runner_from_libraries(self, name): @@ -393,8 +391,8 @@ def _get_runner_from_libraries(self, name): handlers = self._filter_based_on_search_order(handlers) if len(handlers) > 1: handlers, pre_run_message = self._filter_stdlib_handler(handlers) - if len(handlers) != 1: - self._raise_multiple_keywords_found(handlers, name) + if len(handlers) > 1: + self._raise_multiple_keywords_found(handlers, name) runner = handlers[0].create_runner(name, self.languages) if pre_run_message: runner.pre_run_messages += (pre_run_message,) @@ -447,11 +445,12 @@ def _custom_and_standard_keyword_conflict_warning(self, custom, standard): ) def _get_explicit_runner(self, name): - handlers_and_names = [ - (handler, kw_name) - for owner_name, kw_name in self._yield_owner_and_kw_names(name) - for handler in self._yield_handlers(owner_name, kw_name) - ] + handlers_and_names = [] + for owner_name, kw_name in self._get_owner_and_kw_names(name): + for owner in chain(self.libraries.values(), self.resources.values()): + if eq(owner.name, owner_name) and kw_name in owner.handlers: + for handler in owner.handlers.get_handlers(kw_name): + handlers_and_names.append((handler, kw_name)) if not handlers_and_names: return None if len(handlers_and_names) == 1: @@ -464,15 +463,10 @@ def _get_explicit_runner(self, name): handler, kw_name = handlers_and_names[handlers.index(matches[0])] return handler.create_runner(kw_name, self.languages) - def _yield_owner_and_kw_names(self, full_name): + def _get_owner_and_kw_names(self, full_name): tokens = full_name.split('.') - for i in range(1, len(tokens)): - yield '.'.join(tokens[:i]), '.'.join(tokens[i:]) - - def _yield_handlers(self, owner_name, name): - for owner in chain(self.libraries.values(), self.resources.values()): - if eq(owner.name, owner_name) and name in owner.handlers: - yield from owner.handlers.get_handlers(name) + return [('.'.join(tokens[:index]), '.'.join(tokens[index:])) + for index in range(1, len(tokens))] def _raise_multiple_keywords_found(self, handlers, name, implicit=True): if any(hand.supports_embedded_args for hand in handlers): From c80464b98f1efff1d966865eddec6120cf95ad18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 1 Mar 2023 19:32:59 +0200 Subject: [PATCH 0400/1592] Change Import.directory logic to same as earlier. Return the source as-is only if it is an existing directory (or source is None), otherwise return its parent. The logic was accidentallly changed when source was changed to Path from str. Fixes #4607. --- src/robot/running/model.py | 2 +- utest/running/test_run_model.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index a2ea42e2958..5745c6d776a 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -747,7 +747,7 @@ def source(self) -> Path: @property def directory(self) -> Path: source = self.source - return source.parent if source and source.is_file() else source + return source.parent if source and not source.is_dir() else source @property def setting_name(self): diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 21aaebac5e8..7cdf5ee5b98 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -215,6 +215,19 @@ def test_suite(self): def test_import(self): self._assert_lineno_and_source(self.suite.resource.imports[0], 5) + def test_import_without_source(self): + suite = TestSuite() + suite.resource.imports.library('Example') + assert_equal(suite.resource.imports[0].source, None) + assert_equal(suite.resource.imports[0].directory, None) + + def test_import_with_non_existing_source(self): + for source in Path('dummy!'), Path('dummy/example/path'): + suite = TestSuite(source=source) + suite.resource.imports.library('Example') + assert_equal(suite.resource.imports[0].source, source) + assert_equal(suite.resource.imports[0].directory, source.parent) + def test_variable(self): self._assert_lineno_and_source(self.suite.resource.variables[0], 8) From 98cb8300608924b61d78f05ecc6601495b53623a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 1 Mar 2023 14:55:28 +0200 Subject: [PATCH 0401/1592] parsing: dangling END, ELSE and EXCEPT are now errors in AST Also make runtime changes to create a separate Error object in test/keyword bodies and sserialize this in XML and show in logs. Relates to #4210 --- src/robot/htmldata/rebot/testdata.js | 2 +- src/robot/model/__init__.py | 2 +- src/robot/model/body.py | 5 +++ src/robot/model/control.py | 16 ++++++++++ src/robot/model/visitor.py | 30 +++++++++++++++++- src/robot/output/logger.py | 2 ++ src/robot/output/xmllogger.py | 15 +++++++++ src/robot/parsing/lexer/blocklexers.py | 22 +++++++------ src/robot/parsing/lexer/statementlexers.py | 15 +++++++++ src/robot/parsing/model/blocks.py | 6 ++-- src/robot/parsing/model/statements.py | 4 +++ src/robot/parsing/parser/blockparsers.py | 3 ++ src/robot/reporting/jsmodelbuilders.py | 7 ++--- src/robot/result/__init__.py | 2 +- src/robot/result/model.py | 36 ++++++++++++++++++++++ src/robot/result/xmlelementhandlers.py | 17 +++++++--- src/robot/running/builder/transformers.py | 24 +++++++++++++++ src/robot/running/model.py | 29 ++++++++++++++++- 18 files changed, 212 insertions(+), 25 deletions(-) diff --git a/src/robot/htmldata/rebot/testdata.js b/src/robot/htmldata/rebot/testdata.js index 59f8728e514..801e86da4f5 100644 --- a/src/robot/htmldata/rebot/testdata.js +++ b/src/robot/htmldata/rebot/testdata.js @@ -6,7 +6,7 @@ window.testdata = function () { var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FAIL', 'SKIP']; var STATUSES = ['FAIL', 'PASS', 'SKIP', 'NOT RUN']; var KEYWORD_TYPES = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'ITERATION', 'IF', 'ELSE IF', 'ELSE', 'RETURN', - 'TRY', 'EXCEPT', 'FINALLY', 'WHILE', 'CONTINUE', 'BREAK']; + 'TRY', 'EXCEPT', 'FINALLY', 'WHILE', 'CONTINUE', 'BREAK', 'ERROR']; function addElement(elem) { if (!elem.id) diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index 8e6c07fa979..c6e454b0fff 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -27,7 +27,7 @@ from .body import BaseBody, Body, BodyItem, Branches from .configurer import SuiteConfigurer -from .control import For, While, If, IfBranch, Try, TryBranch, Return, Continue, Break +from .control import Break, Continue, Error, For, If, IfBranch, Return, Try, TryBranch, While from .fixture import create_fixture from .itemlist import ItemList from .keyword import Keyword, Keywords diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 357ab1b59a2..3e8dece578a 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -37,6 +37,7 @@ class BodyItem(ModelObject): RETURN = 'RETURN' CONTINUE = 'CONTINUE' BREAK = 'BREAK' + ERROR = 'ERROR' MESSAGE = 'MESSAGE' type = None __slots__ = ['parent'] @@ -85,6 +86,7 @@ class BaseBody(ItemList): continue_class = None break_class = None message_class = None + error_class = None def __init__(self, parent=None, items=None): super().__init__(BodyItem, {'parent': parent}, items) @@ -149,6 +151,9 @@ def create_break(self, *args, **kwargs): def create_message(self, *args, **kwargs): return self._create(self.message_class, 'create_message', args, kwargs) + def create_error(self, *args, **kwargs): + return self._create(self.error_class, 'create_message', args, kwargs) + def filter(self, keywords=None, messages=None, predicate=None): """Filter body items based on type and/or custom predicate. diff --git a/src/robot/model/control.py b/src/robot/model/control.py index aeced4b7be6..08739a51c68 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -320,3 +320,19 @@ def visit(self, visitor): def to_dict(self): return {'type': self.type} + + +@Body.register +class Error(BodyItem): + type = BodyItem.ERROR + __slots__ = ['values'] + + def __init__(self, values, parent=None): + self.values = values + self.parent = parent + + def visit(self, visitor): + visitor.visit_error(self) + + def to_dict(self): + return {'type': self.type, 'data': self.data} diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 19aed19526b..144f1deb53e 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -105,7 +105,8 @@ def visit_test(self, test: TestCase): from typing import TYPE_CHECKING from .body import BodyItem -from .control import Break, Continue, For, If, IfBranch, Return, Try, TryBranch, While +from .control import (Break, Continue, Error, For, If, IfBranch, Return, Try, + TryBranch, While) from .keyword import Keyword from .message import Message from .testcase import TestCase @@ -490,6 +491,33 @@ def end_break(self, break_: Break): """ self.end_body_item(break_) + def visit_error(self, error: Error): + """Visits body items resulting from invalid syntax. + + Examples include syntax like ``END`` or ``ELSE`` in wrong place and + invalid setting like ``[Invalid]``. + """ + if self.start_error(error) is not False: + if hasattr(error, 'body'): + error.body.visit(self) + self.end_error(error) + + def start_error(self, error: Error): + """Called when a ERROR element starts. + + By default, calls :meth:`start_body_item` which, by default, does nothing. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(error) + + def end_error(self, error: Error): + """Called when a ERROR element ends. + + By default, calls :meth:`end_body_item` which, by default, does nothing. + """ + self.end_body_item(error) + def visit_message(self, message: Message): """Implements visiting messages. diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index 4d8fe2f0633..a200b2ec7d3 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -258,6 +258,7 @@ class LoggerProxy(AbstractLoggerProxy): 'Return': 'start_return', 'Continue': 'start_continue', 'Break': 'start_break', + 'Error': 'start_error' } _end_keyword_methods = { 'For': 'end_for', @@ -271,6 +272,7 @@ class LoggerProxy(AbstractLoggerProxy): 'Return': 'end_return', 'Continue': 'end_continue', 'Break': 'end_break', + 'Error': 'end_error' } def start_keyword(self, kw): diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 46d712ea6d3..2b8e16657c6 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -184,6 +184,15 @@ def end_break(self, break_): self._write_status(break_) self._writer.end('break') + def start_error(self, error): + self._writer.start('error') + for value in error.values: + self._writer.element('value', value) + + def end_error(self, error): + self._write_status(error) + self._writer.end('error') + def start_test(self, test): self._writer.start('test', {'id': test.id, 'name': test.name, 'line': str(test.lineno or '')}) @@ -332,3 +341,9 @@ def start_return(self, return_): def end_return(self, return_): pass + + def start_error(self, error): + pass + + def end_error(self, error): + pass diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index d7d7ba16bf8..ebfd00b5ed0 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -31,7 +31,8 @@ InlineIfHeaderLexer, EndLexer, TryHeaderLexer, ExceptHeaderLexer, FinallyHeaderLexer, ForHeaderLexer, WhileHeaderLexer, - ContinueLexer, BreakLexer, ReturnLexer) + ContinueLexer, BreakLexer, ReturnLexer, + SyntaxErrorLexer) class BlockLexer(Lexer): @@ -197,11 +198,6 @@ def _handle_name_or_indentation(self, statement): while statement and not statement[0].value: statement.pop(0).type = None # These tokens will be ignored - def lexer_classes(self): - return (TestOrKeywordSettingLexer, BreakLexer, ContinueLexer, - ForLexer, InlineIfLexer, IfLexer, ReturnLexer, TryLexer, - WhileLexer, KeywordCallLexer) - class TestCaseLexer(TestOrKeywordLexer): name_type = Token.TESTCASE_NAME @@ -212,6 +208,10 @@ def __init__(self, ctx: SuiteFileContext): def lex(self): self._lex_with_priority(priority=TestOrKeywordSettingLexer) + def lexer_classes(self): + return (TestOrKeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, + TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) + class KeywordLexer(TestOrKeywordLexer): name_type = Token.KEYWORD_NAME @@ -219,6 +219,10 @@ class KeywordLexer(TestOrKeywordLexer): def __init__(self, ctx: FileContext): super().__init__(ctx.keyword_context()) + def lexer_classes(self): + return (TestOrKeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, + ReturnLexer, TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) + class NestedBlockLexer(BlockLexer): @@ -246,7 +250,7 @@ def handles(cls, statement: list, ctx: TestOrKeywordContext): def lexer_classes(self): return (ForHeaderLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, EndLexer, - ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) + ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, KeywordCallLexer) class WhileLexer(NestedBlockLexer): @@ -257,7 +261,7 @@ def handles(cls, statement: list, ctx: TestOrKeywordContext): def lexer_classes(self): return (WhileHeaderLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, EndLexer, - ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) + ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, KeywordCallLexer) class TryLexer(NestedBlockLexer): @@ -269,7 +273,7 @@ def handles(cls, statement: list, ctx: TestOrKeywordContext): def lexer_classes(self): return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, ForLexer, InlineIfLexer, IfLexer, WhileLexer, EndLexer, ReturnLexer, - BreakLexer, ContinueLexer, KeywordCallLexer) + BreakLexer, ContinueLexer, SyntaxErrorLexer, KeywordCallLexer) class IfLexer(NestedBlockLexer): diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index e361551e7b2..276214ac382 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -334,3 +334,18 @@ class BreakLexer(TypeAndArguments): @classmethod def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value == 'BREAK' + + +class SyntaxErrorLexer(TypeAndArguments): + token_type = Token.ERROR + + @classmethod + def handles(cls, statement: list, ctx: TestOrKeywordContext): + return statement[0].value in \ + {'BREAK', 'CONTINUE', 'END', 'ELSE', 'ELSE IF','EXCEPT', 'RETURN'} + + def lex(self): + token = self.statement[0] + token.set_error(f'{token.value} is not allowed in this context.') + for t in self.statement[1:]: + t.type = Token.ARGUMENT diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index d6aed0045c1..c9809ea5c46 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -18,8 +18,8 @@ from robot.utils import file_writer, is_pathlike, is_string -from .statements import (Break, Continue, KeywordCall, ReturnSetting, ReturnStatement, - Statement, TemplateArguments) +from .statements import (Break, Continue, Error, KeywordCall, ReturnSetting, + ReturnStatement, Statement, TemplateArguments) from .visitor import ModelVisitor from ..lexer import Token @@ -67,7 +67,7 @@ def __init__(self, header=None, body=None, errors=()): def _body_is_empty(self): # This works with tests, keywords and blocks inside them, not with sections. valid = (KeywordCall, TemplateArguments, Continue, ReturnStatement, Break, - Block) + Block, Error) return not any(isinstance(node, valid) for node in self.body) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index d7379c53507..b48ba62aed2 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -1106,6 +1106,10 @@ class Error(Statement): handles_types = (Token.ERROR, Token.FATAL_ERROR) _errors = () + @property + def values(self): + return [t.value for t in self.data_tokens] + @property def errors(self): """Errors got from the underlying ``ERROR`` and ``FATAL_ERROR`` tokens. diff --git a/src/robot/parsing/parser/blockparsers.py b/src/robot/parsing/parser/blockparsers.py index 82a2fb85b4f..614ff940b16 100644 --- a/src/robot/parsing/parser/blockparsers.py +++ b/src/robot/parsing/parser/blockparsers.py @@ -45,6 +45,9 @@ def __init__(self, model): } def handles(self, statement): + if statement.type == Token.ERROR and \ + statement.errors[0].startswith('Unrecognized section header'): + return False return statement.type not in self.unhandled_tokens def parse(self, statement): diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index a1277948c40..90bb7bfcdc5 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -21,10 +21,9 @@ STATUSES = {'FAIL': 0, 'PASS': 1, 'SKIP': 2, 'NOT RUN': 3} KEYWORD_TYPES = {'KEYWORD': 0, 'SETUP': 1, 'TEARDOWN': 2, - 'FOR': 3, 'ITERATION': 4, - 'IF': 5, 'ELSE IF': 6, 'ELSE': 7, - 'RETURN': 8, 'TRY': 9, 'EXCEPT': 10, - 'FINALLY': 11, 'WHILE': 12, 'CONTINUE': 13, 'BREAK': 14} + 'FOR': 3, 'ITERATION': 4, 'IF': 5, 'ELSE IF': 6, 'ELSE': 7, + 'RETURN': 8, 'TRY': 9, 'EXCEPT': 10, 'FINALLY': 11, 'WHILE': 12, + 'CONTINUE': 13, 'BREAK': 14, 'ERROR': 15} class JsModelBuilder: diff --git a/src/robot/result/__init__.py b/src/robot/result/__init__.py index 0fb8342568a..2ff680c1ae6 100644 --- a/src/robot/result/__init__.py +++ b/src/robot/result/__init__.py @@ -38,7 +38,7 @@ """ from .executionresult import Result -from .model import (Break, Continue, For, ForIteration, If, IfBranch, Keyword, Message, +from .model import (Break, Continue, Error, For, ForIteration, If, IfBranch, Keyword, Message, Return, TestCase, TestSuite, Try, TryBranch, While, WhileIteration) from .resultbuilder import ExecutionResult, ExecutionResultBuilder from .visitor import ResultVisitor diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 2a504db07a9..c3ce8da795c 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -419,6 +419,42 @@ def doc(self): return '' +@Body.register +class Error(model.Error, StatusMixin, DeprecatedAttributesMixin): + __slots__ = ['status', 'starttime', 'endtime'] + body_class = Body + + def __init__(self, values=(), status='FAIL', starttime=None, endtime=None, parent=None): + super().__init__(values, parent) + self.status = status + self.starttime = starttime + self.endtime = endtime + self.body = None + + @setter + def body(self, body): + """Messages as a :class:`~.Body` object. + + Typically contains the message that caused the error. + """ + return self.body_class(self, body) + + @property + @deprecated + def kwname(self): + return self.values[0] + + @property + @deprecated + def args(self): + return self.values[1:] + + @property + @deprecated + def doc(self): + return '' + + @Body.register @Branches.register @Iterations.register diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index b0e5514164b..5557428ea63 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -107,7 +107,7 @@ class TestHandler(ElementHandler): tag = 'test' # 'tags' is for RF < 4 compatibility. children = frozenset(('doc', 'tags', 'tag', 'timeout', 'status', 'kw', 'if', 'for', - 'try', 'while', 'return', 'break', 'continue', 'msg')) + 'try', 'while', 'return', 'break', 'continue', 'error', 'msg')) def start(self, elem, result): lineno = elem.get('line') @@ -122,7 +122,7 @@ class KeywordHandler(ElementHandler): # 'arguments', 'assign' and 'tags' are for RF < 4 compatibility. children = frozenset(('doc', 'arguments', 'arg', 'assign', 'var', 'tags', 'tag', 'timeout', 'status', 'msg', 'kw', 'if', 'for', 'try', - 'while', 'return', 'break', 'continue')) + 'while', 'return', 'break', 'continue', 'error')) def start(self, elem, result): elem_type = elem.get('type') @@ -197,7 +197,7 @@ def start(self, elem, result): class IterationHandler(ElementHandler): tag = 'iter' children = frozenset(('var', 'doc', 'status', 'kw', 'if', 'for', 'msg', 'try', - 'while', 'return', 'break', 'continue')) + 'while', 'return', 'break', 'continue', 'error')) def start(self, elem, result): return result.body.create_iteration() @@ -216,7 +216,7 @@ def start(self, elem, result): class BranchHandler(ElementHandler): tag = 'branch' children = frozenset(('status', 'kw', 'if', 'for', 'try', 'while', 'msg', 'doc', - 'return', 'pattern', 'break', 'continue')) + 'return', 'pattern', 'break', 'continue', 'error')) def start(self, elem, result): return result.body.create_branch(**elem.attrib) @@ -267,6 +267,15 @@ def start(self, elem, result): return result.body.create_break() +@ElementHandler.register +class ErrorHandler(ElementHandler): + tag = 'error' + children = frozenset(('status', 'msg', 'value')) + + def start(self, elem, result): + return result.body.create_error() + + @ElementHandler.register class MessageHandler(ElementHandler): tag = 'msg' diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 0d1a32d7cdf..23d4eb1a383 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -263,6 +263,10 @@ def visit_Break(self, node): self.test.body.create_break(lineno=node.lineno, error=format_error(node.errors)) + def visit_Error(self, node): + self.test.body.create_error(lineno=node.lineno, + values=node.values, error=format_error(node.errors)) + class KeywordBuilder(NodeVisitor): @@ -330,6 +334,10 @@ def visit_If(self, node): def visit_Try(self, node): TryBuilder(self.kw).build(node) + def visit_Error(self, node): + self.kw.body.create_error(lineno=node.lineno, + values=node.values, error=format_error(node.errors)) + class ForBuilder(NodeVisitor): @@ -383,6 +391,10 @@ def visit_Break(self, node): self.model.body.create_break(lineno=node.lineno, error=format_error(node.errors)) + def visit_Error(self, node): + self.model.body.create_error(lineno=node.lineno, + values=node.values, error=format_error(node.errors)) + class IfBuilder(NodeVisitor): @@ -454,6 +466,10 @@ def visit_Break(self, node): self.model.body.create_break(lineno=node.lineno, error=format_error(node.errors)) + def visit_Error(self, node): + self.model.body.create_error(lineno=node.lineno, + values=node.values, error=format_error(node.errors)) + class TryBuilder(NodeVisitor): @@ -517,6 +533,10 @@ def visit_KeywordCall(self, node): def visit_TemplateArguments(self, node): self.template_error = 'Templates cannot be used with TRY.' + def visit_Error(self, node): + self.model.body.create_error(lineno=node.lineno, + values=node.values, error=format_error(node.errors)) + class WhileBuilder(NodeVisitor): @@ -568,6 +588,10 @@ def visit_Break(self, node): def visit_Continue(self, node): self.model.body.create_continue(error=format_error(node.errors)) + def visit_Error(self, node): + self.model.body.create_error(lineno=node.lineno, + values=node.values, error=format_error(node.errors)) + def format_error(errors): if not errors: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 5745c6d776a..426c7d7f3c7 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -43,7 +43,7 @@ from robot.model import BodyItem, create_fixture, Keywords, ModelObject from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, - Return as ReturnResult) + Error as ErrorResult, Return as ReturnResult) from robot.utils import setter from .bodyrunner import ForRunner, IfRunner, KeywordRunner, TryRunner, WhileRunner @@ -323,6 +323,33 @@ def to_dict(self): return data +@Body.register +class Error(model.Error): + __slots__ = ['lineno', 'error'] + + def __init__(self, values, parent=None, lineno=None, error=None): + super().__init__(values, parent) + self.lineno = lineno + self.error = error + + @property + def source(self): + return self.parent.source if self.parent is not None else None + + def run(self, context, run=True, templated=False): + with StatusReporter(self, ErrorResult(self.values), context, run): + if run: + raise DataError(self.error) + + def to_dict(self): + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + + class TestCase(model.TestCase): """Represents a single executable test case. From 2be59c5ac65178141edea2eb27b3b0fa8224ef64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 1 Mar 2023 14:52:56 +0200 Subject: [PATCH 0402/1592] atest: handling dangling END, ELSE and EXCEPT changes in tests --- atest/resources/TestCheckerLibrary.py | 3 + .../lineno_and_source.robot | 186 +++++++++--------- .../parsing/same_setting_multiple_times.robot | 45 +++-- atest/robot/parsing/test_case_settings.robot | 15 +- .../robot/parsing/user_keyword_settings.robot | 12 +- atest/robot/running/if/invalid_if.robot | 40 +++- .../robot/running/if/invalid_inline_if.robot | 2 +- .../timeouts_with_custom_messages.robot | 8 +- atest/testdata/cli/dryrun/reserved.robot | 6 +- atest/testdata/output/js_model.robot | 4 +- .../parsing/same_setting_multiple_times.robot | 13 +- .../testdata/parsing/test_case_settings.robot | 17 +- .../parsing/user_keyword_settings.robot | 16 +- atest/testdata/running/for/for.robot | 3 +- atest/testdata/running/if/invalid_if.robot | 40 ++++ .../running/if/invalid_inline_if.robot | 2 +- .../running/invalid_break_and_continue.robot | 8 +- atest/testdata/running/return.robot | 4 +- .../timeouts_with_custom_messages.robot | 4 + .../standard_libraries/reserved.robot | 8 +- utest/parsing/test_lexer.py | 11 +- .../test_statements_in_invalid_position.py | 27 ++- 22 files changed, 273 insertions(+), 201 deletions(-) diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index 0c5233d2878..bb1b1370a04 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -375,6 +375,9 @@ def start_while(self, while_): def start_while_iteration(self, iteration): self._add_kws_and_msgs(iteration) + def visit_error(self, error): + pass + def visit_errors(self, errors): errors.msgs = errors.messages errors.message_count = errors.msg_count = len(errors.messages) diff --git a/atest/robot/output/listener_interface/lineno_and_source.robot b/atest/robot/output/listener_interface/lineno_and_source.robot index fe0558f4c5a..68a7c75edc2 100644 --- a/atest/robot/output/listener_interface/lineno_and_source.robot +++ b/atest/robot/output/listener_interface/lineno_and_source.robot @@ -12,225 +12,225 @@ ${RESOURCE FILE} ${LISTENER DIR}/lineno_and_source.resource *** Test Cases *** Keyword START KEYWORD No Operation 6 NOT SET - END KEYWORD No Operation 6 PASS + \END KEYWORD No Operation 6 PASS User keyword START KEYWORD User Keyword 9 NOT SET START KEYWORD No Operation 101 NOT SET - END KEYWORD No Operation 101 PASS + \END KEYWORD No Operation 101 PASS START RETURN ${EMPTY} 102 NOT SET - END RETURN ${EMPTY} 102 PASS - END KEYWORD User Keyword 9 PASS + \END RETURN ${EMPTY} 102 PASS + \END KEYWORD User Keyword 9 PASS User keyword in resource START KEYWORD User Keyword In Resource 12 NOT SET START KEYWORD No Operation 3 NOT SET source=${RESOURCE FILE} - END KEYWORD No Operation 3 PASS source=${RESOURCE FILE} - END KEYWORD User Keyword In Resource 12 PASS + \END KEYWORD No Operation 3 PASS source=${RESOURCE FILE} + \END KEYWORD User Keyword In Resource 12 PASS Not run keyword START KEYWORD Fail 16 NOT SET - END KEYWORD Fail 16 FAIL + \END KEYWORD Fail 16 FAIL START KEYWORD Fail 17 NOT RUN - END KEYWORD Fail 17 NOT RUN + \END KEYWORD Fail 17 NOT RUN START KEYWORD Non-existing 18 NOT RUN - END KEYWORD Non-existing 18 NOT RUN + \END KEYWORD Non-existing 18 NOT RUN FOR START FOR \${x} IN [ first | second ] 21 NOT SET START ITERATION \${x} = first 21 NOT SET START KEYWORD No Operation 22 NOT SET - END KEYWORD No Operation 22 PASS - END ITERATION \${x} = first 21 PASS + \END KEYWORD No Operation 22 PASS + \END ITERATION \${x} = first 21 PASS START ITERATION \${x} = second 21 NOT SET START KEYWORD No Operation 22 NOT SET - END KEYWORD No Operation 22 PASS - END ITERATION \${x} = second 21 PASS - END FOR \${x} IN [ first | second ] 21 PASS + \END KEYWORD No Operation 22 PASS + \END ITERATION \${x} = second 21 PASS + \END FOR \${x} IN [ first | second ] 21 PASS FOR in keyword START KEYWORD FOR In Keyword 26 NOT SET START FOR \${x} IN [ once ] 105 NOT SET START ITERATION \${x} = once 105 NOT SET START KEYWORD No Operation 106 NOT SET - END KEYWORD No Operation 106 PASS - END ITERATION \${x} = once 105 PASS - END FOR \${x} IN [ once ] 105 PASS - END KEYWORD FOR In Keyword 26 PASS + \END KEYWORD No Operation 106 PASS + \END ITERATION \${x} = once 105 PASS + \END FOR \${x} IN [ once ] 105 PASS + \END KEYWORD FOR In Keyword 26 PASS FOR in IF START IF True 29 NOT SET START FOR \${x} | \${y} IN [ x | y ] 30 NOT SET START ITERATION \${x} = x, \${y} = y 30 NOT SET START KEYWORD No Operation 31 NOT SET - END KEYWORD No Operation 31 PASS - END ITERATION \${x} = x, \${y} = y 30 PASS - END FOR \${x} | \${y} IN [ x | y ] 30 PASS - END IF True 29 PASS + \END KEYWORD No Operation 31 PASS + \END ITERATION \${x} = x, \${y} = y 30 PASS + \END FOR \${x} | \${y} IN [ x | y ] 30 PASS + \END IF True 29 PASS FOR in resource START KEYWORD FOR In Resource 36 NOT SET START FOR \${x} IN [ once ] 6 NOT SET source=${RESOURCE FILE} START ITERATION \${x} = once 6 NOT SET source=${RESOURCE FILE} START KEYWORD Log 7 NOT SET source=${RESOURCE FILE} - END KEYWORD Log 7 PASS source=${RESOURCE FILE} - END ITERATION \${x} = once 6 PASS source=${RESOURCE FILE} - END FOR \${x} IN [ once ] 6 PASS source=${RESOURCE FILE} - END KEYWORD FOR In Resource 36 PASS + \END KEYWORD Log 7 PASS source=${RESOURCE FILE} + \END ITERATION \${x} = once 6 PASS source=${RESOURCE FILE} + \END FOR \${x} IN [ once ] 6 PASS source=${RESOURCE FILE} + \END KEYWORD FOR In Resource 36 PASS IF START IF 1 > 2 39 NOT RUN START KEYWORD Fail 40 NOT RUN - END KEYWORD Fail 40 NOT RUN - END IF 1 > 2 39 NOT RUN + \END KEYWORD Fail 40 NOT RUN + \END IF 1 > 2 39 NOT RUN START ELSE IF 1 < 2 41 NOT SET START KEYWORD No Operation 42 NOT SET - END KEYWORD No Operation 42 PASS - END ELSE IF 1 < 2 41 PASS + \END KEYWORD No Operation 42 PASS + \END ELSE IF 1 < 2 41 PASS START ELSE ${EMPTY} 43 NOT RUN START KEYWORD Fail 44 NOT RUN - END KEYWORD Fail 44 NOT RUN - END ELSE ${EMPTY} 43 NOT RUN + \END KEYWORD Fail 44 NOT RUN + \END ELSE ${EMPTY} 43 NOT RUN IF in keyword START KEYWORD IF In Keyword 48 NOT SET START IF True 110 NOT SET START KEYWORD No Operation 111 NOT SET - END KEYWORD No Operation 111 PASS + \END KEYWORD No Operation 111 PASS START RETURN ${EMPTY} 112 NOT SET - END RETURN ${EMPTY} 112 PASS - END IF True 110 PASS - END KEYWORD IF In Keyword 48 PASS + \END RETURN ${EMPTY} 112 PASS + \END IF True 110 PASS + \END KEYWORD IF In Keyword 48 PASS IF in FOR START FOR \${x} IN [ 1 | 2 ] 52 NOT SET START ITERATION \${x} = 1 52 NOT SET START IF \${x} == 1 53 NOT SET START KEYWORD Log 54 NOT SET - END KEYWORD Log 54 PASS - END IF \${x} == 1 53 PASS + \END KEYWORD Log 54 PASS + \END IF \${x} == 1 53 PASS START ELSE ${EMPTY} 55 NOT RUN START KEYWORD Fail 56 NOT RUN - END KEYWORD Fail 56 NOT RUN - END ELSE ${EMPTY} 55 NOT RUN - END ITERATION \${x} = 1 52 PASS + \END KEYWORD Fail 56 NOT RUN + \END ELSE ${EMPTY} 55 NOT RUN + \END ITERATION \${x} = 1 52 PASS START ITERATION \${x} = 2 52 NOT SET START IF \${x} == 1 53 NOT RUN START KEYWORD Log 54 NOT RUN - END KEYWORD Log 54 NOT RUN - END IF \${x} == 1 53 NOT RUN + \END KEYWORD Log 54 NOT RUN + \END IF \${x} == 1 53 NOT RUN START ELSE ${EMPTY} 55 NOT SET START KEYWORD Fail 56 NOT SET - END KEYWORD Fail 56 FAIL - END ELSE ${EMPTY} 55 FAIL - END ITERATION \${x} = 2 52 FAIL - END FOR \${x} IN [ 1 | 2 ] 52 FAIL + \END KEYWORD Fail 56 FAIL + \END ELSE ${EMPTY} 55 FAIL + \END ITERATION \${x} = 2 52 FAIL + \END FOR \${x} IN [ 1 | 2 ] 52 FAIL IF in resource START KEYWORD IF In Resource 61 NOT SET START IF True 11 NOT SET source=${RESOURCE FILE} START KEYWORD No Operation 12 NOT SET source=${RESOURCE FILE} - END KEYWORD No Operation 12 PASS source=${RESOURCE FILE} - END IF True 11 PASS source=${RESOURCE FILE} - END KEYWORD IF In Resource 61 PASS + \END KEYWORD No Operation 12 PASS source=${RESOURCE FILE} + \END IF True 11 PASS source=${RESOURCE FILE} + \END KEYWORD IF In Resource 61 PASS TRY START TRY ${EMPTY} 65 NOT SET START KEYWORD Fail 66 NOT SET - END KEYWORD Fail 66 FAIL - END TRY ${EMPTY} 65 FAIL + \END KEYWORD Fail 66 FAIL + \END TRY ${EMPTY} 65 FAIL START EXCEPT AS \${name} 67 NOT SET START TRY ${EMPTY} 68 NOT SET START KEYWORD Fail 69 NOT SET - END KEYWORD Fail 69 FAIL - END TRY ${EMPTY} 68 FAIL + \END KEYWORD Fail 69 FAIL + \END TRY ${EMPTY} 68 FAIL START FINALLY ${EMPTY} 70 NOT SET START KEYWORD Should Be Equal 71 NOT SET - END KEYWORD Should Be Equal 71 PASS - END FINALLY ${EMPTY} 70 PASS - END EXCEPT AS \${name} 67 FAIL + \END KEYWORD Should Be Equal 71 PASS + \END FINALLY ${EMPTY} 70 PASS + \END EXCEPT AS \${name} 67 FAIL START ELSE ${EMPTY} 73 NOT RUN START KEYWORD Fail 74 NOT RUN - END KEYWORD Fail 74 NOT RUN - END ELSE ${EMPTY} 73 NOT RUN + \END KEYWORD Fail 74 NOT RUN + \END ELSE ${EMPTY} 73 NOT RUN TRY in keyword START KEYWORD TRY In Keyword 78 NOT SET START TRY ${EMPTY} 116 NOT SET START RETURN ${EMPTY} 117 NOT SET - END RETURN ${EMPTY} 117 PASS + \END RETURN ${EMPTY} 117 PASS START KEYWORD Fail 118 NOT RUN - END KEYWORD Fail 118 NOT RUN - END TRY ${EMPTY} 116 PASS + \END KEYWORD Fail 118 NOT RUN + \END TRY ${EMPTY} 116 PASS START EXCEPT No match AS \${var} 119 NOT RUN START KEYWORD Fail 120 NOT RUN - END KEYWORD Fail 120 NOT RUN - END EXCEPT No match AS \${var} 119 NOT RUN + \END KEYWORD Fail 120 NOT RUN + \END EXCEPT No match AS \${var} 119 NOT RUN START EXCEPT No | Match | 2 AS \${x} 121 NOT RUN START KEYWORD Fail 122 NOT RUN - END KEYWORD Fail 122 NOT RUN - END EXCEPT No | Match | 2 AS \${x} 121 NOT RUN + \END KEYWORD Fail 122 NOT RUN + \END EXCEPT No | Match | 2 AS \${x} 121 NOT RUN START EXCEPT ${EMPTY} 123 NOT RUN START KEYWORD Fail 124 NOT RUN - END KEYWORD Fail 124 NOT RUN - END EXCEPT ${EMPTY} 123 NOT RUN - END KEYWORD TRY In Keyword 78 PASS + \END KEYWORD Fail 124 NOT RUN + \END EXCEPT ${EMPTY} 123 NOT RUN + \END KEYWORD TRY In Keyword 78 PASS TRY in resource START KEYWORD TRY In Resource 81 NOT SET START TRY ${EMPTY} 16 NOT SET source=${RESOURCE FILE} START KEYWORD Log 17 NOT SET source=${RESOURCE FILE} - END KEYWORD Log 17 PASS source=${RESOURCE FILE} - END TRY ${EMPTY} 16 PASS source=${RESOURCE FILE} + \END KEYWORD Log 17 PASS source=${RESOURCE FILE} + \END TRY ${EMPTY} 16 PASS source=${RESOURCE FILE} START FINALLY ${EMPTY} 18 NOT SET source=${RESOURCE FILE} START KEYWORD Log 19 NOT SET source=${RESOURCE FILE} - END KEYWORD Log 19 PASS source=${RESOURCE FILE} - END FINALLY ${EMPTY} 18 PASS source=${RESOURCE FILE} - END KEYWORD TRY In Resource 81 PASS + \END KEYWORD Log 19 PASS source=${RESOURCE FILE} + \END FINALLY ${EMPTY} 18 PASS source=${RESOURCE FILE} + \END KEYWORD TRY In Resource 81 PASS Run Keyword START KEYWORD Run Keyword 84 NOT SET START KEYWORD Log 84 NOT SET - END KEYWORD Log 84 PASS - END KEYWORD Run Keyword 84 PASS + \END KEYWORD Log 84 PASS + \END KEYWORD Run Keyword 84 PASS START KEYWORD Run Keyword If 85 NOT SET START KEYWORD User Keyword 85 NOT SET START KEYWORD No Operation 101 NOT SET - END KEYWORD No Operation 101 PASS + \END KEYWORD No Operation 101 PASS START RETURN ${EMPTY} 102 NOT SET - END RETURN ${EMPTY} 102 PASS - END KEYWORD User Keyword 85 PASS - END KEYWORD Run Keyword If 85 PASS + \END RETURN ${EMPTY} 102 PASS + \END KEYWORD User Keyword 85 PASS + \END KEYWORD Run Keyword If 85 PASS Run Keyword in keyword START KEYWORD Run Keyword in keyword 89 NOT SET START KEYWORD Run Keyword 128 NOT SET START KEYWORD No Operation 128 NOT SET - END KEYWORD No Operation 128 PASS - END KEYWORD Run Keyword 128 PASS - END KEYWORD Run Keyword in keyword 89 PASS + \END KEYWORD No Operation 128 PASS + \END KEYWORD Run Keyword 128 PASS + \END KEYWORD Run Keyword in keyword 89 PASS Run Keyword in resource START KEYWORD Run Keyword in resource 92 NOT SET START KEYWORD Run Keyword 23 NOT SET source=${RESOURCE FILE} START KEYWORD Log 23 NOT SET source=${RESOURCE FILE} - END KEYWORD Log 23 PASS source=${RESOURCE FILE} - END KEYWORD Run Keyword 23 PASS source=${RESOURCE FILE} - END KEYWORD Run Keyword in resource 92 PASS + \END KEYWORD Log 23 PASS source=${RESOURCE FILE} + \END KEYWORD Run Keyword 23 PASS source=${RESOURCE FILE} + \END KEYWORD Run Keyword in resource 92 PASS In setup and teardown START SETUP User Keyword 95 NOT SET START KEYWORD No Operation 101 NOT SET - END KEYWORD No Operation 101 PASS + \END KEYWORD No Operation 101 PASS START RETURN ${EMPTY} 102 NOT SET - END RETURN ${EMPTY} 102 PASS - END SETUP User Keyword 95 PASS + \END RETURN ${EMPTY} 102 PASS + \END SETUP User Keyword 95 PASS START KEYWORD No Operation 96 NOT SET - END KEYWORD No Operation 96 PASS + \END KEYWORD No Operation 96 PASS START TEARDOWN Run Keyword 97 NOT SET START KEYWORD Log 97 NOT SET - END KEYWORD Log 97 PASS - END TEARDOWN Run Keyword 97 PASS + \END KEYWORD Log 97 PASS + \END TEARDOWN Run Keyword 97 PASS Test [Template] Expect test @@ -257,7 +257,7 @@ Test Suite START SUITE Lineno And Source - END SUITE Lineno And Source status=FAIL + \END SUITE Lineno And Source status=FAIL [Teardown] Validate suite *** Keywords *** diff --git a/atest/robot/parsing/same_setting_multiple_times.robot b/atest/robot/parsing/same_setting_multiple_times.robot index 4e5c3224ff6..2e78f2839cc 100644 --- a/atest/robot/parsing/same_setting_multiple_times.robot +++ b/atest/robot/parsing/same_setting_multiple_times.robot @@ -47,65 +47,64 @@ Test Timeout Test [Documentation] ${tc} = Check Test Case Test Settings - Should Be Equal ${tc.doc} T1 - Setting multiple times 12 32 Documentation + Check Keyword Data ${tc.kws[0]} ${EMPTY} type=ERROR status=FAIL + Should Be Equal ${tc.kws[0].values[0]} [Documentation] + Setting multiple times 12 40 Documentation Test [Tags] Check Test Tags Test Settings - Setting multiple times 13 34 Tags - Setting multiple times 14 35 Tags + Setting multiple times 13 42 Tags + Setting multiple times 19 53 Tags Test [Setup] ${tc} = Check Test Case Test Settings Should Be Equal ${tc.setup.name} BuiltIn.Log Many - Setting multiple times 15 37 Setup + Setting multiple times 14 44 Setup Test [Teardown] ${tc} = Check Test Case Test Settings Teardown Should Not Be Defined ${tc} - Setting multiple times 16 39 Teardown - Setting multiple times 17 40 Teardown + Setting multiple times 15 46 Teardown + Setting multiple times 16 47 Teardown Test [Template] ${tc} = Check Test Case Test Settings - Check Keyword Data ${tc.kws[0]} BuiltIn.Log args=No Operation - Setting multiple times 18 42 Template + Check Keyword Data ${tc.kws[7]} BuiltIn.Log args=No Operation + Setting multiple times 17 49 Template Test [Timeout] ${tc} = Check Test Case Test Settings Should Be Equal ${tc.timeout} 2 seconds - Setting multiple times 19 44 Timeout + Setting multiple times 18 51 Timeout Keyword [Arguments] ${tc} = Check Test Case Keyword Settings - Check Keyword Data ${tc.kws[0]} Keyword Settings assign=\${ret} args=1, 2, 3 tags=K1 + Check Keyword Data ${tc.kws[0]} Keyword Settings assign=\${ret} args=1, 2, 3 tags=K1 status=FAIL Check Log Message ${tc.kws[0].msgs[0]} Arguments: [ \${a1}='1' | \${a2}='2' | \${a3}='3' ] TRACE - Setting multiple times 20 55 Arguments + Setting multiple times 20 64 Arguments Keyword [Documentation] ${tc} = Check Test Case Keyword Settings Should Be Equal ${tc.kws[0].doc} ${EMPTY} - Setting multiple times 21 57 Documentation - Setting multiple times 22 58 Documentation + Setting multiple times 21 66 Documentation + Setting multiple times 22 67 Documentation Keyword [Tags] ${tc} = Check Test Case Keyword Settings Should Be True list($tc.kws[0].tags) == ['K1'] - Setting multiple times 23 60 Tags + Setting multiple times 23 69 Tags Keyword [Timeout] ${tc} = Check Test Case Keyword Settings Should Be Equal ${tc.kws[0].timeout} ${NONE} - Setting multiple times 24 62 Timeout - Setting multiple times 25 63 Timeout + Setting multiple times 24 71 Timeout + Setting multiple times 25 72 Timeout Keyword [Return] - ${tc} = Check Test Case Keyword Settings - Check Log Message ${tc.kws[0].msgs[1]} Return: 'R0' TRACE - Check Log Message ${tc.kws[0].msgs[2]} \${ret} = R0 - Setting multiple times 26 66 Return - Setting multiple times 27 67 Return - Setting multiple times 28 68 Return + Check Test Case Keyword Settings + Setting multiple times 26 75 Return + Setting multiple times 27 76 Return + Setting multiple times 28 77 Return *** Keywords *** Setting multiple times diff --git a/atest/robot/parsing/test_case_settings.robot b/atest/robot/parsing/test_case_settings.robot index 5ca3043154e..7e1fa10657e 100644 --- a/atest/robot/parsing/test_case_settings.robot +++ b/atest/robot/parsing/test_case_settings.robot @@ -147,11 +147,6 @@ Template Timeout Verify Timeout 1 day -Timeout with message - Verify Timeout 1 minute 39 seconds 999 milliseconds - Error In File 0 parsing/test_case_settings.robot 184 - ... Setting 'Timeout' accepts only one value, got 2. - Default timeout Verify Timeout 1 minute 39 seconds 999 milliseconds @@ -176,20 +171,20 @@ Multiple settings Invalid setting Check Test Case ${TEST NAME} - Error In File 1 parsing/test_case_settings.robot 217 + Error In File 0 parsing/test_case_settings.robot 214 ... Non-existing setting 'Invalid'. Setting not valid with tests Check Test Case ${TEST NAME} - Error In File 2 parsing/test_case_settings.robot 221 + Error In File 1 parsing/test_case_settings.robot 219 ... Setting 'Metadata' is not allowed with tests or tasks. Check Test Case ${TEST NAME} - Error In File 3 parsing/test_case_settings.robot 222 + Error In File 2 parsing/test_case_settings.robot 220 ... Setting 'Arguments' is not allowed with tests or tasks. Small typo should provide recommendation - Check Test Doc ${TEST NAME} - Error In File 4 parsing/test_case_settings.robot 226 + Check Test Case ${TEST NAME} + Error In File 3 parsing/test_case_settings.robot 227 ... SEPARATOR=\n ... Non-existing setting 'Doc U ment a tion'. Did you mean: ... ${SPACE*4}Documentation diff --git a/atest/robot/parsing/user_keyword_settings.robot b/atest/robot/parsing/user_keyword_settings.robot index efec9ad6584..a6c249f2914 100644 --- a/atest/robot/parsing/user_keyword_settings.robot +++ b/atest/robot/parsing/user_keyword_settings.robot @@ -94,28 +94,26 @@ Multiple settings Invalid setting Check Test Case ${TEST NAME} - Error In File 0 parsing/user_keyword_settings.robot 198 + Error In File 0 parsing/user_keyword_settings.robot 202 ... Non-existing setting 'Invalid Setting'. - Error In File 1 parsing/user_keyword_settings.robot 202 - ... Non-existing setting 'invalid'. Setting not valid with user keywords Check Test Case ${TEST NAME} - Error In File 2 parsing/user_keyword_settings.robot 206 + Error In File 1 parsing/user_keyword_settings.robot 206 ... Setting 'Metadata' is not allowed with user keywords. Check Test Case ${TEST NAME} - Error In File 3 parsing/user_keyword_settings.robot 207 + Error In File 2 parsing/user_keyword_settings.robot 207 ... Setting 'Template' is not allowed with user keywords. Small typo should provide recommendation Check Test Case ${TEST NAME} - Error In File 4 parsing/user_keyword_settings.robot 211 + Error In File 3 parsing/user_keyword_settings.robot 211 ... SEPARATOR=\n ... Non-existing setting 'Doc Umentation'. Did you mean: ... ${SPACE*4}Documentation Invalid empty line continuation in arguments should throw an error - Error in File 5 parsing/user_keyword_settings.robot 214 + Error in File 4 parsing/user_keyword_settings.robot 214 ... Creating keyword 'Invalid empty line continuation in arguments should throw an error' failed: ... Invalid argument specification: Invalid argument syntax ''. diff --git a/atest/robot/running/if/invalid_if.robot b/atest/robot/running/if/invalid_if.robot index 76c97c9277b..6f97c00e030 100644 --- a/atest/robot/running/if/invalid_if.robot +++ b/atest/robot/running/if/invalid_if.robot @@ -58,6 +58,34 @@ ELSE after ELSE ELSE IF after ELSE FAIL NOT RUN NOT RUN +Dangling ELSE + [Template] Check Test Case + ${TEST NAME} + +Dangling ELSE inside FOR + [Template] Check Test Case + ${TEST NAME} + +Dangling ELSE inside WHILE + [Template] Check Test Case + ${TEST NAME} + +Dangling ELSE IF + [Template] Check Test Case + ${TEST NAME} + +Dangling ELSE IF inside FOR + [Template] Check Test Case + ${TEST NAME} + +Dangling ELSE IF inside WHILE + [Template] Check Test Case + ${TEST NAME} + +Dangling ELSE IF inside TRY + [Template] Check Test Case + ${TEST NAME} + Invalid IF inside FOR FAIL @@ -65,16 +93,16 @@ Multiple errors FAIL NOT RUN NOT RUN NOT RUN NOT RUN Invalid data causes syntax error - [Template] NONE - Check Test Case ${TEST NAME} + [Template] Check Test Case + ${TEST NAME} Invalid condition causes normal error - [Template] NONE - Check Test Case ${TEST NAME} + [Template] Check Test Case + ${TEST NAME} Non-existing variable in condition causes normal error - [Template] NONE - Check Test Case ${TEST NAME} + [Template] Check Test Case + ${TEST NAME} *** Keywords *** Branch statuses should be diff --git a/atest/robot/running/if/invalid_inline_if.robot b/atest/robot/running/if/invalid_inline_if.robot index 4609f4f9d88..c8c7157ca32 100644 --- a/atest/robot/running/if/invalid_inline_if.robot +++ b/atest/robot/running/if/invalid_inline_if.robot @@ -68,7 +68,7 @@ Invalid END after inline header Check IF/ELSE Status PASS root=${tc.body[0]} Check Log Message ${tc.body[0].body[0].body[0].body[0]} Executed inside inline IF Check Log Message ${tc.body[1].body[0]} Executed outside IF - Check Keyword Data ${tc.body[2]} Reserved.End status=FAIL + Check Keyword Data ${tc.body[2]} ${EMPTY} type=ERROR status=FAIL Assign in IF branch FAIL diff --git a/atest/robot/running/timeouts_with_custom_messages.robot b/atest/robot/running/timeouts_with_custom_messages.robot index 46127b1ae28..8f186c3447b 100644 --- a/atest/robot/running/timeouts_with_custom_messages.robot +++ b/atest/robot/running/timeouts_with_custom_messages.robot @@ -9,19 +9,19 @@ Default Test Timeout Message Test Timeout Message Check Test Case ${TEST NAME} - Using more than one value with timeout should error 1 9 2 + Using more than one value with timeout should error 1 10 2 Test Timeout Message In Multiple Columns Check Test Case ${TEST NAME} - Using more than one value with timeout should error 2 13 7 + Using more than one value with timeout should error 2 15 7 Keyword Timeout Message Check Test Case ${TEST NAME} - Using more than one value with timeout should error 3 26 2 + Using more than one value with timeout should error 3 30 2 Keyword Timeout Message In Multiple Columns Check Test Case ${TEST NAME} - Using more than one value with timeout should error 4 30 7 + Using more than one value with timeout should error 4 34 7 *** Keywords *** Using more than one value with timeout should error diff --git a/atest/testdata/cli/dryrun/reserved.robot b/atest/testdata/cli/dryrun/reserved.robot index 71b2dcb607a..d5fd1a62559 100644 --- a/atest/testdata/cli/dryrun/reserved.robot +++ b/atest/testdata/cli/dryrun/reserved.robot @@ -9,7 +9,7 @@ Valid END after For ... ... 1) 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. ... - ... 2) 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. + ... 2) END is not allowed in this context. For ${x} IN invalid Log ${x} END @@ -72,11 +72,11 @@ Reserved inside IF ... ... 2) 'If' is a reserved keyword. It must be an upper case 'IF' when used as a marker. ... - ... 3) 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. + ... 3) END is not allowed in this context. ... ... 4) 'Return' is a reserved keyword. ... - ... 5) 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. + ... 5) END is not allowed in this context. IF True For ${x} IN invalid Log ${x} diff --git a/atest/testdata/output/js_model.robot b/atest/testdata/output/js_model.robot index f5e65b2e45b..dae7aad0788 100644 --- a/atest/testdata/output/js_model.robot +++ b/atest/testdata/output/js_model.robot @@ -7,7 +7,7 @@ Resource </script> *** Test Cases *** </script> [Documentation] FAIL </script> - [Timeout] 10s </script> + [Timeout] 10s Log </script> </script> @@ -18,5 +18,5 @@ HTML </script> *** Keywords *** </script> [Documentation] </script> - [Timeout] 10s </script> + [Timeout] 10s Fail </script> diff --git a/atest/testdata/parsing/same_setting_multiple_times.robot b/atest/testdata/parsing/same_setting_multiple_times.robot index 980d55ac5b8..98494ef7bac 100644 --- a/atest/testdata/parsing/same_setting_multiple_times.robot +++ b/atest/testdata/parsing/same_setting_multiple_times.robot @@ -28,11 +28,18 @@ Use Defaults Sleep 0.1s Test Settings - [Documentation] T1 + [Documentation] FAIL Several failures occurred:\n\n + ... 1) Setting 'Documentation' is allowed only once. Only the first value is used.\n\n + ... 2) Setting 'Tags' is allowed only once. Only the first value is used.\n\n + ... 3) Setting 'Setup' is allowed only once. Only the first value is used.\n\n + ... 4) Setting 'Teardown' is allowed only once. Only the first value is used.\n\n + ... 5) Setting 'Teardown' is allowed only once. Only the first value is used.\n\n + ... 6) Setting 'Template' is allowed only once. Only the first value is used.\n\n + ... 7) Setting 'Timeout' is allowed only once. Only the first value is used.\n\n + ... 8) Setting 'Tags' is allowed only once. Only the first value is used. [Documentation] FAIL 2 s [Tags] [Tags] T1 - [Tags] T2 [Setup] Log Many Own [Setup] stuff here [Teardown] @@ -43,8 +50,10 @@ Test Settings [Timeout] 2 s [Timeout] 2 ms No Operation + [Tags] T2 Keyword Settings + [Documentation] FAIL Setting 'Arguments' is allowed only once. Only the first value is used. [Template] NONE ${ret} = Keyword Settings 1 2 3 Should Be Equal ${ret} R0 diff --git a/atest/testdata/parsing/test_case_settings.robot b/atest/testdata/parsing/test_case_settings.robot index 930ffb1d62e..0a746f72046 100644 --- a/atest/testdata/parsing/test_case_settings.robot +++ b/atest/testdata/parsing/test_case_settings.robot @@ -180,10 +180,6 @@ Timeout [Timeout] 1d No Operation -Timeout with message - [Timeout] 666 Message not supported since RF 3.2 - No Operation - Default timeout No Operation @@ -203,7 +199,7 @@ Invalid timeout [Documentation] FAIL Setup failed: ... Setting test timeout failed: Invalid time string 'invalid'. [Timeout] invalid - No Operation + Fail Should not be run Multiple settings [Documentation] Documentation for this test case @@ -214,14 +210,19 @@ Multiple settings [Teardown] Log Test case teardown Invalid setting + [Documentation] FAIL Non-existing setting 'Invalid'. [Invalid] This is invalid - No Operation + Fail Should not be run Setting not valid with tests + [Documentation] FAIL Setting 'Metadata' is not allowed with tests or tasks. [Metadata] Not valid. [Arguments] Not valid. - No Operation + Fail Should not be run Small typo should provide recommendation + [Documentation] FAIL + ... Non-existing setting 'Doc U ment a tion'. Did you mean: + ... ${SPACE*4}Documentation [Doc U ment a tion] This actually worked before RF 3.2. - No Operation + Fail Should not be run diff --git a/atest/testdata/parsing/user_keyword_settings.robot b/atest/testdata/parsing/user_keyword_settings.robot index 3afb6129f37..cb3ab92d0b1 100644 --- a/atest/testdata/parsing/user_keyword_settings.robot +++ b/atest/testdata/parsing/user_keyword_settings.robot @@ -84,14 +84,18 @@ Multiple settings Should Be Equal ${ret} Hello World!! Invalid setting - [Documentation] FAIL Keywords are executed regardless invalid settings - Invalid passing - Invalid failing + [Documentation] FAIL Non-existing setting 'Invalid Setting'. + Invalid + Invalid Setting not valid with user keywords + [Documentation] FAIL Setting 'Metadata' is not allowed with user keywords. Setting not valid with user keywords Small typo should provide recommendation + [Documentation] FAIL + ... Non-existing setting 'Doc Umentation'. Did you mean: + ... ${SPACE*4}Documentation Small typo should provide recommendation *** Keywords *** @@ -194,14 +198,10 @@ Multiple settings [Teardown] Log Teardown ${name} [Return] Hello ${name}!! -Invalid passing +Invalid [Invalid Setting] This is invalid No Operation -Invalid failing - [invalid] Yes, this is also invalid - Fail Keywords are executed regardless invalid settings - Setting not valid with user keywords [Metadata] Not valid. [Template] Not valid. diff --git a/atest/testdata/running/for/for.robot b/atest/testdata/running/for/for.robot index c53d9d03448..80104a79467 100644 --- a/atest/testdata/running/for/for.robot +++ b/atest/testdata/running/for/for.robot @@ -7,7 +7,7 @@ Variables binary_list.py @{RESULT} ${WRONG VALUES} Number of FOR loop values should be multiple of its variables. ${INVALID FOR} 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. -${INVALID END} 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. +${INVALID END} END is not allowed in this context. *** Test Cases *** Simple loop @@ -520,6 +520,7 @@ Nested For In UK 2 Fail This ought to be enough Invalid END usage in UK + No Operation END Header at the end of file diff --git a/atest/testdata/running/if/invalid_if.robot b/atest/testdata/running/if/invalid_if.robot index 946fb9dd609..5fa9167b999 100644 --- a/atest/testdata/running/if/invalid_if.robot +++ b/atest/testdata/running/if/invalid_if.robot @@ -135,6 +135,46 @@ ELSE IF after ELSE Log hei END +Dangling ELSE + [Documentation] FAIL ELSE is not allowed in this context. + ELSE + +Dangling ELSE inside FOR + [Documentation] FAIL ELSE is not allowed in this context. + FOR ${i} IN 1 2 + ELSE + END + +Dangling ELSE inside WHILE + [Documentation] FAIL ELSE is not allowed in this context. + WHILE ${True} + ELSE + END + +Dangling ELSE IF + [Documentation] FAIL ELSE IF is not allowed in this context. + ELSE IF + +Dangling ELSE IF inside FOR + [Documentation] FAIL ELSE IF is not allowed in this context. + FOR ${i} IN 1 2 + ELSE IF + END + +Dangling ELSE IF inside WHILE + [Documentation] FAIL ELSE IF is not allowed in this context. + WHILE ${True} + ELSE IF + END + +Dangling ELSE IF inside TRY + [Documentation] FAIL ELSE IF is not allowed in this context. + TRY + Fail + EXCEPT + ELSE IF + END + Invalid IF inside FOR [Documentation] FAIL ELSE IF not allowed after ELSE. FOR ${value} IN 1 2 3 diff --git a/atest/testdata/running/if/invalid_inline_if.robot b/atest/testdata/running/if/invalid_inline_if.robot index ef32263862f..9f2d82b06bc 100644 --- a/atest/testdata/running/if/invalid_inline_if.robot +++ b/atest/testdata/running/if/invalid_inline_if.robot @@ -102,7 +102,7 @@ Unnecessary END IF False Not run ELSE No operation END Invalid END after inline header - [Documentation] FAIL 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. + [Documentation] FAIL END is not allowed in this context. IF True Log Executed inside inline IF Log Executed outside IF END diff --git a/atest/testdata/running/invalid_break_and_continue.robot b/atest/testdata/running/invalid_break_and_continue.robot index 63190c36da6..dce80007165 100644 --- a/atest/testdata/running/invalid_break_and_continue.robot +++ b/atest/testdata/running/invalid_break_and_continue.robot @@ -1,12 +1,12 @@ *** Test cases *** CONTINUE in test case - [Documentation] FAIL CONTINUE can only be used inside a loop. + [Documentation] FAIL CONTINUE is not allowed in this context. Log all good CONTINUE Fail Should not be executed CONTINUE in keyword - [Documentation] FAIL CONTINUE can only be used inside a loop. + [Documentation] FAIL CONTINUE is not allowed in this context. Continue in keyword CONTINUE in IF @@ -73,13 +73,13 @@ CONTINUE with argument in WHILE Fail Should not be executed BREAK in test case - [Documentation] FAIL BREAK can only be used inside a loop. + [Documentation] FAIL BREAK is not allowed in this context. Log all good BREAK Fail Should not be executed BREAK in keyword - [Documentation] FAIL BREAK can only be used inside a loop. + [Documentation] FAIL BREAK is not allowed in this context. Break in keyword BREAK in IF diff --git a/atest/testdata/running/return.robot b/atest/testdata/running/return.robot index 534ca44a246..7abcf6a31da 100644 --- a/atest/testdata/running/return.robot +++ b/atest/testdata/running/return.robot @@ -43,11 +43,11 @@ In nested FOR/IF structure Should be equal ${x} ${6} In test - [Documentation] FAIL RETURN can only be used inside a user keyword. + [Documentation] FAIL RETURN is not allowed in this context. RETURN In test with values - [Documentation] FAIL RETURN can only be used inside a user keyword. + [Documentation] FAIL RETURN is not allowed in this context. RETURN v1 v2 In test inside IF diff --git a/atest/testdata/running/timeouts_with_custom_messages.robot b/atest/testdata/running/timeouts_with_custom_messages.robot index b4f4423ea79..e0e3c704367 100644 --- a/atest/testdata/running/timeouts_with_custom_messages.robot +++ b/atest/testdata/running/timeouts_with_custom_messages.robot @@ -6,19 +6,23 @@ Default Test Timeout Message No operation Test Timeout Message + [Documentation] FAIL Setting 'Timeout' accepts only one value, got 2. [Timeout] 100 milliseconds My test timeout message No operation Test Timeout Message In Multiple Columns + [Documentation] FAIL Setting 'Timeout' accepts only one value, got 7. [Timeout] 1 millisecond My test timeout message ... in ... multiple columns No operation Keyword Timeout Message + [Documentation] FAIL Setting 'Timeout' accepts only one value, got 2. Keyword Timeout Message Keyword Timeout Message In Multiple Columns + [Documentation] FAIL Setting 'Timeout' accepts only one value, got 7. Keyword Timeout Message In Multiple Columns *** Keywords *** diff --git a/atest/testdata/standard_libraries/reserved.robot b/atest/testdata/standard_libraries/reserved.robot index 0b172ffb8fe..5696282997e 100644 --- a/atest/testdata/standard_libraries/reserved.robot +++ b/atest/testdata/standard_libraries/reserved.robot @@ -3,7 +3,7 @@ Markers should get note about case 1 [Documentation] FAIL 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. For ${var} IN some items Log ${var} - END + EnD Markers should get note about case 2 [Documentation] FAIL 'If' is a reserved keyword. It must be an upper case 'IF' when used as a marker. @@ -19,15 +19,15 @@ Others should just be reserved 2 'End' gets extra note [Documentation] FAIL 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. - END + End 'Else' gets extra note [Documentation] FAIL 'Else' is a reserved keyword. It must be an upper case 'ELSE' and follow an opening 'IF' when used as a marker. - ELSE Log ${message} + Else Log ${message} 'Else if' gets extra note [Documentation] FAIL 'Else If' is a reserved keyword. It must be an upper case 'ELSE IF' and follow an opening 'IF' when used as a marker. - ELSE if Log ${message} + Else if Log ${message} 'Elif' gets extra note [Documentation] FAIL 'Elif' is a reserved keyword. The marker to use with 'IF' is 'ELSE IF'. diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index e65be27b54e..fba3ca11bf4 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -1905,9 +1905,8 @@ def test_in_keyword(self): self._verify(data, expected) def test_in_test(self): - # This is not valid usage but that's not recognized during lexing. data = ' RETURN' - expected = [(T.RETURN_STATEMENT, 'RETURN', 3, 4), + expected = [(T.ERROR, 'RETURN', 3, 4, 'RETURN is not allowed in this context.'), (T.EOS, '', 3, 10)] self._verify(data, expected, test=True) @@ -1966,13 +1965,13 @@ class TestContinue(unittest.TestCase): def test_in_keyword(self): data = ' CONTINUE' - expected = [(T.CONTINUE, 'CONTINUE', 3, 4), + expected = [(T.ERROR, 'CONTINUE', 3, 4, 'CONTINUE is not allowed in this context.'), (T.EOS, '', 3, 12)] self._verify(data, expected) def test_in_test(self): data = ' CONTINUE' - expected = [(T.CONTINUE, 'CONTINUE', 3, 4), + expected = [(T.ERROR, 'CONTINUE', 3, 4, 'CONTINUE is not allowed in this context.'), (T.EOS, '', 3, 12)] self._verify(data, expected, test=True) @@ -2082,13 +2081,13 @@ class TestBreak(unittest.TestCase): def test_in_keyword(self): data = ' BREAK' - expected = [(T.BREAK, 'BREAK', 3, 4), + expected = [(T.ERROR, 'BREAK', 3, 4, 'BREAK is not allowed in this context.'), (T.EOS, '', 3, 9)] self._verify(data, expected) def test_in_test(self): data = ' BREAK' - expected = [(T.BREAK, 'BREAK', 3, 4), + expected = [(T.ERROR, 'BREAK', 3, 4, 'BREAK is not allowed in this context.'), (T.EOS, '', 3, 9)] self._verify(data, expected, test=True) diff --git a/utest/parsing/test_statements_in_invalid_position.py b/utest/parsing/test_statements_in_invalid_position.py index 892879a2a9c..ec792614e43 100644 --- a/utest/parsing/test_statements_in_invalid_position.py +++ b/utest/parsing/test_statements_in_invalid_position.py @@ -1,7 +1,7 @@ import unittest from robot.parsing import get_model, Token -from robot.parsing.model.statements import ReturnStatement, Break, Continue +from robot.parsing.model.statements import Break, Continue, Error, ReturnStatement from parsing_test_utils import assert_model, RemoveNonDataTokensVisitor @@ -22,9 +22,8 @@ def test_in_test_case_body(self): Example RETURN''', data_only=data_only) node = model.sections[0].body[0].body[0] - expected = ReturnStatement( - [Token(Token.RETURN_STATEMENT, 'RETURN', 3, 4)], - errors=('RETURN can only be used inside a user keyword.',) + expected = Error( + [Token(Token.ERROR, 'RETURN', 3, 4, 'RETURN is not allowed in this context.')], ) remove_non_data_nodes_and_assert(node, expected, data_only) @@ -173,9 +172,8 @@ def test_in_test_case_body(self): Example BREAK''', data_only=data_only) node = model.sections[0].body[0].body[0] - expected = Break( - [Token(Token.BREAK, 'BREAK', 3, 4)], - errors=('BREAK can only be used inside a loop.',) + expected = Error( + [Token(Token.ERROR, 'BREAK', 3, 4, 'BREAK is not allowed in this context.')], ) remove_non_data_nodes_and_assert(node, expected, data_only) @@ -243,9 +241,8 @@ def test_in_uk_body(self): Example BREAK''', data_only=data_only) node = model.sections[0].body[0].body[0] - expected = Break( - [Token(Token.BREAK, 'BREAK', 3, 4)], - errors=('BREAK can only be used inside a loop.',) + expected = Error( + [Token(Token.ERROR, 'BREAK', 3, 4, 'BREAK is not allowed in this context.')], ) remove_non_data_nodes_and_assert(node, expected, data_only) @@ -294,9 +291,8 @@ def test_in_test_case_body(self): Example CONTINUE''', data_only=data_only) node = model.sections[0].body[0].body[0] - expected = Continue( - [Token(Token.CONTINUE, 'CONTINUE', 3, 4)], - errors=('CONTINUE can only be used inside a loop.',) + expected = Error( + [Token(Token.ERROR, 'CONTINUE', 3, 4, 'CONTINUE is not allowed in this context.')], ) remove_non_data_nodes_and_assert(node, expected, data_only) @@ -364,9 +360,8 @@ def test_in_uk_body(self): Example CONTINUE''', data_only=data_only) node = model.sections[0].body[0].body[0] - expected = Continue( - [Token(Token.CONTINUE, 'CONTINUE', 3, 4)], - errors=('CONTINUE can only be used inside a loop.',) + expected = Error( + [Token(Token.ERROR, 'CONTINUE', 3, 4, 'CONTINUE is not allowed in this context.')], ) remove_non_data_nodes_and_assert(node, expected, data_only) From a62ac242b056bdc59d913a1a627c19ea6b27b18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 1 Mar 2023 15:28:54 +0200 Subject: [PATCH 0403/1592] blockparser: add FIXME to an ugly hack --- src/robot/parsing/parser/blockparsers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/robot/parsing/parser/blockparsers.py b/src/robot/parsing/parser/blockparsers.py index 614ff940b16..d4f4a41e52e 100644 --- a/src/robot/parsing/parser/blockparsers.py +++ b/src/robot/parsing/parser/blockparsers.py @@ -45,6 +45,7 @@ def __init__(self, model): } def handles(self, statement): + # FIXME: this needs to be handled better if statement.type == Token.ERROR and \ statement.errors[0].startswith('Unrecognized section header'): return False From 301a4c2d33377c16944f73e6de9c2d5a492bfc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Thu, 2 Mar 2023 16:44:56 +0200 Subject: [PATCH 0404/1592] parsing: do not report test/kw errors in console These errors will now cause failures in running the tests, so the console messages are redundant. Relates to #4210 --- .../parsing/same_setting_multiple_times.robot | 35 ------------------- atest/robot/parsing/test_case_settings.robot | 12 +------ .../robot/parsing/user_keyword_settings.robot | 13 +------ .../timeouts_with_custom_messages.robot | 4 --- src/robot/running/builder/transformers.py | 6 ++++ 5 files changed, 8 insertions(+), 62 deletions(-) diff --git a/atest/robot/parsing/same_setting_multiple_times.robot b/atest/robot/parsing/same_setting_multiple_times.robot index 2e78f2839cc..7daa2b9718d 100644 --- a/atest/robot/parsing/same_setting_multiple_times.robot +++ b/atest/robot/parsing/same_setting_multiple_times.robot @@ -5,110 +5,75 @@ Resource atest_resource.robot *** Test Cases *** Suite Documentation Should Be Equal ${SUITE.doc} S1 - Setting multiple times 0 3 Documentation Suite Metadata Should Be Equal ${SUITE.metadata['Foo']} M2 Suite Setup Should Be Equal ${SUITE.setup.name} BuiltIn.Log Many - Setting multiple times 1 7 Suite Setup Suite Teardown Should Be Equal ${SUITE.teardown.name} BuiltIn.Comment - Setting multiple times 2 9 Suite Teardown Force and Default Tags Check Test Tags Use Defaults D1 - Setting multiple times 7 18 Force Tags - Setting multiple times 8 19 Force Tags - Setting multiple times 9 21 Default Tags - Setting multiple times 10 22 Default Tags Test Setup ${tc} = Check Test Case Use Defaults Should Be Equal ${tc.setup.name} BuiltIn.Log Many - Setting multiple times 3 11 Test Setup Test Teardown ${tc} = Check Test Case Use Defaults Teardown Should Not Be Defined ${tc} - Setting multiple times 4 13 Test Teardown Test Template ${tc} = Check Test Case Use Defaults Check Keyword Data ${tc.kws[0]} BuiltIn.Log Many args=Sleep, 0.1s - Setting multiple times 6 16 Test Template Test Timeout ${tc} = Check Test Case Use Defaults Should Be Equal ${tc.timeout} 1 second - Setting multiple times 11 24 Test Timeout Test [Documentation] ${tc} = Check Test Case Test Settings Check Keyword Data ${tc.kws[0]} ${EMPTY} type=ERROR status=FAIL Should Be Equal ${tc.kws[0].values[0]} [Documentation] - Setting multiple times 12 40 Documentation Test [Tags] Check Test Tags Test Settings - Setting multiple times 13 42 Tags - Setting multiple times 19 53 Tags Test [Setup] ${tc} = Check Test Case Test Settings Should Be Equal ${tc.setup.name} BuiltIn.Log Many - Setting multiple times 14 44 Setup Test [Teardown] ${tc} = Check Test Case Test Settings Teardown Should Not Be Defined ${tc} - Setting multiple times 15 46 Teardown - Setting multiple times 16 47 Teardown Test [Template] ${tc} = Check Test Case Test Settings Check Keyword Data ${tc.kws[7]} BuiltIn.Log args=No Operation - Setting multiple times 17 49 Template Test [Timeout] ${tc} = Check Test Case Test Settings Should Be Equal ${tc.timeout} 2 seconds - Setting multiple times 18 51 Timeout Keyword [Arguments] ${tc} = Check Test Case Keyword Settings Check Keyword Data ${tc.kws[0]} Keyword Settings assign=\${ret} args=1, 2, 3 tags=K1 status=FAIL Check Log Message ${tc.kws[0].msgs[0]} Arguments: [ \${a1}='1' | \${a2}='2' | \${a3}='3' ] TRACE - Setting multiple times 20 64 Arguments Keyword [Documentation] ${tc} = Check Test Case Keyword Settings Should Be Equal ${tc.kws[0].doc} ${EMPTY} - Setting multiple times 21 66 Documentation - Setting multiple times 22 67 Documentation Keyword [Tags] ${tc} = Check Test Case Keyword Settings Should Be True list($tc.kws[0].tags) == ['K1'] - Setting multiple times 23 69 Tags Keyword [Timeout] ${tc} = Check Test Case Keyword Settings Should Be Equal ${tc.kws[0].timeout} ${NONE} - Setting multiple times 24 71 Timeout - Setting multiple times 25 72 Timeout Keyword [Return] Check Test Case Keyword Settings - Setting multiple times 26 75 Return - Setting multiple times 27 76 Return - Setting multiple times 28 77 Return - -*** Keywords *** -Setting multiple times - [Arguments] ${index} ${lineno} ${setting} - Error In File - ... ${index} parsing/same_setting_multiple_times.robot ${lineno} - ... Setting '${setting}' is allowed only once. Only the first value is used. diff --git a/atest/robot/parsing/test_case_settings.robot b/atest/robot/parsing/test_case_settings.robot index 7e1fa10657e..ac2b70790b6 100644 --- a/atest/robot/parsing/test_case_settings.robot +++ b/atest/robot/parsing/test_case_settings.robot @@ -171,23 +171,13 @@ Multiple settings Invalid setting Check Test Case ${TEST NAME} - Error In File 0 parsing/test_case_settings.robot 214 - ... Non-existing setting 'Invalid'. Setting not valid with tests Check Test Case ${TEST NAME} - Error In File 1 parsing/test_case_settings.robot 219 - ... Setting 'Metadata' is not allowed with tests or tasks. - Check Test Case ${TEST NAME} - Error In File 2 parsing/test_case_settings.robot 220 - ... Setting 'Arguments' is not allowed with tests or tasks. Small typo should provide recommendation Check Test Case ${TEST NAME} - Error In File 3 parsing/test_case_settings.robot 227 - ... SEPARATOR=\n - ... Non-existing setting 'Doc U ment a tion'. Did you mean: - ... ${SPACE*4}Documentation + *** Keywords *** Verify Documentation diff --git a/atest/robot/parsing/user_keyword_settings.robot b/atest/robot/parsing/user_keyword_settings.robot index a6c249f2914..c7e1817af09 100644 --- a/atest/robot/parsing/user_keyword_settings.robot +++ b/atest/robot/parsing/user_keyword_settings.robot @@ -94,26 +94,15 @@ Multiple settings Invalid setting Check Test Case ${TEST NAME} - Error In File 0 parsing/user_keyword_settings.robot 202 - ... Non-existing setting 'Invalid Setting'. Setting not valid with user keywords Check Test Case ${TEST NAME} - Error In File 1 parsing/user_keyword_settings.robot 206 - ... Setting 'Metadata' is not allowed with user keywords. - Check Test Case ${TEST NAME} - Error In File 2 parsing/user_keyword_settings.robot 207 - ... Setting 'Template' is not allowed with user keywords. Small typo should provide recommendation Check Test Case ${TEST NAME} - Error In File 3 parsing/user_keyword_settings.robot 211 - ... SEPARATOR=\n - ... Non-existing setting 'Doc Umentation'. Did you mean: - ... ${SPACE*4}Documentation Invalid empty line continuation in arguments should throw an error - Error in File 4 parsing/user_keyword_settings.robot 214 + Error in File 0 parsing/user_keyword_settings.robot 214 ... Creating keyword 'Invalid empty line continuation in arguments should throw an error' failed: ... Invalid argument specification: Invalid argument syntax ''. diff --git a/atest/robot/running/timeouts_with_custom_messages.robot b/atest/robot/running/timeouts_with_custom_messages.robot index 8f186c3447b..4f21b34fb55 100644 --- a/atest/robot/running/timeouts_with_custom_messages.robot +++ b/atest/robot/running/timeouts_with_custom_messages.robot @@ -9,19 +9,15 @@ Default Test Timeout Message Test Timeout Message Check Test Case ${TEST NAME} - Using more than one value with timeout should error 1 10 2 Test Timeout Message In Multiple Columns Check Test Case ${TEST NAME} - Using more than one value with timeout should error 2 15 7 Keyword Timeout Message Check Test Case ${TEST NAME} - Using more than one value with timeout should error 3 30 2 Keyword Timeout Message In Multiple Columns Check Test Case ${TEST NAME} - Using more than one value with timeout should error 4 34 7 *** Keywords *** Using more than one value with timeout should error diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 23d4eb1a383..5d76cdbc3a4 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -618,6 +618,12 @@ class ErrorReporter(NodeVisitor): def __init__(self, source): self.source = source + def visit_TestCase(self, node): + pass + + def visit_Keyword(self, node): + pass + def visit_Error(self, node): fatal = node.get_token(Token.FATAL_ERROR) if fatal: From c29defc300fad9653753e3a45cc7f0346f0701df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Fri, 3 Mar 2023 15:36:25 +0200 Subject: [PATCH 0405/1592] parsing: lex dangling FINALLY as Error relates to #4210 --- atest/robot/running/try_except/invalid_try_except.robot | 4 ++++ atest/testdata/running/try_except/invalid_try_except.robot | 6 ++++++ src/robot/parsing/lexer/blocklexers.py | 2 +- src/robot/parsing/lexer/statementlexers.py | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/atest/robot/running/try_except/invalid_try_except.robot b/atest/robot/running/try_except/invalid_try_except.robot index 41c452a00e8..45ad4c198fb 100644 --- a/atest/robot/running/try_except/invalid_try_except.robot +++ b/atest/robot/running/try_except/invalid_try_except.robot @@ -84,3 +84,7 @@ RETURN in FINALLY Invalid TRY/EXCEPT causes syntax error that cannot be caught TRY:FAIL EXCEPT:NOT RUN ELSE:NOT RUN + +Dangling FINALLY + [Template] Check Test Case + ${TEST NAME} diff --git a/atest/testdata/running/try_except/invalid_try_except.robot b/atest/testdata/running/try_except/invalid_try_except.robot index 7feb94db67a..c3c1dd535f5 100644 --- a/atest/testdata/running/try_except/invalid_try_except.robot +++ b/atest/testdata/running/try_except/invalid_try_except.robot @@ -274,6 +274,12 @@ Invalid TRY/EXCEPT causes syntax error that cannot be caught Fail Not run either END +Dangling FINALLY + [Documentation] FAIL FINALLY is not allowed in this context. + IF ${True} + FINALLY + END + *** Keywords *** RETURN in FINALLY TRY diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index ebfd00b5ed0..9ad7a3f1fe2 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -285,7 +285,7 @@ def handles(cls, statement: list, ctx: TestOrKeywordContext): def lexer_classes(self): return (InlineIfLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ForLexer, TryLexer, WhileLexer, EndLexer, ReturnLexer, ContinueLexer, - BreakLexer, KeywordCallLexer) + BreakLexer, SyntaxErrorLexer, KeywordCallLexer) class InlineIfLexer(BlockLexer): diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 276214ac382..f2f7237afc7 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -342,7 +342,7 @@ class SyntaxErrorLexer(TypeAndArguments): @classmethod def handles(cls, statement: list, ctx: TestOrKeywordContext): return statement[0].value in \ - {'BREAK', 'CONTINUE', 'END', 'ELSE', 'ELSE IF','EXCEPT', 'RETURN'} + {'BREAK', 'CONTINUE', 'END', 'ELSE', 'ELSE IF','EXCEPT', 'FINALLY', 'RETURN'} def lex(self): token = self.statement[0] From 45f638bf92f71878f46d806825c45c9b8a0f32f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sun, 5 Mar 2023 10:23:45 +0200 Subject: [PATCH 0406/1592] schema: Add the new Error element relates to #4210 --- doc/schema/robot.xsd | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index e7a1b79d721..029b5843b43 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -58,6 +58,13 @@ </xs:extension> </xs:simpleContent> </xs:complexType> + <xs:complexType name="Error"> + <xs:choice maxOccurs="unbounded"> + <xs:element name="value" type="xs:string" minOccurs="1" maxOccurs="unbounded" /> + <xs:element name="msg" type="Message" /> + <xs:element name="status" type="Status" /> + </xs:choice> + </xs:complexType> <xs:complexType name="Test"> <xs:choice maxOccurs="unbounded"> <xs:element name="kw" type="Keyword" minOccurs="0" maxOccurs="unbounded" /> @@ -65,6 +72,7 @@ <xs:element name="if" type="If" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="try" type="Try" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="while" type="While" minOccurs="0" maxOccurs="unbounded" /> + <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> <xs:element name="tag" type="xs:string" minOccurs="0" maxOccurs="unbounded" /> @@ -84,6 +92,7 @@ <xs:element name="if" type="If" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="try" type="Try" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="while" type="While" minOccurs="0" maxOccurs="unbounded" /> + <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="return" type="Return" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="var" type="xs:string" minOccurs="0" maxOccurs="unbounded" /> <!-- Assignment --> @@ -136,6 +145,7 @@ <xs:element name="return" type="Return" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="break" type="Break" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="continue" type="Continue" minOccurs="0" maxOccurs="unbounded" /> + <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> <xs:element name="status" type="BodyItemStatus" /> @@ -166,6 +176,7 @@ <xs:element name="return" type="Return" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="break" type="Break" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="continue" type="Continue" minOccurs="0" maxOccurs="unbounded" /> + <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> <xs:element name="status" type="BodyItemStatus" /> @@ -199,6 +210,7 @@ <xs:element name="return" type="Return" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="break" type="Break" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="continue" type="Continue" minOccurs="0" maxOccurs="unbounded" /> + <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> <xs:element name="status" type="BodyItemStatus" /> @@ -235,6 +247,7 @@ <xs:element name="return" type="Return" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="break" type="Break" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="continue" type="Continue" minOccurs="0" maxOccurs="unbounded" /> + <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> <xs:element name="status" type="BodyItemStatus" /> From ee35e69102c261d1205c5ea6ce80fcd411620c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Mon, 6 Mar 2023 09:03:16 +0200 Subject: [PATCH 0407/1592] schema: add missing elements --- doc/schema/robot.xsd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index 029b5843b43..413a9d7223a 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -236,6 +236,7 @@ <xs:element name="status" type="BodyItemStatus" /> </xs:choice> <xs:attribute name="condition" type="xs:string" /> + <xs:attribute name="limit" type="xs:string" /> </xs:complexType> <xs:complexType name="WhileIteration"> <xs:choice maxOccurs="unbounded"> @@ -311,6 +312,7 @@ <xs:enumeration value="PASS" /> <xs:enumeration value="FAIL" /> <xs:enumeration value="SKIP" /> + <xs:enumeration value="NOT RUN" /> </xs:restriction> </xs:simpleType> <xs:complexType name="BodyItemStatus"> From 2a7fd56cd12127e365dbfbe131fd437e51dd725e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 6 Mar 2023 15:24:05 +0200 Subject: [PATCH 0408/1592] Remind testing output.xml schema before releases. Also mention --schema-validation in atest/run.py usage. --- BUILD.rst | 7 +++++++ atest/run.py | 11 ++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/BUILD.rst b/BUILD.rst index 7392a628942..1c0d907b4ba 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -66,6 +66,13 @@ Testing Make sure that adequate tests are executed before releases are created. See `<atest/README.rst>`_ for details. +If output.xml `schema <doc/schema/README.rst>`_ has changed, remember to +run tests also with `full schema validation`__ enabled:: + + atest/run.py --schema-validation + +__ https://github.com/robotframework/robotframework/tree/master/atest#schema-validation + Preparation ----------- diff --git a/atest/run.py b/atest/run.py index ef46c6f76d0..8296cea4430 100755 --- a/atest/run.py +++ b/atest/run.py @@ -2,7 +2,7 @@ """A script for running Robot Framework's own acceptance tests. -Usage: atest/run.py [--interpreter interpreter] [options] [data] +Usage: atest/run.py [--interpreter name] [--schema-validation [options] [data] `data` is path (or paths) of the file or directory under the `atest/robot` folder to execute. If `data` is not given, all tests except for tests tagged @@ -12,10 +12,11 @@ See its help (e.g. `robot --help`) for more information. By default, uses the same Python interpreter for running tests that is used -for running this script. That can be changed by using the `--interpreter` (`-I`) -option. It can be the name of the interpreter (e.g. `pypy3`) or a path to the -selected interpreter (e.g. `/usr/bin/python39`). If the interpreter itself needs -arguments, the interpreter and its arguments need to be quoted (e.g. `"py -3"`). +for running this script. That can be changed by using the `--interpreter` +(`-I`) option. It can be the name of the interpreter like `pypy3` or a path +to the selected interpreter like `/usr/bin/python39`. If the interpreter +itself needs arguments, the interpreter and its arguments need to be quoted +like `"py -3.9"`. To enable schema validation for all suites, use `--schema-validation` (`-S`) option. This is same as setting `ATEST_VALIDATE_OUTPUT` environment variable From 1eaddfc0f25cc94e209f4c31785cbdc192c7d467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 6 Mar 2023 18:44:29 +0200 Subject: [PATCH 0409/1592] More compact and tiny bit faster code --- src/robot/variables/evaluation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py index 8c5f5b6bb09..b9dc2c9a47e 100644 --- a/src/robot/variables/evaluation.py +++ b/src/robot/variables/evaluation.py @@ -48,7 +48,7 @@ def _evaluate(expression, variable_store, modules=None, namespace=None): # automatically as modules. It must be also be used as the global namespace # with `eval()` because lambdas and possibly other special constructs don't # see the local namespace at all. - namespace = dict(namespace) if namespace else {} + namespace = dict(namespace or ()) if modules: namespace.update(_import_modules(modules)) local_ns = EvaluationNamespace(variable_store, namespace) From 062214785b66697a21b1ba737580ef779d1d64ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 9 Mar 2023 02:33:30 +0200 Subject: [PATCH 0410/1592] Fix Slack link, mention Foundation is non-profit --- README.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index eac7d8933f2..e73a52c9199 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ http://robotframework.org. Robot Framework project is hosted on GitHub_ where you can find source code, an issue tracker, and some further documentation. Downloads are hosted on PyPI_. -Robot Framework development is sponsored by `Robot Framework Foundation +Robot Framework development is sponsored by non-profit `Robot Framework Foundation <http://robotframework.org/foundation>`_. If you are using the framework and benefiting from it, consider joining the foundation to help maintaining the framework and developing it further. @@ -113,8 +113,7 @@ Documentation Support and Contact ------------------- -- `Slack <https://robotframework.slack.com/>`_ - (`click for invite <https://robotframework-slack-invite.herokuapp.com>`__) +- `Slack <http://slack.robotframework.org/>`_ - `Forum <https://forum.robotframework.org/>`_ - `robotframework-users <https://groups.google.com/group/robotframework-users/>`_ mailing list From 27a533e4edf0aebd699c15d7b32a30e76fc7638c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 10 Mar 2023 21:40:35 +0200 Subject: [PATCH 0411/1592] Recommend `$x` syntax if invalid IF or WHILE has `${x}` For example, `${var} == 'value'` fails for NameError if `${var}` is a string and using `$var == 'value'` avoids that. Fixes #4676. --- atest/robot/running/if/invalid_if.robot | 17 ++-- .../robot/running/if/invalid_inline_if.robot | 7 +- atest/robot/running/while/invalid_while.robot | 12 ++- atest/testdata/running/if/invalid_if.robot | 41 ++++++--- .../running/if/invalid_inline_if.robot | 88 +++++++++++-------- .../running/while/invalid_while.robot | 50 ++++++++--- src/robot/libraries/BuiltIn.py | 2 +- src/robot/running/bodyrunner.py | 16 ++-- src/robot/variables/evaluation.py | 38 +++++++- src/robot/variables/finders.py | 8 +- src/robot/variables/replacer.py | 4 +- src/robot/variables/variables.py | 2 +- 12 files changed, 194 insertions(+), 91 deletions(-) diff --git a/atest/robot/running/if/invalid_if.robot b/atest/robot/running/if/invalid_if.robot index 6f97c00e030..b28d58bbc25 100644 --- a/atest/robot/running/if/invalid_if.robot +++ b/atest/robot/running/if/invalid_if.robot @@ -16,12 +16,18 @@ IF with invalid condition IF with invalid condition with ELSE FAIL NOT RUN -IF condition with non-existing variable +IF condition with non-existing ${variable} + FAIL NOT RUN + +IF condition with non-existing $variable FAIL NOT RUN ELSE IF with invalid condition NOT RUN NOT RUN FAIL NOT RUN NOT RUN +Recommend $var syntax if invalid condition contains ${var} + FAIL index=1 + IF without END FAIL @@ -106,11 +112,12 @@ Non-existing variable in condition causes normal error *** Keywords *** Branch statuses should be - [Arguments] @{statuses} + [Arguments] @{statuses} ${index}=0 ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.body[0].status} FAIL - FOR ${branch} ${status} IN ZIP ${tc.body[0].body} ${statuses} + ${if} = Set Variable ${tc.body}[${index}] + Should Be Equal ${if.status} FAIL + FOR ${branch} ${status} IN ZIP ${if.body} ${statuses} Should Be Equal ${branch.status} ${status} END - Should Be Equal ${{len($tc.body[0].body)}} ${{len($statuses)}} + Should Be Equal ${{len($if.body)}} ${{len($statuses)}} RETURN ${tc} diff --git a/atest/robot/running/if/invalid_inline_if.robot b/atest/robot/running/if/invalid_inline_if.robot index c8c7157ca32..d725e09663e 100644 --- a/atest/robot/running/if/invalid_inline_if.robot +++ b/atest/robot/running/if/invalid_inline_if.robot @@ -103,7 +103,12 @@ Assign when ELSE IF branch is empty Assign when ELSE branch is empty FAIL NOT RUN -Assign with RETURN +Control structures are allowed + [Template] NONE + ${tc} = Check Test Case ${TESTNAME} + Check IF/ELSE Status NOT RUN PASS root=${tc.body[0].body[0]} + +Control structures are not allowed with assignment [Template] NONE ${tc} = Check Test Case ${TESTNAME} Check IF/ELSE Status FAIL NOT RUN root=${tc.body[0].body[0]} diff --git a/atest/robot/running/while/invalid_while.robot b/atest/robot/running/while/invalid_while.robot index 9a5d49f1d2c..d591dd5ce9a 100644 --- a/atest/robot/running/while/invalid_while.robot +++ b/atest/robot/running/while/invalid_while.robot @@ -14,12 +14,18 @@ Multiple conditions Invalid condition Check Invalid WHILE Test Case -Invalid condition on second round - Check Test Case ${TEST NAME} +Non-existing ${variable} in condition + Check Invalid WHILE Test Case -Non-existing variable in condition +Non-existing $variable in condition Check Invalid WHILE Test Case +Recommend $var syntax if invalid condition contains ${var} + Check Test Case ${TEST NAME} + +Invalid condition on second round + Check Test Case ${TEST NAME} + No body Check Invalid WHILE Test Case body=False diff --git a/atest/testdata/running/if/invalid_if.robot b/atest/testdata/running/if/invalid_if.robot index 5fa9167b999..9886ad177b9 100644 --- a/atest/testdata/running/if/invalid_if.robot +++ b/atest/testdata/running/if/invalid_if.robot @@ -14,21 +14,30 @@ IF without condition with ELSE END IF with invalid condition - [Documentation] FAIL STARTS: Evaluating IF condition failed: Evaluating expression ''123'=123' failed: SyntaxError: + [Documentation] FAIL STARTS: Invalid IF condition: Evaluating expression ''123'=123' failed: SyntaxError: IF '123'=${123} Fail Should not be run END -IF condition with non-existing variable - [Documentation] FAIL Evaluating IF condition failed: Variable '\${ooop}' not found. +IF condition with non-existing ${variable} + [Documentation] FAIL Invalid IF condition: Evaluating expression '\${ooop}' failed: Variable '\${ooop}' not found. IF ${ooop} Fail Should not be run ELSE IF ${not evaluated} Not run END +IF condition with non-existing $variable + [Documentation] FAIL Invalid IF condition: Evaluating expression '$ooop' failed: Variable '$ooop' not found. + IF $ooop + Fail Should not be run + ELSE IF $not_evaluated + Not run + END + IF with invalid condition with ELSE - [Documentation] FAIL Evaluating IF condition failed: Evaluating expression 'ooops' failed: NameError: name 'ooops' is not defined nor importable as module + [Documentation] FAIL Invalid IF condition: \ + ... Evaluating expression 'ooops' failed: NameError: name 'ooops' is not defined nor importable as module IF ooops Fail Should not be run ELSE @@ -36,7 +45,7 @@ IF with invalid condition with ELSE END ELSE IF with invalid condition - [Documentation] FAIL STARTS: Evaluating ELSE IF condition failed: Evaluating expression '1/0' failed: ZeroDivisionError: + [Documentation] FAIL STARTS: Invalid ELSE IF condition: Evaluating expression '1/0' failed: ZeroDivisionError: IF False Fail Should not be run ELSE IF False @@ -49,6 +58,18 @@ ELSE IF with invalid condition Fail Should not be run END +Recommend $var syntax if invalid condition contains ${var} + [Documentation] FAIL Invalid IF condition: \ + ... Evaluating expression 'x == 'x'' failed: NameError: name 'x' is not defined nor importable as module + ... + ... Variables in the original expression '\${x} == 'x'' were resolved before the expression was evaluated. \ + ... Try using '$x == 'x'' syntax to avoid that. See Evaluating Expressions appendix in Robot Framework User Guide for more details. + ${x} = Set Variable x + IF ${x} == 'x' + Fail Shouldn't be run + END + + IF without END [Documentation] FAIL IF must have closing END. IF ${True} @@ -221,14 +242,14 @@ Invalid condition causes normal error [Documentation] FAIL Teardown failed: ... Several failures occurred: ... - ... 1) Evaluating IF condition failed: Evaluating expression 'bad in teardown' failed: NameError: name 'bad' is not defined nor importable as module + ... 1) Invalid IF condition: Evaluating expression 'bad in teardown' failed: NameError: name 'bad' is not defined nor importable as module ... ... 2) Should be run in teardown TRY IF bad Fail Should not be run END - EXCEPT Evaluating IF condition failed: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + EXCEPT Invalid IF condition: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module No Operation END [Teardown] Invalid condition @@ -237,14 +258,14 @@ Non-existing variable in condition causes normal error [Documentation] FAIL Teardown failed: ... Several failures occurred: ... - ... 1) Evaluating IF condition failed: Variable '\${bad}' not found. + ... 1) Invalid IF condition: Evaluating expression '\${oops}' failed: Variable '\${oops}' not found. ... ... 2) Should be run in teardown TRY IF ${bad} Fail Should not be run END - EXCEPT Evaluating IF condition failed: Variable '\${bad}' not found. + EXCEPT Invalid IF condition: Evaluating expression '\${bad}' failed: Variable '\${bad}' not found. No Operation END [Teardown] Non-existing variable in condition @@ -259,7 +280,7 @@ Invalid condition Fail Should be run in teardown Non-existing variable in condition - IF ${bad} + IF ${oops} Fail Should not be run END Fail Should be run in teardown diff --git a/atest/testdata/running/if/invalid_inline_if.robot b/atest/testdata/running/if/invalid_inline_if.robot index 9f2d82b06bc..08de7680562 100644 --- a/atest/testdata/running/if/invalid_inline_if.robot +++ b/atest/testdata/running/if/invalid_inline_if.robot @@ -1,160 +1,174 @@ *** Test Cases *** Invalid condition - [Documentation] FAIL Evaluating IF condition failed: Evaluating expression 'ooops' failed: NameError: name 'ooops' is not defined nor importable as module + [Documentation] FAIL Invalid IF condition: \ + ... Evaluating expression 'ooops' failed: NameError: name 'ooops' is not defined nor importable as module IF ooops Not run ELSE Not run either Condition with non-existing variable - [Documentation] FAIL Evaluating IF condition failed: Variable '\${ooops}' not found. + [Documentation] FAIL Invalid IF condition: \ + ... Evaluating expression '${ooops}' failed: Variable '\${ooops}' not found. IF ${ooops} Not run Invalid condition with other error - [Documentation] FAIL ELSE branch cannot be empty. + [Documentation] FAIL ELSE branch cannot be empty. IF bad Not run ELSE Empty IF - [Documentation] FAIL Multiple errors: + [Documentation] FAIL + ... Multiple errors: ... - IF must have a condition. ... - IF branch cannot be empty. ... - IF must have closing END. IF IF without branch - [Documentation] FAIL Multiple errors: + [Documentation] FAIL + ... Multiple errors: ... - IF branch cannot be empty. ... - IF must have closing END. IF True IF without branch with ELSE IF - [Documentation] FAIL IF branch cannot be empty. + [Documentation] FAIL IF branch cannot be empty. IF True ELSE IF True Not run IF without branch with ELSE - [Documentation] FAIL IF branch cannot be empty. + [Documentation] FAIL IF branch cannot be empty. IF True ELSE Not run IF followed by ELSE IF - [Documentation] FAIL STARTS: Evaluating IF condition failed: Evaluating expression 'ELSE IF' failed: + [Documentation] FAIL STARTS: Invalid IF condition: Evaluating expression 'ELSE IF' failed: IF ELSE IF False Not run IF followed by ELSE - [Documentation] FAIL Evaluating IF condition failed: Evaluating expression 'ELSE' failed: NameError: name 'ELSE' is not defined nor importable as module + [Documentation] FAIL Invalid IF condition: \ + ... Evaluating expression 'ELSE' failed: NameError: name 'ELSE' is not defined nor importable as module IF ELSE Not run Empty ELSE IF 1 - [Documentation] FAIL Multiple errors: + [Documentation] FAIL + ... Multiple errors: ... - ELSE IF must have a condition. ... - ELSE IF branch cannot be empty. IF False Not run ELSE IF Empty ELSE IF 2 - [Documentation] FAIL Evaluating ELSE IF condition failed: Evaluating expression 'ELSE' failed: NameError: name 'ELSE' is not defined nor importable as module + [Documentation] FAIL Invalid ELSE IF condition: \ + ... Evaluating expression 'ELSE' failed: NameError: name 'ELSE' is not defined nor importable as module IF False Not run ELSE IF ELSE Not run ELSE IF without branch 1 - [Documentation] FAIL ELSE IF branch cannot be empty. + [Documentation] FAIL ELSE IF branch cannot be empty. IF False Not run ELSE IF False ELSE IF without branch 2 - [Documentation] FAIL ELSE IF branch cannot be empty. + [Documentation] FAIL ELSE IF branch cannot be empty. IF False Not run ELSE IF False ELSE Not run Empty ELSE - [Documentation] FAIL ELSE branch cannot be empty. + [Documentation] FAIL ELSE branch cannot be empty. IF True Not run ELSE IF True Not run ELSE ELSE IF after ELSE 1 - [Documentation] FAIL ELSE IF not allowed after ELSE. + [Documentation] FAIL ELSE IF not allowed after ELSE. IF True Not run ELSE Not run ELSE IF True Not run ELSE IF after ELSE 2 - [Documentation] FAIL ELSE IF not allowed after ELSE. + [Documentation] FAIL ELSE IF not allowed after ELSE. IF True Not run ELSE Not run ELSE IF True Not run ELSE IF True Not run Multiple ELSEs 1 - [Documentation] FAIL Only one ELSE allowed. + [Documentation] FAIL Only one ELSE allowed. IF True Not run ELSE Not run ELSE Not run Multiple ELSEs 2 - [Documentation] FAIL Only one ELSE allowed. + [Documentation] FAIL Only one ELSE allowed. IF True Not run ELSE Not run ELSE Not run ELSE Not run Nested IF 1 - [Documentation] FAIL Inline IF cannot be nested. + [Documentation] FAIL Inline IF cannot be nested. IF True IF True Not run Nested IF 2 - [Documentation] FAIL Inline IF cannot be nested. + [Documentation] FAIL Inline IF cannot be nested. IF True Not run ELSE IF True Not run Nested IF 3 - [Documentation] FAIL Inline IF cannot be nested. + [Documentation] FAIL Inline IF cannot be nested. IF True IF True Not run ... ELSE IF True IF True Not run ... ELSE IF True Not run Nested FOR - [Documentation] FAIL 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. + [Documentation] FAIL 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. IF True FOR ${x} IN @{stuff} Unnecessary END - [Documentation] FAIL Keyword 'BuiltIn.No Operation' expected 0 arguments, got 1. + [Documentation] FAIL Keyword 'BuiltIn.No Operation' expected 0 arguments, got 1. IF True No operation ELSE Log END IF False Not run ELSE No operation END Invalid END after inline header - [Documentation] FAIL END is not allowed in this context. + [Documentation] FAIL END is not allowed in this context. IF True Log Executed inside inline IF Log Executed outside IF END Assign in IF branch - [Documentation] FAIL Inline IF branches cannot contain assignments. + [Documentation] FAIL Inline IF branches cannot contain assignments. IF False ${x} = Whatever Assign in ELSE IF branch - [Documentation] FAIL Inline IF branches cannot contain assignments. + [Documentation] FAIL Inline IF branches cannot contain assignments. IF False Keyword ELSE IF False ${x} = Whatever Assign in ELSE branch - [Documentation] FAIL Inline IF branches cannot contain assignments. + [Documentation] FAIL Inline IF branches cannot contain assignments. IF False Keyword ELSE ${x} = Whatever Invalid assign mark usage - [Documentation] FAIL Assign mark '=' can be used only with the last variable. + [Documentation] FAIL Assign mark '=' can be used only with the last variable. ${x} = ${y} IF True Create list x y Too many list variables in assign - [Documentation] FAIL Assignment can contain only one list variable. + [Documentation] FAIL Assignment can contain only one list variable. @{x} @{y} = IF True Create list x y ELSE Not run Invalid number of variables in assign - [Documentation] FAIL Cannot set variables: Expected 2 return values, got 3. + [Documentation] FAIL Cannot set variables: Expected 2 return values, got 3. ${x} ${y} = IF False Create list x y ELSE Create list x y z Invalid value for list assign - [Documentation] FAIL Cannot set variable '\@{x}': Expected list-like value, got string. + [Documentation] FAIL Cannot set variable '\@{x}': Expected list-like value, got string. @{x} = IF True Set variable String is not list Invalid value for dict assign - [Documentation] FAIL Cannot set variable '\&{x}': Expected dictionary-like value, got string. + [Documentation] FAIL Cannot set variable '\&{x}': Expected dictionary-like value, got string. &{x} = IF False Not run ELSE Set variable String is not dict either Assign when IF branch is empty - [Documentation] FAIL IF branch cannot be empty. + [Documentation] FAIL IF branch cannot be empty. ${x} = IF False Assign when ELSE IF branch is empty - [Documentation] FAIL ELSE IF branch cannot be empty. + [Documentation] FAIL ELSE IF branch cannot be empty. ${x} = IF True Not run ELSE IF True Assign when ELSE branch is empty - [Documentation] FAIL ELSE branch cannot be empty. + [Documentation] FAIL ELSE branch cannot be empty. ${x} = IF True Not run ELSE -Assign with RETURN - [Documentation] FAIL Inline IF with assignment can only contain keyword calls. +Control structures are allowed + With RETURN + +Control structures are not allowed with assignment + [Documentation] FAIL Inline IF with assignment can only contain keyword calls. Assign with RETURN *** Keywords *** +With RETURN + IF False Fail Not run ELSE RETURN + Fail Not run + Assign with RETURN ${x} = IF False RETURN ELSE Not run diff --git a/atest/testdata/running/while/invalid_while.robot b/atest/testdata/running/while/invalid_while.robot index 42f9a164c19..77ac370eb11 100644 --- a/atest/testdata/running/while/invalid_while.robot +++ b/atest/testdata/running/while/invalid_while.robot @@ -1,24 +1,54 @@ *** Test Cases *** No condition - [Documentation] FAIL WHILE must have a condition. + [Documentation] FAIL WHILE must have a condition. WHILE Fail Not executed! END Multiple conditions - [Documentation] FAIL WHILE cannot have more than one condition. + [Documentation] FAIL WHILE cannot have more than one condition. WHILE Too many ! Fail Not executed! END Invalid condition - [Documentation] FAIL STARTS: Evaluating WHILE condition failed: Evaluating expression 'ooops!' failed: SyntaxError: - WHILE ooops! + [Documentation] FAIL Invalid WHILE condition: \ + ... Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + WHILE bad Fail Not executed! END +Non-existing ${variable} in condition + [Documentation] FAIL Invalid WHILE condition: \ + ... Evaluating expression '\${bad} > 0' failed: Variable '\${bad}' not found. + WHILE ${bad} > 0 + Fail Not executed! + END + +Non-existing $variable in condition + [Documentation] FAIL Invalid WHILE condition: \ + ... Evaluating expression '$bad > 0' failed: Variable '$bad' not found. + WHILE $bad > 0 + Fail Not executed! + END + +Recommend $var syntax if invalid condition contains ${var} + [Documentation] FAIL Invalid WHILE condition: \ + ... Evaluating expression 'x == 'x'' failed: NameError: name 'x' is not defined nor importable as module + ... + ... Variables in the original expression '\${x} == 'x'' were resolved before the expression was evaluated. \ + ... Try using '$x == 'x'' syntax to avoid that. See Evaluating Expressions appendix in Robot Framework User Guide for more details. + ${x} = Set Variable x + WHILE ${x} == 'x' + Fail Shouldn't be run + END + Invalid condition on second round - [Documentation] FAIL Evaluating WHILE condition failed: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + [Documentation] FAIL Invalid WHILE condition: \ + ... Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + ... + ... Variables in the original expression '\${condition}' were resolved before the expression was evaluated. \ + ... Try using '$condition' syntax to avoid that. See Evaluating Expressions appendix in Robot Framework User Guide for more details. ${condition} = Set Variable True WHILE ${condition} IF ${condition} @@ -28,12 +58,6 @@ Invalid condition on second round END END -Non-existing variable in condition - [Documentation] FAIL Evaluating WHILE condition failed: Variable '\${ooops}' not found. - WHILE ${ooops} - Fail Not executed! - END - No body [Documentation] FAIL WHILE loop cannot be empty. WHILE True @@ -58,7 +82,7 @@ Invalid condition causes normal error WHILE bad Fail Should not be run END - EXCEPT Evaluating WHILE condition failed: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + EXCEPT Invalid WHILE condition: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module No Operation END @@ -67,6 +91,6 @@ Non-existing variable in condition causes normal error WHILE ${bad} Fail Should not be run END - EXCEPT Evaluating WHILE condition failed: Variable '\${bad}' not found. + EXCEPT Invalid WHILE condition: Evaluating expression '\${bad}' failed: Variable '\${bad}' not found. No Operation END diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index f10e55f3f2d..ad302ba2d75 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -3432,7 +3432,7 @@ def evaluate(self, expression, modules=None, namespace=None): ``modules=rootmod, rootmod.submod``. """ try: - return evaluate_expression(expression, self._variables.current.store, + return evaluate_expression(expression, self._variables.current, modules, namespace) except DataError as err: raise RuntimeError(err.message) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 07c49f0fec8..d57ba376538 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -388,13 +388,11 @@ def _run_iteration(self, data, result, run=True): def _should_run(self, condition, variables): try: - condition = variables.replace_scalar(condition) - if is_string(condition): - return evaluate_expression(condition, variables.current.store) - return bool(condition) + return evaluate_expression(condition, variables.current, + resolve_variables=True) except Exception: msg = get_error_message() - raise DataError(f'Evaluating WHILE condition failed: {msg}') + raise DataError(f'Invalid WHILE condition: {msg}') class IfRunner: @@ -464,13 +462,11 @@ def _should_run_branch(self, branch, context, recursive_dry_run=False): if condition is None: return True try: - condition = variables.replace_scalar(condition) - if is_string(condition): - return evaluate_expression(condition, variables.current.store) - return bool(condition) + return evaluate_expression(condition, variables.current, + resolve_variables=True) except Exception: msg = get_error_message() - raise DataError(f'Evaluating {branch.type} condition failed: {msg}') + raise DataError(f'Invalid {branch.type} condition: {msg}') class TryRunner: diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py index b9dc2c9a47e..34bc7f15164 100644 --- a/src/robot/variables/evaluation.py +++ b/src/robot/variables/evaluation.py @@ -22,22 +22,34 @@ from robot.errors import DataError from robot.utils import get_error_message, type_name +from .search import search_variable from .notfound import variable_not_found PYTHON_BUILTINS = set(builtins.__dict__) -def evaluate_expression(expression, variable_store, modules=None, namespace=None): +def evaluate_expression(expression, variables, modules=None, namespace=None, + resolve_variables=False): + original = expression try: if not isinstance(expression, str): raise TypeError(f'Expression must be string, got {type_name(expression)}.') + if resolve_variables: + expression = variables.replace_scalar(expression) + if not isinstance(expression, str): + return expression if not expression: raise ValueError('Expression cannot be empty.') - return _evaluate(expression, variable_store, modules, namespace) + return _evaluate(expression, variables.store, modules, namespace) + except DataError as err: + error = str(err) + recommendation = '' except Exception: - raise DataError(f"Evaluating expression '{expression}' failed: " - f"{get_error_message()}") + error = get_error_message() + recommendation = _recommend_special_variables(original) + raise DataError(f"Evaluating expression '{expression}' failed: {error}\n\n" + f"{recommendation}".strip()) def _evaluate(expression, variable_store, modules=None, namespace=None): @@ -91,6 +103,24 @@ def _import_modules(module_names): return modules +def _recommend_special_variables(expression): + example = [] + remaining = expression + while True: + match = search_variable(remaining) + if not match: + break + example[-1:] = [match.before, match.identifier, match.base, match.after] + remaining = example[-1] + if not example: + return '' + example = ''.join(example) + return (f"Variables in the original expression '{expression}' were resolved " + f"before the expression was evaluated. Try using '{example}' " + f"syntax to avoid that. See Evaluating Expressions appendix in " + f"Robot Framework User Guide for more details.") + + class EvaluationNamespace(MutableMapping): def __init__(self, variable_store, namespace): diff --git a/src/robot/variables/finders.py b/src/robot/variables/finders.py index 06e69e09c18..9db64112ace 100644 --- a/src/robot/variables/finders.py +++ b/src/robot/variables/finders.py @@ -29,14 +29,14 @@ class VariableFinder: - def __init__(self, variable_store): - self._finders = (StoredFinder(variable_store), + def __init__(self, variables): + self._finders = (StoredFinder(variables.store), NumberFinder(), EmptyFinder(), - InlinePythonFinder(variable_store), + InlinePythonFinder(variables), EnvironmentFinder(), ExtendedFinder(self)) - self._store = variable_store + self._store = variables.store def find(self, variable): match = self._get_match(variable) diff --git a/src/robot/variables/replacer.py b/src/robot/variables/replacer.py index ea316823954..c58f7862a10 100644 --- a/src/robot/variables/replacer.py +++ b/src/robot/variables/replacer.py @@ -24,8 +24,8 @@ class VariableReplacer: - def __init__(self, variable_store): - self._finder = VariableFinder(variable_store) + def __init__(self, variables): + self._finder = VariableFinder(variables) def replace_list(self, items, replace_until=None, ignore_errors=False): """Replaces variables from a list of items. diff --git a/src/robot/variables/variables.py b/src/robot/variables/variables.py index bee955f17cb..1cf6e0f4eab 100644 --- a/src/robot/variables/variables.py +++ b/src/robot/variables/variables.py @@ -31,7 +31,7 @@ class Variables: def __init__(self): self.store = VariableStore(self) - self._replacer = VariableReplacer(self.store) + self._replacer = VariableReplacer(self) def __setitem__(self, name, value): self.store.add(name, value) From 9ffeee36e415430505f7c50f3d9b86a5fc093da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 18 Feb 2023 01:52:28 +0200 Subject: [PATCH 0412/1592] Add optional, typed listener base classes. Fixes #4568. --- .../ListenerInterface.rst | 21 +- src/robot/__init__.py | 2 +- src/robot/api/interfaces.py | 302 +++++++++++++++++- src/robot/output/listenerarguments.py | 2 + 4 files changed, 312 insertions(+), 15 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 6abba68fe0b..3b0971d0f3f 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -250,36 +250,38 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | | | | | Additional attributes for `FOR` types: | | | | | - | | | * `variables`: Assigned variables for each loop iteration. | + | | | * `variables`: Assigned variables for each loop iteration | + | | | as a list or strings. | | | | * `flavor`: Type of loop (e.g. `IN RANGE`). | - | | | * `values`: List of values being looped over. | + | | | * `values`: List of values being looped over | + | | | as a list or strings. | | | | | | | | Additional attributes for `ITERATION` types: | | | | | | | | * `variables`: Variables and string representations of their | - | | | contents for one `FOR` loop iteration. | + | | | contents for one `FOR` loop iteration as a dictionary. | | | | | | | | Additional attributes for `WHILE` types: | | | | | | | | * `condition`: The looping condition. | | | | * `limit`: The maximum iteration limit. | | | | | - | | | Additional attributes for `IF` and `ELSE_IF` types: | + | | | Additional attributes for `IF` and `ELSE IF` types: | | | | | | | | * `condition`: The conditional expression being evaluated. | | | | | | | | Additional attributes for `EXCEPT` types: | | | | | - | | | * `patterns`: The exception pattern being matched. | + | | | * `patterns`: The exception patterns being matched | + | | | as a list or strings. | | | | * `pattern_type`: The type of pattern match (e.g. `GLOB`). | | | | * `variable`: The variable containing the captured exception. | | | | | | | | Additional attributes for `RETURN` types: | | | | | - | | | * `values`: Return values from a keyword. | + | | | * `values`: Return values from a keyword as a list or strings. | | | | | | | | Additional attributes for control structures are new in RF 6.0.| - | | | | +------------------+------------------+----------------------------------------------------------------+ | end_keyword | name, attributes | Called when a keyword ends. | | | | | @@ -343,7 +345,7 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | if getting the | | | | source of the library failed for some reason. | | | | * `importer`: An absolute path to the file importing the | - | | | library. `None` when BuiltIn_ is imported well as when | + | | | library. `None` when BuiltIn_ is imported as well as when | | | | using the :name:`Import Library` keyword. | +------------------+------------------+----------------------------------------------------------------+ | resource_import | name, attributes | Called when a resource file has been imported. | @@ -731,8 +733,7 @@ acting as a listener itself: ROBOT_LIBRARY_SCOPE = 'GLOBAL' ROBOT_LISTENER_API_VERSION = 2 - // actual library code here ... - } + # actual library code here ... .. sourcecode:: python diff --git a/src/robot/__init__.py b/src/robot/__init__.py index 5a4cc31023a..9b9f18618ef 100644 --- a/src/robot/__init__.py +++ b/src/robot/__init__.py @@ -32,7 +32,7 @@ ``from robot.libdoc import libdoc_cli``. The functions and modules listed above are considered stable. Other modules in -this package are for for internal usage and may change without prior notice. +this package are for internal usage and may change without prior notice. .. tip:: More public APIs are exposed by the :mod:`robot.api` package. """ diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 9458e1499ac..99c7feaed8c 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -19,8 +19,8 @@ - :class:`DynamicLibrary` for libraries using the `dynamic library API`__. - :class:`HybridLibrary` for libraries using the `hybrid library API`__. -- `ListenerV2` for `listener interface version 2`__. *TODO*. -- `ListenerV3` for `listener interface version 3`__. *TODO*. +- :class:`ListenerV2` for `listener interface version 2`__. +- :class:`ListenerV3` for `listener interface version 3`__. - Type definitions used by the aforementioned classes. Main benefit of using these base classes is that editors can provide automatic @@ -31,6 +31,9 @@ .. note:: These classes are not exposed via the top level :mod:`robot.api` package. They need to imported via :mod:`robot.api.interfaces`. +.. note:: Using :class:`ListenerV2` and :class:`ListenerV3` requires Python 3.8 + or newer. + New in Robot Framework 6.1. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dynamic-library-api @@ -42,14 +45,21 @@ import sys from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional, Tuple, Union - - # Need to use version check and not try/except to support Mypy's stubgen. +if sys.version_info >= (3, 8): + from typing import TypedDict +else: + TypedDict = dict if sys.version_info >= (3, 10): from types import UnionType else: UnionType = type +from robot import result, running +from robot.model import Message + + +# Type aliases used by DynamicLibrary and HybridLibrary. Name = str PositArgs = List[Any] NamedArgs = Dict[str, Any] @@ -266,3 +276,287 @@ def get_keyword_names(self) -> List[Name]: Returned names must match names of the implemented keyword methods. """ raise NotImplementedError + + +# Attribute dictionary specifications used by ListenerV2. + +class StartSuiteAttributes(TypedDict): + """Attributes passed to listener v2 ``start_suite`` method. + + See the User Guide for more information. + """ + id: str + longname: str + doc: str + metadata: dict + source: str + suites: List[str] + tests: List[str] + totaltests: int + starttime: str + + +class EndSuiteAttributes(StartSuiteAttributes): + """Attributes passed to listener v2 ``end_suite`` method. + + See the User Guide for more information. + """ + endtime: str + elapsedtime: int + status: str + statistics: str + message: str + + +class StartTestAttributes(TypedDict): + """Attributes passed to listener v2 ``start_test`` method. + + See the User Guide for more information. + """ + id: str + longname: str + originalname: str + doc: str + tags: List[str] + template: str + source: str + lineno: int + starttime: str + + +class EndTestAttributes(StartTestAttributes): + """Attributes passed to listener v2 ``end_test`` method. + + See the User Guide for more information. + """ + endtime: str + elapedtime: int + status: str + message: str + + +class OptionalKeywordAttributes(TypedDict, total=False): + """Extra attributes passed to listener v2 ``start/end_keyword`` methods. + + These attributes are included with control structures. For example, with + IF structures attributes include ``condition``. + """ + # FOR + variables: List[str] + flavor: str + values: List[str] + # ITERATION with FOR + variables: Dict[str, str] + # WHILE and IF + condition: str + # WHILE + limit: str + # EXCEPT + patterns: List[str] + pattern_type: str + variable: str + # RETURN + values: List[str] + + +class StartKeywordAttributes(OptionalKeywordAttributes): + """Attributes passed to listener v2 ``start_keyword`` method. + + See the User Guide for more information. + """ + type: str + kwname: str + libname: str + doc: str + args: List[str] + assign: List[str] + tags: List[str] + source: str + lineno: int + status: str + starttime: str + + +class EndKeywordAttributes(StartKeywordAttributes): + """Attributes passed to listener v2 ``end_keyword`` method. + + See the User Guide for more information. + """ + endtime: str + elapsedtime: int + + +class MessageAttributes(TypedDict): + """Attributes passed to listener v2 ``log_message`` and ``messages`` methods. + + See the User Guide for more information. + """ + message: str + level: str + timestamp: str + html: str + + +class LibraryAttributes(TypedDict): + """Attributes passed to listener v2 ``library_import`` method. + + See the User Guide for more information. + """ + args: List[str] + originalname: str + source: str + importer: Union[str, None] + + +class ResourceAttributes(TypedDict): + """Attributes passed to listener v2 ``resource_import`` method. + + See the User Guide for more information. + """ + source: str + importer: Union[str, None] + + +class VariablesAttributes(TypedDict): + """Attributes passed to listener v2 ``variables_import`` method. + + See the User Guide for more information. + """ + args: List[str] + source: str + importer: Union[str, None] + + +class ListenerV2: + """Optional base class for listeners using the listener API v2.""" + ROBOT_LISTENER_API_VERSION = 2 + + def start_suite(self, name: str, attributes: StartSuiteAttributes): + """Called when a suite starts.""" + + def end_suite(self, name: str, attributes: EndSuiteAttributes): + """Called when a suite end.""" + + def start_test(self, name: str, attributes: StartTestAttributes): + """Called when a test or task starts.""" + + def end_test(self, name: str, attributes: EndTestAttributes): + """Called when a test or task ends.""" + + def start_keyword(self, name: str, attributes: StartKeywordAttributes): + """Called when a keyword or a control structure like IF starts. + + The type of the started item is in ``attributes['type']``. Control + structures can contain extra attributes that are only relevant to them. + """ + + def end_keyword(self, name: str, attributes: EndKeywordAttributes): + """Called when a keyword or a control structure like IF ends. + + The type of the started item is in ``attributes['type']``. Control + structures can contain extra attributes that are only relevant to them. + """ + + def log_message(self, message: MessageAttributes): + """Called when a normal log message are emitted. + + The messages are typically logged by keywords, but also the framework + itself logs some messages. These messages end up to output.xml and + log.html. + """ + + def message(self, message: MessageAttributes): + """Called when framework's internal messages are emitted. + + Only logged by the framework itself. These messages end up to the syslog + if it is enabled. + """ + + def library_import(self, name: str, attributes: LibraryAttributes): + """Called after a library has been imported.""" + + def resource_import(self, name: str, attributes: ResourceAttributes): + """Called after a resource file has been imported.""" + + def variables_import(self, name: str, attributes: VariablesAttributes): + """Called after a variable file has been imported.""" + + def output_file(self, path: str): + """Called after the output file has been created. + + At this point the file is guaranteed to be closed. + """ + + def log_file(self, path: str): + """Called after the log file has been created.""" + + def report_file(self, path: str): + """Called after the report file has been created.""" + + def xunit_file(self, path: str): + """Called after the xunit compatible output file has been created.""" + + def debug_file(self, path: str): + """Called after the debug file has been created.""" + + def close(self): + """Called when the whole execution ends. + + With library listeners called when the library goes out of scope. + """ + + +class ListenerV3: + """Optional base class for listeners using the listener API v2.""" + ROBOT_LISTENER_API_VERSION = 3 + + def start_suite(self, data: running.TestSuite, result: result.TestSuite): + """Called when a suite starts.""" + + def end_suite(self, data: running.TestSuite, result: result.TestSuite): + """Called when a suite ends.""" + + def start_test(self, data: running.TestCase, result: result.TestCase): + """Called when a test or task starts.""" + + def end_test(self, data: running.TestCase, result: result.TestCase): + """Called when a test or ends starts.""" + + def log_message(self, message: Message): + """Called when a normal log message are emitted. + + The messages are typically logged by keywords, but also the framework + itself logs some messages. These messages end up to output.xml and + log.html. + """ + + def message(self, message: Message): + """Called when framework's internal messages are emitted. + + Only logged by the framework itself. These messages end up to the syslog + if it is enabled. + """ + + def output_file(self, path: str): + """Called after the output file has been created. + + At this point the file is guaranteed to be closed. + """ + + def log_file(self, path: str): + """Called after the log file has been created.""" + + def report_file(self, path: str): + """Called after the report file has been created.""" + + def xunit_file(self, path: str): + """Called after the xunit compatible output file has been created.""" + + def debug_file(self, path: str): + """Called after the debug file has been created.""" + + def close(self): + """Called when the whole execution ends. + + With library listeners called when the library goes out of scope. + """ diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 8b4035d4cce..c7950f90d5c 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -77,6 +77,8 @@ def _get_version2_arguments(self, item): def _get_attribute_value(self, item, name): value = getattr(item, name) + if value is None: + return '' return self._take_copy_of_mutable_value(value) def _take_copy_of_mutable_value(self, value): From b9b8720297d5a797c2a46d739db80d5593725ae9 Mon Sep 17 00:00:00 2001 From: sunday2 <160109794@qq.com> Date: Sat, 11 Mar 2023 07:23:38 +0800 Subject: [PATCH 0413/1592] Add JSON variable file support (#4542) Implements #4532. Documentation still missing. --- .../robot/variables/json_variable_file.robot | 72 +++++++++++++++++++ atest/testdata/variables/invalid.json | 1 + .../testdata/variables/invalid_encoding.json | 5 ++ .../variables/json_variable_file.robot | 63 ++++++++++++++++ atest/testdata/variables/non_dict.json | 6 ++ atest/testdata/variables/valid.json | 55 ++++++++++++++ atest/testdata/variables/valid2.json | 3 + atest/testdata/variables/valid3.JSON | 20 ++++++ .../testresources/res_and_var_files/cli.json | 3 + .../testresources/res_and_var_files/cli2.json | 3 + .../res_and_var_files/pythonpath.json | 3 + src/robot/running/namespace.py | 2 +- src/robot/variables/filesetter.py | 27 +++++++ src/robot/variables/scopes.py | 2 +- 14 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 atest/robot/variables/json_variable_file.robot create mode 100644 atest/testdata/variables/invalid.json create mode 100644 atest/testdata/variables/invalid_encoding.json create mode 100644 atest/testdata/variables/json_variable_file.robot create mode 100644 atest/testdata/variables/non_dict.json create mode 100644 atest/testdata/variables/valid.json create mode 100644 atest/testdata/variables/valid2.json create mode 100644 atest/testdata/variables/valid3.JSON create mode 100644 atest/testresources/res_and_var_files/cli.json create mode 100644 atest/testresources/res_and_var_files/cli2.json create mode 100644 atest/testresources/res_and_var_files/pythonpath.json diff --git a/atest/robot/variables/json_variable_file.robot b/atest/robot/variables/json_variable_file.robot new file mode 100644 index 00000000000..0f1d9f3850d --- /dev/null +++ b/atest/robot/variables/json_variable_file.robot @@ -0,0 +1,72 @@ +*** Settings *** +Suite Setup Run Tests --variablefile ${VARDIR}/cli.json -V ${VARDIR}/cli2.json --pythonpath ${VARDIR} +... variables/json_variable_file.robot +Resource atest_resource.robot + +*** Variables *** +${VARDIR} ${DATADIR}/../testresources/res_and_var_files + +*** Test Cases *** +Valid JSON file + Check Test Case ${TESTNAME} + +Valid JSON file with uper case extension + Check Test Case ${TESTNAME} + +Non-ASCII strings + Check Test Case ${TESTNAME} + +Dictionary is dot-accessible + Check Test Case ${TESTNAME} + +Nested dictionary is dot-accessible + Check Test Case ${TESTNAME} + +Dictionary inside list is dot-accessible + Check Test Case ${TESTNAME} + +JSON file in PYTHONPATH + Check Test Case ${TESTNAME} + +Import Variables keyword + Check Test Case ${TESTNAME} + +JSON file from CLI + Check Test Case ${TESTNAME} + +Invalid JSON file + Processing should have failed 0 4 invalid.json + ... ${EMPTY} + ... JSONDecodeError* + +Non-mapping JSON file + Processing should have failed 1 5 non_dict.json + ... ${EMPTY} + ... JSON variable file must be a mapping, got list. + +JSON files do not accept arguments + Processing should have failed 2 6 valid.json + ... with arguments ? arguments | not | accepted ?${SPACE} + ... JSON variable files do not accept arguments. + +Non-existing JSON file + Importing should have failed 3 7 + ... Variable file 'non_existing.Json' does not exist. + +JSON with invalid encoding + Processing should have failed 4 8 invalid_encoding.json + ... ${EMPTY} + ... UnicodeDecodeError* + +*** Keywords *** +Processing should have failed + [Arguments] ${index} ${lineno} ${file} ${arguments} ${error} + ${path} = Normalize Path ${DATADIR}/variables/${file} + Importing should have failed ${index} ${lineno} + ... Processing variable file '${path}' ${arguments}failed: + ... ${error} + +Importing should have failed + [Arguments] ${index} ${lineno} @{error} + Error In File ${index} variables/json_variable_file.robot ${lineno} + ... @{error} diff --git a/atest/testdata/variables/invalid.json b/atest/testdata/variables/invalid.json new file mode 100644 index 00000000000..b8bfd9d4e12 --- /dev/null +++ b/atest/testdata/variables/invalid.json @@ -0,0 +1 @@ +name: "jack" diff --git a/atest/testdata/variables/invalid_encoding.json b/atest/testdata/variables/invalid_encoding.json new file mode 100644 index 00000000000..32977a8e003 --- /dev/null +++ b/atest/testdata/variables/invalid_encoding.json @@ -0,0 +1,5 @@ +{ + "encoding": "latin-1", + "expected": "utf-8", + "non-ascii": "hyv yt!" +} diff --git a/atest/testdata/variables/json_variable_file.robot b/atest/testdata/variables/json_variable_file.robot new file mode 100644 index 00000000000..86a0348028f --- /dev/null +++ b/atest/testdata/variables/json_variable_file.robot @@ -0,0 +1,63 @@ +*** Settings *** +Variables valid.json +Variables pythonpath.json +Variables ./invalid.json +Variables ..${/}variables${/}non_dict.json +Variables valid.json arguments not accepted +Variables non_existing.Json +Variables invalid_encoding.json +Variables valid3.JSON +Test Template Should Be Equal + +*** Variables *** +@{EXPECTED LIST} one ${2} +&{EXPECTED DICT} a=1 b=${2} 3=${EXPECTED LIST} key with spaces=value with spaces + + +*** Test Cases *** +Valid JSON file + ${STRING} Hello, YAML! + ${INTEGER} ${42} + ${FLOAT} ${3.14} + ${LIST} ${EXPECTED LIST} + ${DICT} ${EXPECTED DICT} + ${BOOL} ${TRUE} + ${NULL} ${NULL} + +Valid JSON file with uper case extension + ${STRING IN JSON} Hello, YAML! + ${INTEGER IN JSON} ${42} + ${FLOAT IN JSON} ${3.14} + ${LIST IN JSON} ${EXPECTED LIST} + ${DICT IN JSON} ${EXPECTED DICT} + ${BOOL IN JSON} ${TRUE} + ${NULL IN JSON} ${NULL} + +Non-ASCII strings + ${NON} äscii + ${NÖN} äscii + +Dictionary is dot-accessible + ${DICT.a} 1 + ${DICT.b} ${2} + +Nested dictionary is dot-accessible + ${NESTED DICT.dict} ${EXPECTED DICT} + ${NESTED DICT.dict.a} 1 + ${NESTED DICT.dict.b} ${2} + +Dictionary inside list is dot-accessible + ${LIST WITH DICT[1].key} value + ${LIST WITH DICT[2].dict} ${EXPECTED DICT} + ${LIST WITH DICT[2].nested[0].leaf} value + +JSON file in PYTHONPATH + ${JSON FILE IN PYTHONPATH} ${TRUE} + +Import Variables keyword + [Setup] Import Variables ${CURDIR}/valid2.json + ${VALID 2} imported successfully + +JSON file from CLI + ${JSON FILE FROM CLI} woot! + ${JSON FILE FROM CLI2} kewl! diff --git a/atest/testdata/variables/non_dict.json b/atest/testdata/variables/non_dict.json new file mode 100644 index 00000000000..0d62503f354 --- /dev/null +++ b/atest/testdata/variables/non_dict.json @@ -0,0 +1,6 @@ +[ + "Not dictionary", + { + "true": "top-level" + } +] diff --git a/atest/testdata/variables/valid.json b/atest/testdata/variables/valid.json new file mode 100644 index 00000000000..ffe50a1599d --- /dev/null +++ b/atest/testdata/variables/valid.json @@ -0,0 +1,55 @@ +{ + "string": "Hello, YAML!", + "non": "äscii", + "nön": "äscii", + "integer": 42, + "float": 3.14, + "bool": true, + "null": null, + "list": [ + "one", + 2 + ], + "dict": { + "a": "1", + "b": 2, + "3": [ + "one", + 2 + ], + "key with spaces": "value with spaces" + }, + "nested dict": { + "dict": { + "a": "1", + "b": 2, + "3": [ + "one", + 2 + ], + "key with spaces": "value with spaces" + } + }, + "list with dict": [ + "scalar", + { + "key": "value" + }, + { + "dict": { + "a": "1", + "b": 2, + "3": [ + "one", + 2 + ], + "key with spaces": "value with spaces" + }, + "nested": [ + { + "leaf": "value" + } + ] + } + ] +} diff --git a/atest/testdata/variables/valid2.json b/atest/testdata/variables/valid2.json new file mode 100644 index 00000000000..3afe58ec444 --- /dev/null +++ b/atest/testdata/variables/valid2.json @@ -0,0 +1,3 @@ +{ + "valid 2": "imported successfully" +} diff --git a/atest/testdata/variables/valid3.JSON b/atest/testdata/variables/valid3.JSON new file mode 100644 index 00000000000..6f211f3bad7 --- /dev/null +++ b/atest/testdata/variables/valid3.JSON @@ -0,0 +1,20 @@ +{ + "string in JSON": "Hello, YAML!", + "integer in JSON": 42, + "float in JSON": 3.14, + "bool in JSON": true, + "null in JSON": null, + "list in JSON": [ + "one", + 2 + ], + "dict in JSON": { + "a": "1", + "b": 2, + "3": [ + "one", + 2 + ], + "key with spaces": "value with spaces" + } +} diff --git a/atest/testresources/res_and_var_files/cli.json b/atest/testresources/res_and_var_files/cli.json new file mode 100644 index 00000000000..f10129148ab --- /dev/null +++ b/atest/testresources/res_and_var_files/cli.json @@ -0,0 +1,3 @@ +{ + "json file from cli": "woot!" +} diff --git a/atest/testresources/res_and_var_files/cli2.json b/atest/testresources/res_and_var_files/cli2.json new file mode 100644 index 00000000000..c10204ba799 --- /dev/null +++ b/atest/testresources/res_and_var_files/cli2.json @@ -0,0 +1,3 @@ +{ + "json file from cli2": "kewl!" +} diff --git a/atest/testresources/res_and_var_files/pythonpath.json b/atest/testresources/res_and_var_files/pythonpath.json new file mode 100644 index 00000000000..d08a053a985 --- /dev/null +++ b/atest/testresources/res_and_var_files/pythonpath.json @@ -0,0 +1,3 @@ +{ + "json file in pythonpath": true +} diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index cc0de429f1d..b0b23f3814c 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -37,7 +37,7 @@ class Namespace: _default_libraries = ('BuiltIn', 'Reserved', 'Easter') _library_import_by_path_ends = ('.py', '/', os.sep) - _variables_import_by_path_ends = _library_import_by_path_ends + ('.yaml', '.yml') + _variables_import_by_path_ends = _library_import_by_path_ends + ('.yaml', '.yml') + ('.json',) def __init__(self, variables, suite, resource, languages): LOGGER.info(f"Initializing namespace for suite '{suite.longname}'.") diff --git a/src/robot/variables/filesetter.py b/src/robot/variables/filesetter.py index 3569a8c6667..f7f2bd25aab 100644 --- a/src/robot/variables/filesetter.py +++ b/src/robot/variables/filesetter.py @@ -15,6 +15,7 @@ import inspect import io +import json try: import yaml except ImportError: @@ -43,6 +44,8 @@ def _import_if_needed(self, path_or_variables, args=None): % (path_or_variables, args)) if path_or_variables.lower().endswith(('.yaml', '.yml')): importer = YamlImporter() + elif path_or_variables.lower().endswith('.json'): + importer = JsonImporter() else: importer = PythonImporter() try: @@ -147,3 +150,27 @@ def _validate(self, name, value): if name[0] == '&' and not is_dict_like(value): raise DataError("Invalid variable '%s': Expected dict-like value, " "got %s." % (name, type_name(value))) + + +class JsonImporter: + def import_variables(self, path, args=None): + if args: + raise DataError('JSON variable files do not accept arguments.') + variables = self._import(path) + return [('${%s}' % name, self._dot_dict(value)) + for name, value in variables] + + def _import(self, path): + with io.open(path, encoding='UTF-8') as stream: + variables = json.load(stream) + if not is_dict_like(variables): + raise DataError('JSON variable file must be a mapping, got %s.' + % type_name(variables)) + return variables.items() + + def _dot_dict(self, value): + if is_dict_like(value): + return DotDict((k, self._dot_dict(v)) for k, v in value.items()) + if is_list_like(value): + return [self._dot_dict(v) for v in value] + return value diff --git a/src/robot/variables/scopes.py b/src/robot/variables/scopes.py index 149f005cb6e..3092b995b93 100644 --- a/src/robot/variables/scopes.py +++ b/src/robot/variables/scopes.py @@ -162,7 +162,7 @@ def as_dict(self, decoration=True): class GlobalVariables(Variables): - _import_by_path_ends = ('.py', '/', os.sep, '.yaml', '.yml') + _import_by_path_ends = ('.py', '/', os.sep, '.yaml', '.yml', '.json') def __init__(self, settings): super().__init__() From 24ed331fe41a088a1f648c60b12e8025186c1a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 10 Mar 2023 22:57:12 +0200 Subject: [PATCH 0414/1592] API doc tuning --- src/robot/api/__init__.py | 4 ++-- src/robot/api/interfaces.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/api/__init__.py b/src/robot/api/__init__.py index d1440d186e2..047bd377268 100644 --- a/src/robot/api/__init__.py +++ b/src/robot/api/__init__.py @@ -30,8 +30,8 @@ reporting failures and other events. These exceptions can be imported also directly via :mod:`robot.api` like ``from robot.api import SkipExecution``. -* :mod:`.interfaces` that contains optional base classes that can be used - when creating libraries or listeners. New in RF 6.1. +* :mod:`.interfaces` module containing optional base classes that can be used + when creating libraries or listeners. New in Robot Framework 6.1. * :mod:`.parsing` module exposing the parsing APIs. This module is new in Robot Framework 4.0. Various parsing related functions and classes were exposed diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 99c7feaed8c..8ac095f2eb5 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -29,7 +29,7 @@ base class. .. note:: These classes are not exposed via the top level :mod:`robot.api` - package. They need to imported via :mod:`robot.api.interfaces`. + package and need to imported via :mod:`robot.api.interfaces`. .. note:: Using :class:`ListenerV2` and :class:`ListenerV3` requires Python 3.8 or newer. From 21d584864c981ffb4e2f6f3406e673e59b4b8308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 11 Mar 2023 00:21:03 +0200 Subject: [PATCH 0415/1592] Don't run suite setup/teardown if all tests skipped or excluded. Fixes #4571. --- atest/robot/running/skip.robot | 10 +++++++++- .../running/skip/all_skipped/__init__.robot | 3 +++ .../running/skip/all_skipped/tests.robot | 18 +++++++++++++++++ src/robot/model/testsuite.py | 20 +++++++++++++++---- src/robot/running/suiterunner.py | 13 +++++++++++- utest/model/test_testsuite.py | 13 +++++++++++- 6 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 atest/testdata/running/skip/all_skipped/__init__.robot create mode 100644 atest/testdata/running/skip/all_skipped/tests.robot diff --git a/atest/robot/running/skip.robot b/atest/robot/running/skip.robot index e6f8726acf1..e8a9d2e2f06 100644 --- a/atest/robot/running/skip.robot +++ b/atest/robot/running/skip.robot @@ -119,7 +119,7 @@ Skipped with --SkipOnFailure when Failure in Test Teardown Check Test Case ${TEST NAME} Skipped with --SkipOnFailure when Set Tags Used in Teardown - Check Test Case Skipped with --SkipOnFailure when Set Tags Used in Teardown + Check Test Case ${TEST NAME} Skipped although test fails since test is tagged with robot:skip-on-failure Check Test Case ${TEST NAME} @@ -127,3 +127,11 @@ Skipped although test fails since test is tagged with robot:skip-on-failure Using Skip Does Not Affect Passing And Failing Tests Check Test Case Passing Test Check Test Case Failing Test + +Suite setup and teardown are not run if all tests are unconditionally skipped or excluded + ${suite} = Get Test Suite All Skipped + Should Be True not ($suite.setup or $suite.teardown) + Should Be True not ($suite.suites[0].setup or $suite.suites[0].teardown) + Check Test Case Skip using robot:skip + Check Test Case Skip using --skip + Length Should Be ${suite.suites[0].tests} 2 diff --git a/atest/testdata/running/skip/all_skipped/__init__.robot b/atest/testdata/running/skip/all_skipped/__init__.robot new file mode 100644 index 00000000000..0c51e728b57 --- /dev/null +++ b/atest/testdata/running/skip/all_skipped/__init__.robot @@ -0,0 +1,3 @@ +*** Settings *** +Suite Setup Fail Because all tests are skipped +Suite Teardown Fail these should not be run diff --git a/atest/testdata/running/skip/all_skipped/tests.robot b/atest/testdata/running/skip/all_skipped/tests.robot new file mode 100644 index 00000000000..b973819f7b0 --- /dev/null +++ b/atest/testdata/running/skip/all_skipped/tests.robot @@ -0,0 +1,18 @@ +*** Settings *** +Suite Setup Fail Because all tests are skipped +Suite Teardown Fail these should not be run + +*** Test Cases *** +Skip using robot:skip + [Documentation] SKIP Test skipped using 'robot:skip' tag. + [Tags] robot:skip + Fail Should not be run + +Skip using --skip + [Documentation] SKIP Test skipped using '--skip' command line option. + [Tags] skip-this + Fail Should not be run + +Exclude using robot:exclude + [Tags] robot:exclude + Fail Should not be run diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 5b32ca03bb9..62071cf0a56 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -95,7 +95,7 @@ def longname(self): """Suite name prefixed with the long name of the parent suite.""" if not self.parent: return self.name - return '%s.%s' % (self.parent.longname, self.name) + return f'{self.parent.longname}.{self.name}' @setter def metadata(self, metadata): @@ -206,16 +206,28 @@ def id(self): ..., ``s1-s2-s1``, ..., and so on. The first test in a suite has an id like ``s1-t1``, the second has an - id ``s1-t2``, and so on. Similarly keywords in suites (setup/teardown) + id ``s1-t2``, and so on. Similarly, keywords in suites (setup/teardown) and in tests get ids like ``s1-k1``, ``s1-t1-k1``, and ``s1-s4-t2-k5``. """ if not self.parent: return 's1' - return '%s-s%d' % (self.parent.id, self.parent.suites.index(self)+1) + index = self.parent.suites.index(self) + return f'{self.parent.id}-s{index + 1}' + + @property + def all_tests(self): + """Yields all tests this suite and its child suites contain. + + New in Robot Framework 6.1. + """ + yield from self.tests + for suite in self.suites: + yield from suite.all_tests @property def test_count(self): - """Number of the tests in this suite, recursively.""" + """Total number of the tests in this suite and in its child suites.""" + # This is considerably faster than `return len(list(self.all_tests))`. return len(self.tests) + sum(suite.test_count for suite in self.suites) @property diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index e01ffc2a1ec..2d145a25581 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -85,7 +85,18 @@ def start_suite(self, suite): suites=suite.suites, test_count=suite.test_count)) self._output.register_error_listener(self._suite_status.error_occurred) - self._run_setup(suite.setup, self._suite_status) + if self._any_test_run(suite): + self._run_setup(suite.setup, self._suite_status) + + def _any_test_run(self, suite): + skipped_tags = self._skipped_tags + for test in suite.all_tests: + tags = test.tags + if not (skipped_tags.match(tags) + or tags.robot('skip') + or tags.robot('exclude')): + return True + return False def _resolve_setting(self, value): if is_list_like(value): diff --git a/utest/model/test_testsuite.py b/utest/model/test_testsuite.py index de6ee8e0363..49480fbf11f 100644 --- a/utest/model/test_testsuite.py +++ b/utest/model/test_testsuite.py @@ -49,7 +49,6 @@ def test_name_from_source(self): assert_equal(TestSuite(source=Path(inp)).name, exp) assert_equal(TestSuite(source=Path(inp).resolve()).name, exp) - def test_suite_name_from_source(self): suite = TestSuite(source='example.robot') assert_equal(suite.name, 'Example') @@ -99,6 +98,18 @@ def test_set_tags_also_to_new_child(self): assert_equal(list(suite.tests[2].tags), ['a']) assert_equal(list(suite.suites[0].tests[0].tags), ['a']) + def test_all_tests_and_test_count(self): + root = TestSuite() + assert_equal(root.test_count, 0) + assert_equal(list(root.all_tests), []) + for i in range(10): + suite = root.suites.create() + for j in range(100): + suite.tests.create() + assert_equal(root.test_count, 1000) + assert_equal(len(list(root.all_tests)), 1000) + assert_equal(list(root.suites[0].all_tests), list(root.suites[0].tests)) + def test_configure_only_works_with_root_suite(self): for Suite in TestSuite, RunningTestSuite, ResultTestSuite: root = Suite() From f7ee913622e60c0cf630e1580c2bb58c57100187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 11 Mar 2023 01:36:26 +0200 Subject: [PATCH 0416/1592] Document JSON variable file support #4532 --- .../ResourceAndVariableFiles.rst | 64 ++++++++++++++----- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst index b0171a902c2..ff855e4da3c 100644 --- a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst +++ b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst @@ -153,11 +153,12 @@ two different approaches for creating variables: Alternatively variable files can be implemented as `classes`__ that the framework will instantiate. Also in this case it is possible to create variables as attributes or get them dynamically from the `get_variables` -method. Variable files can also be created as `YAML files`__. +method. Variable files can also be created as YAML__ and JSON__. __ `Setting variables in command line`_ __ `Implementing variable file as a class`_ __ `Variable file as YAML`_ +__ `Variable file as JSON`_ Taking variable files into use ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -542,8 +543,9 @@ creates only one variable `${DYNAMIC VARIABLE}`. Variable file as YAML ~~~~~~~~~~~~~~~~~~~~~ -Variable files can also be implemented as `YAML <http://yaml.org>`_ files. -YAML is a data serialization language with a simple and human-friendly syntax. +Variable files can also be implemented as `YAML <https://yaml.org>`_ files. +YAML is a data serialization language with a simple and human-friendly syntax +that is nevertheless easy for machines to parse. The following example demonstrates a simple YAML file: .. sourcecode:: yaml @@ -558,20 +560,11 @@ The following example demonstrates a simple YAML file: two: kaksi with spaces: kolme -.. note:: Using YAML files with Robot Framework requires `PyYAML - <http://pyyaml.org>`_ module to be installed. If you have - pip_ installed, you can install it simply by running - `pip install pyyaml`. - - YAML variable files must have either :file:`.yaml` or :file:`.yml` - extension. Support for the :file:`.yml` extension is new in - Robot Framework 3.2. - YAML variable files can be used exactly like normal variable files from the command line using :option:`--variablefile` option, in the Settings section using :setting:`Variables` setting, and dynamically using the -:name:`Import Variables` keyword. - +:name:`Import Variables` keyword. They are automatically recognized by their +extension that must be either :file:`.yaml` or :file:`.yml`. If the above YAML file is imported, it will create exactly the same variables as this Variable section: @@ -583,7 +576,7 @@ as this Variable section: @{LIST} one two &{DICT} one=yksi two=kaksi with spaces=kolme -YAML files used as variable files must always be mappings in the top level. +YAML files used as variable files must always be mappings on the top level. As the above example demonstrates, keys and values in the mapping become variable names and values, respectively. Variable values can be any data types supported by YAML syntax. If names or values contain non-ASCII @@ -595,5 +588,42 @@ Most importantly, values of these dictionaries are accessible as attributes like `${DICT.one}`, assuming their names are valid as Python attribute names. If the name contains spaces or is otherwise not a valid attribute name, it is always possible to access dictionary values using syntax like -`${DICT}[with spaces]` syntax. The created dictionaries are also ordered, but -unfortunately the original source order of in the YAML file is not preserved. +`${DICT}[with spaces]` syntax. + +.. note:: Using YAML files with Robot Framework requires `PyYAML + <http://pyyaml.org>`_ module to be installed. You can typically + install it with pip_ like `pip install pyyaml`. + +Variable file as JSON +~~~~~~~~~~~~~~~~~~~~~ + +Variable files can also be implemented as `JSON <https://json.org>`_ files. +Similarly as YAML discussed in the previous section, JSON is a data +serialization format targeted both for humans and machines. It is based on +JavaScript syntax and it is not as human-friendly as YAML, but it still +relatively easy to understand and modify. The following example contains +exactly the same data as the earlier YAML example: + +.. sourcecode:: json + + { + "string": "Hello, world!", + "integer": 42, + "list": [ + "one", + "two" + ], + "dict": { + "one": "yksi", + "two": "kaksi", + "with spaces": "kolme" + } + } + +JSON variable files are automatically recognized by their :file:`.json` +extension and they can be used exactly like YAML variable files. They +also have exactly same requirements for structure, encoding, and so on. +Unlike YAML, Python supports JSON out-of-the-box so no extra modules need +to be installed. + +.. note:: Support for JSON variable files is new in Robot Framework 6.1. From 24dd2dda62abb4fa6b7b62893de334539ed397bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 13 Mar 2023 01:24:58 +0200 Subject: [PATCH 0417/1592] Add more generic FOR examples to tests --- .../cli/model_modifiers/ModelModifier.py | 2 +- .../robot/cli/model_modifiers/pre_rebot.robot | 2 +- atest/robot/cli/model_modifiers/pre_run.robot | 2 +- .../all_passed_tag_and_name.robot | 4 ++-- .../using_run_keyword.robot | 2 +- atest/testdata/misc/for_loops.robot | 21 ++++++++++++++++--- atest/testresources/listeners/listeners.py | 2 +- utest/testdoc/test_jsonconverter.py | 4 ++-- 8 files changed, 27 insertions(+), 12 deletions(-) diff --git a/atest/robot/cli/model_modifiers/ModelModifier.py b/atest/robot/cli/model_modifiers/ModelModifier.py index 8e1f2ab1ed4..41aa2f2220f 100644 --- a/atest/robot/cli/model_modifiers/ModelModifier.py +++ b/atest/robot/cli/model_modifiers/ModelModifier.py @@ -25,7 +25,7 @@ def start_test(self, test): test.tags.add(self.config) def start_for(self, for_): - if for_.parent.name == 'FOR IN RANGE loop in test': + if for_.parent.name == 'FOR IN RANGE': for_.flavor = 'IN' for_.values = ['FOR', 'is', 'modified!'] diff --git a/atest/robot/cli/model_modifiers/pre_rebot.robot b/atest/robot/cli/model_modifiers/pre_rebot.robot index 82908d75f66..7c49b041f4e 100644 --- a/atest/robot/cli/model_modifiers/pre_rebot.robot +++ b/atest/robot/cli/model_modifiers/pre_rebot.robot @@ -59,7 +59,7 @@ Modifiers are used before normal configuration Modify FOR [Setup] Modify FOR and IF - ${tc} = Check Test Case For In Range Loop In Test + ${tc} = Check Test Case FOR IN RANGE Should Be Equal ${tc.body[0].flavor} IN Should Be Equal ${tc.body[0].values} ${{('FOR', 'is', 'modified!')}} Should Be Equal ${tc.body[0].body[0].variables['\${i}']} 0 (modified) diff --git a/atest/robot/cli/model_modifiers/pre_run.robot b/atest/robot/cli/model_modifiers/pre_run.robot index e1c5f924efd..84a265917b9 100644 --- a/atest/robot/cli/model_modifiers/pre_run.robot +++ b/atest/robot/cli/model_modifiers/pre_run.robot @@ -54,7 +54,7 @@ Modifiers are used before normal configuration Modify FOR and IF Run Tests --prerun ${CURDIR}/ModelModifier.py misc/for_loops.robot misc/if_else.robot - ${tc} = Check Test Case For In Range Loop In Test + ${tc} = Check Test Case FOR IN RANGE Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} FOR Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} is Check Log Message ${tc.body[0].body[2].body[0].msgs[0]} modified! diff --git a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot index 5593caf2803..c55adc45f79 100644 --- a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot +++ b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot @@ -45,10 +45,10 @@ IF/ELSE in All mode FOR in All mode [Setup] Previous test should have passed IF/ELSE in All mode - ${tc} = Check Test Case FOR Loop In Test + ${tc} = Check Test Case FOR Length Should Be ${tc.body} 1 FOR Loop Should Be Empty ${tc.body[0]} IN - ${tc} = Check Test Case FOR IN RANGE Loop In Test + ${tc} = Check Test Case FOR IN RANGE Length Should Be ${tc.body} 1 FOR Loop Should Be Empty ${tc.body[0]} IN RANGE diff --git a/atest/robot/output/listener_interface/using_run_keyword.robot b/atest/robot/output/listener_interface/using_run_keyword.robot index 264b0d70699..23f9a005f3f 100644 --- a/atest/robot/output/listener_interface/using_run_keyword.robot +++ b/atest/robot/output/listener_interface/using_run_keyword.robot @@ -75,7 +75,7 @@ In start_keyword and end_keyword with user keyword Length Should Be ${tc.body[3].body} 3 In start_keyword and end_keyword with FOR loop - ${tc} = Check Test Case FOR loop in test + ${tc} = Check Test Case FOR ${for} = Set Variable ${tc.body[1]} Should Be Equal ${for.type} FOR Length Should Be ${for.body} 5 diff --git a/atest/testdata/misc/for_loops.robot b/atest/testdata/misc/for_loops.robot index 0ebef0dd599..76bf4139e12 100644 --- a/atest/testdata/misc/for_loops.robot +++ b/atest/testdata/misc/for_loops.robot @@ -1,13 +1,28 @@ +*** Variables *** +@{ANIMALS} cat dog horse +@{FINNISH} kissa koira hevonen + *** Test Cases *** -FOR loop in test - FOR ${pet} IN cat dog horse +FOR + FOR ${pet} IN @{ANIMALS} Log ${pet} END -FOR IN RANGE loop in test +FOR IN RANGE FOR ${i} IN RANGE 10 Log ${i} IF ${i} == 9 BREAK CONTINUE Not executed! END + +FOR IN ENUMERATE + FOR ${index} ${element} IN ENUMERATE @{ANIMALS} start=1 + Log ${index}: ${element} + END + +FOR IN ZIP + FOR ${en} ${fi} IN ZIP ${ANIMALS} ${FINNISH} + Log ${en} is ${fi} in Finnish + + END diff --git a/atest/testresources/listeners/listeners.py b/atest/testresources/listeners/listeners.py index 44526c56376..40c4f0b81ce 100644 --- a/atest/testresources/listeners/listeners.py +++ b/atest/testresources/listeners/listeners.py @@ -78,7 +78,7 @@ def _get_expected_type(self, kwname, libname, args, source, lineno, **ignore): if kwname == '': source = os.path.basename(source) if source == 'for_loops.robot': - return 'BREAK' if lineno == 10 else 'CONTINUE' + return 'BREAK' if lineno == 14 else 'CONTINUE' return 'ELSE' expected = args[0] if libname == 'BuiltIn' else kwname return {'Suite Setup': 'SETUP', 'Suite Teardown': 'TEARDOWN', diff --git a/utest/testdoc/test_jsonconverter.py b/utest/testdoc/test_jsonconverter.py index 24c0726c5b1..b778a2cdcbe 100644 --- a/utest/testdoc/test_jsonconverter.py +++ b/utest/testdoc/test_jsonconverter.py @@ -28,7 +28,7 @@ def test_suite(self): fullName='Misc', doc='<p>My doc</p>', metadata=[('1', '<p>2</p>'), ('abc', '<p>123</p>')], - numberOfTests=190, + numberOfTests=192, tests=[], keywords=[]) test_convert(self.suite['suites'][0], @@ -155,7 +155,7 @@ def test_test_setup_and_teardown(self): def test_for_loops(self): test_convert(self.suite['suites'][1]['tests'][0]['keywords'][0], - name='${pet} IN [ cat | dog | horse ]', + name='${pet} IN [ @{ANIMALS} ]', arguments='', type='FOR') test_convert(self.suite['suites'][1]['tests'][1]['keywords'][0], From e8a0542e5a5b09108082c03aabac37dcd50cacda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 13 Mar 2023 01:27:01 +0200 Subject: [PATCH 0418/1592] f-strings and super() are super --- src/robot/parsing/model/statements.py | 26 ++++++++++++++------------ src/robot/running/bodyrunner.py | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index b48ba62aed2..e6b23f3eb40 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -150,8 +150,10 @@ def __getitem__(self, item): return self.tokens[item] def __repr__(self): - errors = '' if not self.errors else ', errors=%s' % list(self.errors) - return '%s(tokens=%s%s)' % (type(self).__name__, list(self.tokens), errors) + name = type(self).__name__ + tokens = f'tokens={list(self.tokens)}' + errors = f', errors={list(self.errors)}' if self.errors else '' + return f'{name}({tokens}{errors})' class DocumentationOrMetadata(Statement): @@ -225,7 +227,7 @@ def from_params(cls, type, name=None, eol=EOL): 'Keywords', 'Comments') name = dict(zip(cls.handles_types, names))[type] if not name.startswith('*'): - name = '*** %s ***' % name + name = f'*** {name} ***' return cls([ Token(type, name), Token('EOL', '\n') @@ -548,7 +550,7 @@ def validate(self, ctx: 'ValidationContext'): name = self.get_value(Token.VARIABLE) match = search_variable(name, ignore_errors=True) if not match.is_assign(allow_assign_mark=True): - self.errors += ("Invalid variable name '%s'." % name,) + self.errors += (f"Invalid variable name '{name}'.",) if match.is_dict_assign(allow_assign_mark=True): self._validate_dict_items() @@ -556,9 +558,9 @@ def _validate_dict_items(self): for item in self.get_values(Token.ARGUMENT): if not self._is_valid_dict_item(item): self.errors += ( - "Invalid dictionary variable item '%s'. " - "Items must use 'name=value' syntax or be dictionary " - "variables themselves." % item, + f"Invalid dictionary variable item '{item}'. " + f"Items must use 'name=value' syntax or be dictionary " + f"variables themselves.", ) def _is_valid_dict_item(self, item): @@ -583,7 +585,7 @@ def name(self): def validate(self, ctx: 'ValidationContext'): if not self.name: - self.errors += (f'Test name cannot be empty.',) + self.errors += ('Test name cannot be empty.',) @Statement.register @@ -825,12 +827,12 @@ def validate(self, ctx: 'ValidationContext'): else: for var in self.variables: if not is_scalar_assign(var): - self._add_error("invalid loop variable '%s'" % var) + self._add_error(f"invalid loop variable '{var}'") if not self.values: self._add_error('no loop values') def _add_error(self, error): - self.errors += ('FOR loop has %s.' % error,) + self.errors += (f'FOR loop has {error}.',) class IfElseHeader(Statement): @@ -868,9 +870,9 @@ def condition(self): def validate(self, ctx: 'ValidationContext'): conditions = len(self.get_tokens(Token.ARGUMENT)) if conditions == 0: - self.errors += ('%s must have a condition.' % self.type,) + self.errors += (f'{self.type} must have a condition.',) if conditions > 1: - self.errors += ('%s cannot have more than one condition.' % self.type,) + self.errors += (f'{self.type} cannot have more than one condition.',) @Statement.register diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index d57ba376538..164a6d61505 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -248,7 +248,7 @@ def _map_values_to_rounds(self, values, per_round): msg = get_error_message() raise DataError(f'Converting FOR IN RANGE values failed: {msg}.') values = frange(*values) - return ForInRunner._map_values_to_rounds(self, values, per_round) + return super()._map_values_to_rounds(values, per_round) def _to_number_with_arithmetic(self, item): if is_number(item): From 41db9274c8f4d39c438e9b1ceee2d50c09f58d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 13 Mar 2023 15:55:55 +0200 Subject: [PATCH 0419/1592] Handle optional FOR IN ENUMERATE start index in parser. Fixes #4684. --- atest/robot/running/for/for.resource | 13 +++---- .../running/for/for_dict_iteration.robot | 2 +- .../robot/running/for/for_in_enumerate.robot | 8 ++--- doc/schema/robot.xsd | 1 + src/robot/model/control.py | 11 ++++-- src/robot/output/xmllogger.py | 5 ++- src/robot/parsing/lexer/statementlexers.py | 9 +++-- src/robot/parsing/model/blocks.py | 4 +++ src/robot/parsing/model/statements.py | 8 +++++ src/robot/result/model.py | 13 ++++--- src/robot/result/xmlelementhandlers.py | 9 +++-- src/robot/running/bodyrunner.py | 35 ++++++------------- src/robot/running/builder/transformers.py | 3 +- src/robot/running/model.py | 4 +-- utest/parsing/test_model.py | 30 ++++++++++++++-- utest/reporting/test_jsmodelbuilders.py | 9 +++++ utest/running/test_run_model.py | 3 ++ 17 files changed, 110 insertions(+), 57 deletions(-) diff --git a/atest/robot/running/for/for.resource b/atest/robot/running/for/for.resource index 350433731fd..f4b36ae9875 100644 --- a/atest/robot/running/for/for.resource +++ b/atest/robot/running/for/for.resource @@ -5,20 +5,21 @@ Resource atest_resource.robot Check test and get loop [Arguments] ${test name} ${loop index}=0 ${tc} = Check Test Case ${test name} - RETURN ${tc.kws}[${loop index}] + RETURN ${tc.body}[${loop index}] Check test and failed loop - [Arguments] ${test name} ${type}=FOR ${loop index}=0 + [Arguments] ${test name} ${type}=FOR ${loop index}=0 &{config} ${loop} = Check test and get loop ${test name} ${loop index} Length Should Be ${loop.body} 2 Should Be Equal ${loop.body[0].type} ITERATION Should Be Equal ${loop.body[1].type} MESSAGE - Run Keyword Should Be ${type} loop ${loop} 1 FAIL + Run Keyword Should Be ${type} loop ${loop} 1 FAIL &{config} Should be FOR loop - [Arguments] ${loop} ${iterations} ${status}=PASS ${flavor}=IN + [Arguments] ${loop} ${iterations} ${status}=PASS ${flavor}=IN ${start}=${None} Should Be Equal ${loop.type} FOR Should Be Equal ${loop.flavor} ${flavor} + Should Be Equal ${loop.start} ${start} Length Should Be ${loop.body.filter(messages=False)} ${iterations} Should Be Equal ${loop.status} ${status} @@ -31,8 +32,8 @@ Should be IN ZIP loop Should Be FOR Loop ${loop} ${iterations} ${status} flavor=IN ZIP Should be IN ENUMERATE loop - [Arguments] ${loop} ${iterations} ${status}=PASS - Should Be FOR Loop ${loop} ${iterations} ${status} flavor=IN ENUMERATE + [Arguments] ${loop} ${iterations} ${status}=PASS ${start}=${None} + Should Be FOR Loop ${loop} ${iterations} ${status} IN ENUMERATE ${start} Should be FOR iteration [Arguments] ${iteration} &{variables} diff --git a/atest/robot/running/for/for_dict_iteration.robot b/atest/robot/running/for/for_dict_iteration.robot index ceb0c5caf45..52910c9337e 100644 --- a/atest/robot/running/for/for_dict_iteration.robot +++ b/atest/robot/running/for/for_dict_iteration.robot @@ -43,7 +43,7 @@ FOR IN ENUMERATE loop with three variables FOR IN ENUMERATE loop with start ${loop} = Check test and get loop ${TESTNAME} - Should be IN ENUMERATE loop ${loop} 3 + Should be IN ENUMERATE loop ${loop} 3 start=42 FOR IN ENUMERATE loop with more than three variables is invalid Check test and failed loop ${TESTNAME} IN ENUMERATE diff --git a/atest/robot/running/for/for_in_enumerate.robot b/atest/robot/running/for/for_in_enumerate.robot index dd839246c15..93bf15491bc 100644 --- a/atest/robot/running/for/for_in_enumerate.robot +++ b/atest/robot/running/for/for_in_enumerate.robot @@ -21,7 +21,7 @@ Values from list variable Start ${loop} = Check test and get loop ${TEST NAME} - Should be IN ENUMERATE loop ${loop} 5 + Should be IN ENUMERATE loop ${loop} 5 start=1 Should be FOR iteration ${loop.body[0]} \${index}=1 \${item}=1 Should be FOR iteration ${loop.body[1]} \${index}=2 \${item}=2 Should be FOR iteration ${loop.body[2]} \${index}=3 \${item}=3 @@ -33,10 +33,10 @@ Escape start Should be IN ENUMERATE loop ${loop} 2 Invalid start - Check test and failed loop ${TEST NAME} IN ENUMERATE + Check test and failed loop ${TEST NAME} IN ENUMERATE start=invalid Invalid variable in start - Check test and failed loop ${TEST NAME} IN ENUMERATE + Check test and failed loop ${TEST NAME} IN ENUMERATE start=\${invalid} Index and two items ${loop} = Check test and get loop ${TEST NAME} 1 @@ -64,4 +64,4 @@ No values Check test and failed loop ${TEST NAME} IN ENUMERATE No values with start - Check test and failed loop ${TEST NAME} IN ENUMERATE + Check test and failed loop ${TEST NAME} IN ENUMERATE start=0 diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index 413a9d7223a..a3db0b83b7b 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -124,6 +124,7 @@ <xs:element name="status" type="BodyItemStatus" /> </xs:choice> <xs:attribute name="flavor" type="ForFlavor" /> + <xs:attribute name="start" type="xs:string" /> <!-- Used if IN ENUMERATE uses `start=`. --> </xs:complexType> <xs:simpleType name="ForFlavor"> <xs:restriction base="xs:string"> diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 08739a51c68..4869cbd3828 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -24,12 +24,14 @@ class For(BodyItem): type = BodyItem.FOR body_class = Body repr_args = ('variables', 'flavor', 'values') - __slots__ = ['variables', 'flavor', 'values'] + __slots__ = ['variables', 'flavor', 'values', 'start'] - def __init__(self, variables=(), flavor='IN', values=(), parent=None): + def __init__(self, variables=(), flavor='IN', values=(), start=None, + parent=None): self.variables = variables self.flavor = flavor self.values = values + self.start = start self.parent = parent self.body = None @@ -55,11 +57,14 @@ def __str__(self): return 'FOR %s %s %s' % (variables, self.flavor, values) def to_dict(self): - return {'type': self.type, + data = {'type': self.type, 'variables': list(self.variables), 'flavor': self.flavor, 'values': list(self.values), 'body': self.body.to_dicts()} + if self.start is not None: + data['start'] = self.start + return data @Body.register diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 2b8e16657c6..9b5dbef6ba2 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -100,7 +100,10 @@ def end_if_branch(self, branch): self._writer.end('branch') def start_for(self, for_): - self._writer.start('for', {'flavor': for_.flavor}) + attrs = {'flavor': for_.flavor} + if for_.start is not None: + attrs['start'] = for_.start + self._writer.start('for', attrs) for name in for_.variables: self._writer.element('var', name) for value in for_.values: diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index f2f7237afc7..c06bba37390 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -190,15 +190,18 @@ def handles(cls, statement: list, ctx: TestOrKeywordContext): def lex(self): self.statement[0].type = Token.FOR - separator_seen = False + separator = None for token in self.statement[1:]: - if separator_seen: + if separator: token.type = Token.ARGUMENT elif normalize_whitespace(token.value) in self.separators: token.type = Token.FOR_SEPARATOR - separator_seen = True + separator = normalize_whitespace(token.value) else: token.type = Token.VARIABLE + if (separator == 'IN ENUMERATE' + and self.statement[-1].value.startswith('start=')): + self.statement[-1].type = Token.OPTION class IfHeaderLexer(TypeAndArguments): diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index c9809ea5c46..f122574b3c7 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -238,6 +238,10 @@ def values(self): def flavor(self): return self.header.flavor + @property + def start(self): + return self.header.start + def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): self.errors += ('FOR loop cannot be empty.',) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index e6b23f3eb40..f5a2215452f 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -819,6 +819,14 @@ def flavor(self): separator = self.get_token(Token.FOR_SEPARATOR) return normalize_whitespace(separator.value) if separator else None + @property + def start(self): + if self.flavor == 'IN ENUMERATE': + value = self.get_value(Token.OPTION) + if value: + return value[len('start='):] + return None + def validate(self, ctx: 'ValidationContext'): if not self.variables: self._add_error('no loop variables') diff --git a/src/robot/result/model.py b/src/robot/result/model.py index c3ce8da795c..ecb2b8726ec 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -172,9 +172,9 @@ class For(model.For, StatusMixin, DeprecatedAttributesMixin): iteration_class = ForIteration __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, variables=(), flavor='IN', values=(), status='FAIL', - starttime=None, endtime=None, doc='', parent=None): - super().__init__(variables, flavor, values, parent) + def __init__(self, variables=(), flavor='IN', values=(), start=None, + status='FAIL', starttime=None, endtime=None, doc='', parent=None): + super().__init__(variables, flavor, values, start, parent) self.status = status self.starttime = starttime self.endtime = endtime @@ -187,8 +187,11 @@ def body(self, iterations): @property @deprecated def name(self): - return '%s %s [ %s ]' % (' | '.join(self.variables), self.flavor, - ' | '.join(self.values)) + variables = ' | '.join(self.variables) + values = ' | '.join(self.values) + if self.start is not None: + values += f' | start={self.start}' + return f'{variables} {self.flavor} [ {values} ]' class WhileIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 5557428ea63..5e7a7ebb158 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -178,7 +178,8 @@ class ForHandler(ElementHandler): children = frozenset(('var', 'value', 'iter', 'status', 'doc', 'msg', 'kw')) def start(self, elem, result): - return result.body.create_for(flavor=elem.get('flavor')) + return result.body.create_for(flavor=elem.get('flavor'), + start=elem.get('start')) @ElementHandler.register @@ -187,10 +188,8 @@ class WhileHandler(ElementHandler): children = frozenset(('iter', 'status', 'doc', 'msg', 'kw')) def start(self, elem, result): - return result.body.create_while( - condition=elem.get('condition'), - limit=elem.get('limit') - ) + return result.body.create_while(condition=elem.get('condition'), + limit=elem.get('limit')) @ElementHandler.register diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 164a6d61505..1870794d659 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -101,7 +101,7 @@ def run(self, data): error = DataError(data.error, syntax=True) else: run = True - result = ForResult(data.variables, data.flavor, data.values) + result = ForResult(data.variables, data.flavor, data.values, data.start) with StatusReporter(data, result, self._context, run) as status: if run: try: @@ -261,7 +261,6 @@ def _to_number_with_arithmetic(self, item): class ForInZipRunner(ForInRunner): flavor = 'IN ZIP' - _start = 0 def _resolve_dict_values(self, values): raise DataError('FOR IN ZIP loops do not support iterating over dictionaries.', @@ -279,35 +278,23 @@ def _map_values_to_rounds(self, values, per_round): class ForInEnumerateRunner(ForInRunner): flavor = 'IN ENUMERATE' + _start = 0 - def _is_dict_iteration(self, values): - if values and values[-1].startswith('start='): - values = values[:-1] - return super()._is_dict_iteration(values) - - def _resolve_dict_values(self, values): - self._start, values = self._get_start(values) - return ForInRunner._resolve_dict_values(self, values) + def _get_values_for_rounds(self, data): + self._start = self._resolve_start(data.start) + return super()._get_values_for_rounds(data) - def _resolve_values(self, values): - self._start, values = self._get_start(values) - return ForInRunner._resolve_values(self, values) - - def _get_start(self, values): - if not values[-1].startswith('start='): - return 0, values - *values, start = values - if not values: - raise DataError('FOR loop has no loop values.', syntax=True) + def _resolve_start(self, start): + if not start or self._context.dry_run: + return 0 try: - start = self._context.variables.replace_string(start[6:]) + start = self._context.variables.replace_string(start) try: - start = int(start) + return int(start) except ValueError: raise DataError(f"Start value must be an integer, got '{start}'.") except DataError as err: raise DataError(f'Invalid start value: {err}') - return start, values def _map_dict_values_to_rounds(self, values, per_round): if per_round > 3: @@ -320,7 +307,7 @@ def _map_dict_values_to_rounds(self, values, per_round): def _map_values_to_rounds(self, values, per_round): per_round = max(per_round-1, 1) - values = ForInRunner._map_values_to_rounds(self, values, per_round) + values = super()._map_values_to_rounds(values, per_round) return ([i] + v for i, v in enumerate(values, start=self._start)) def _raise_wrong_variable_count(self, variables, values): diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 5d76cdbc3a4..abad5646e8f 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -348,7 +348,8 @@ def __init__(self, parent): def build(self, node): error = format_error(self._get_errors(node)) self.model = self.parent.body.create_for( - node.variables, node.flavor, node.values, lineno=node.lineno, error=error + node.variables, node.flavor, node.values, node.start, + lineno=node.lineno, error=error ) for step in node.body: self.visit(step) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 426c7d7f3c7..6b41ac49018 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -94,9 +94,9 @@ class For(model.For): __slots__ = ['lineno', 'error'] body_class = Body - def __init__(self, variables=(), flavor='IN', values=(), parent=None, + def __init__(self, variables=(), flavor='IN', values=(), start=None, parent=None, lineno=None, error=None): - super().__init__(variables, flavor, values, parent) + super().__init__(variables, flavor, values, start, parent) self.lineno = lineno self.error = error diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index c33b9fb176b..9bfc9819b8d 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -242,11 +242,37 @@ def test_valid(self): ) get_and_assert_model(data, expected) + def test_enumerate_with_start(self): + data = ''' +*** Test Cases *** +Example + FOR ${x} IN ENUMERATE @{stuff} start=1 + Log ${x} + END +''' + expected = For( + header=ForHeader([ + Token(Token.FOR, 'FOR', 3, 4), + Token(Token.VARIABLE, '${x}', 3, 11), + Token(Token.FOR_SEPARATOR, 'IN ENUMERATE', 3, 19), + Token(Token.ARGUMENT, '@{stuff}', 3, 35), + Token(Token.OPTION, 'start=1', 3, 47), + ]), + body=[ + KeywordCall([Token(Token.KEYWORD, 'Log', 4, 8), + Token(Token.ARGUMENT, '${x}', 4, 15)]) + ], + end=End([ + Token(Token.END, 'END', 5, 4) + ]) + ) + get_and_assert_model(data, expected) + def test_nested(self): data = ''' *** Test Cases *** Example - FOR ${x} IN 1 2 + FOR ${x} IN 1 start=has no special meaning here FOR ${y} IN RANGE ${x} Log ${y} END @@ -258,7 +284,7 @@ def test_nested(self): Token(Token.VARIABLE, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 19), Token(Token.ARGUMENT, '1', 3, 25), - Token(Token.ARGUMENT, '2', 3, 30), + Token(Token.ARGUMENT, 'start=has no special meaning here', 3, 30), ]), body=[ For( diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index 03b45a52bf1..fcff6bce93b 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -183,6 +183,15 @@ def test_if(self): ) self._verify_test(test, body=(exp_if, exp_else_if, exp_else)) + def test_for(self): + test = TestSuite().tests.create() + test.body.create_for(variables=['${x}'], values=['a', 'b']) + test.body.create_for(['${x}'], 'IN ENUMERATE', ['a', 'b'], start='1') + end = ('', '', '', '', '', '', (0, None, 0), ()) + exp_f1 = (3, '${x} IN [ a | b ]', *end) + exp_f2 = (3, '${x} IN ENUMERATE [ a | b | start=1 ]', *end) + self._verify_test(test, body=(exp_f1, exp_f2)) + def test_message_directly_under_test(self): test = TestSuite().tests.create() test.body.create_message('Hi from test') diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 7cdf5ee5b98..e7ec2c2026a 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -261,6 +261,9 @@ def test_for(self): self._verify(For(['${i}'], 'IN RANGE', ['10'], lineno=2), type='FOR', variables=['${i}'], flavor='IN RANGE', values=['10'], body=[], lineno=2) + self._verify(For(['${i}', '${a}'], 'IN ENUMERATE', ['cat', 'dog'], start='1'), + type='FOR', variables=['${i}', '${a}'], flavor='IN ENUMERATE', + values=['cat', 'dog'], body=[], start='1') def test_while(self): self._verify(While(), type='WHILE', body=[]) From d56637d12b195414dc038884377bb80aa4708f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 13 Mar 2023 17:15:53 +0200 Subject: [PATCH 0420/1592] Add Statement.get_option() helper. Makes it easier to get parsed options from statements. It's especially useful if a statement can have multiple options like FOR IN ZIP can soonish (#4682) have `mode` and `fill`. --- src/robot/parsing/model/statements.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index f5a2215452f..9b1d4b01ac5 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -126,6 +126,10 @@ def get_values(self, *types): """Return values of tokens having any of the given ``types``.""" return tuple(t.value for t in self.tokens if t.type in types) + def get_option(self, name): + options = dict(opt.split('=', 1) for opt in self.get_values(Token.OPTION)) + return options.get(name) + @property def lines(self): line = [] @@ -821,11 +825,7 @@ def flavor(self): @property def start(self): - if self.flavor == 'IN ENUMERATE': - value = self.get_value(Token.OPTION) - if value: - return value[len('start='):] - return None + return self.get_option('start') if self.flavor == 'IN ENUMERATE' else None def validate(self, ctx: 'ValidationContext'): if not self.variables: @@ -969,8 +969,7 @@ def patterns(self): @property def pattern_type(self): - value = self.get_value(Token.OPTION) - return value[len('type='):] if value else None + return self.get_option('type') @property def variable(self): @@ -1021,8 +1020,7 @@ def condition(self): @property def limit(self): - value = self.get_value(Token.OPTION) - return value[len('limit='):] if value else None + return self.get_option('limit') def validate(self, ctx: 'ValidationContext'): values = self.get_values(Token.ARGUMENT) From 0a353488c9a37084fa747aa2058b9ba34286e2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 14 Mar 2023 01:27:46 +0200 Subject: [PATCH 0421/1592] Configurable FOR IN ZIP behavior if lengths differ. Three modes configured using `mode` option: - SHORTEST: Items in logger lists ignored. Same as using `zip(...)` in Python. Current default. - STRICT: Lengths must match. Same as `zip(..., strict=True)`. Future default. - LONGEST: Fill values in shorter lists with `fill` value or `None`. Same as `itertools.zip_longest(..., fillvalue=fill)` Fixes #4682. --- atest/robot/running/for/for.resource | 13 ++- atest/robot/running/for/for_in_zip.robot | 46 +++++++++- atest/testdata/running/for/for_in_zip.robot | 91 +++++++++++++++++-- doc/schema/robot.xsd | 4 +- .../CreatingTestData/ControlStructures.rst | 71 ++++++++++++--- src/robot/model/control.py | 15 ++- src/robot/output/xmllogger.py | 7 +- src/robot/parsing/lexer/statementlexers.py | 5 + src/robot/parsing/model/blocks.py | 8 ++ src/robot/parsing/model/statements.py | 8 ++ src/robot/result/model.py | 14 ++- src/robot/result/xmlelementhandlers.py | 4 +- src/robot/running/bodyrunner.py | 65 +++++++++++-- src/robot/running/builder/transformers.py | 2 +- src/robot/running/model.py | 6 +- 15 files changed, 304 insertions(+), 55 deletions(-) diff --git a/atest/robot/running/for/for.resource b/atest/robot/running/for/for.resource index f4b36ae9875..40fdfb30c12 100644 --- a/atest/robot/running/for/for.resource +++ b/atest/robot/running/for/for.resource @@ -16,24 +16,27 @@ Check test and failed loop Run Keyword Should Be ${type} loop ${loop} 1 FAIL &{config} Should be FOR loop - [Arguments] ${loop} ${iterations} ${status}=PASS ${flavor}=IN ${start}=${None} + [Arguments] ${loop} ${iterations} ${status}=PASS ${flavor}=IN + ... ${start}=${None} ${mode}=${None} ${fill}=${None} Should Be Equal ${loop.type} FOR Should Be Equal ${loop.flavor} ${flavor} Should Be Equal ${loop.start} ${start} + Should Be Equal ${loop.mode} ${mode} + Should Be Equal ${loop.fill} ${fill} Length Should Be ${loop.body.filter(messages=False)} ${iterations} Should Be Equal ${loop.status} ${status} Should be IN RANGE loop [Arguments] ${loop} ${iterations} ${status}=PASS - Should Be FOR Loop ${loop} ${iterations} ${status} flavor=IN RANGE + Should Be FOR Loop ${loop} ${iterations} ${status} IN RANGE Should be IN ZIP loop - [Arguments] ${loop} ${iterations} ${status}=PASS - Should Be FOR Loop ${loop} ${iterations} ${status} flavor=IN ZIP + [Arguments] ${loop} ${iterations} ${status}=PASS ${mode}=${None} ${fill}=${None} + Should Be FOR Loop ${loop} ${iterations} ${status} IN ZIP mode=${mode} fill=${fill} Should be IN ENUMERATE loop [Arguments] ${loop} ${iterations} ${status}=PASS ${start}=${None} - Should Be FOR Loop ${loop} ${iterations} ${status} IN ENUMERATE ${start} + Should Be FOR Loop ${loop} ${iterations} ${status} IN ENUMERATE start=${start} Should be FOR iteration [Arguments] ${iteration} &{variables} diff --git a/atest/robot/running/for/for_in_zip.robot b/atest/robot/running/for/for_in_zip.robot index b512c9a9a21..36d3dc61e0f 100644 --- a/atest/robot/running/for/for_in_zip.robot +++ b/atest/robot/running/for/for_in_zip.robot @@ -48,9 +48,9 @@ One variable and two lists One variable and six lists ${loop} = Check test and get loop ${TEST NAME} Should be IN ZIP loop ${loop} 3 - Should be FOR iteration ${loop.body[0]} \${x}=('a', 'x', '1', '1', 'x', 'a') - Should be FOR iteration ${loop.body[1]} \${x}=('b', 'y', '2', '2', 'y', 'b') - Should be FOR iteration ${loop.body[2]} \${x}=('c', 'z', '3', '3', 'z', 'c') + Should be FOR iteration ${loop.body[0]} \${x}=('a', 'x', 1, 1, 'x', 'a') + Should be FOR iteration ${loop.body[1]} \${x}=('b', 'y', 2, 2, 'y', 'b') + Should be FOR iteration ${loop.body[2]} \${x}=('c', 'z', 3, 3, 'z', 'c') Other iterables Check Test Case ${TEST NAME} @@ -70,6 +70,46 @@ List variable with iterables can be empty Should be FOR iteration ${tc.body[1].body[0]} \${x}= \${y}= \${z}= Check Log Message ${tc.body[2].msgs[0]} Executed! +Strict mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 3 PASS mode=STRICT + Should be IN ZIP loop ${tc.body[2]} 1 FAIL mode=strict + +Strict mode requires items to have length + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=STRICT + +Shortest mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 3 PASS mode=SHORTEST fill=ignored + Should be IN ZIP loop ${tc.body[3]} 3 PASS mode=\${{'shortest'}} + +Shortest mode supports infinite iterators + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 5 PASS mode=SHORTEST + +Longest mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 3 PASS mode=LONGEST + Should be IN ZIP loop ${tc.body[3]} 5 PASS mode=LoNgEsT + +Longest mode with custom fill value + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 5 PASS mode=longest fill=? + Should be IN ZIP loop ${tc.body[3]} 5 PASS mode=longest fill=\${0} + +Invalid mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=bad + +Non-existing variable in mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=\${bad} fill=\${ignored} + +Non-existing variable in fill value + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=longest fill=\${bad} + Not iterable value Check test and failed loop ${TEST NAME} IN ZIP diff --git a/atest/testdata/running/for/for_in_zip.robot b/atest/testdata/running/for/for_in_zip.robot index 5be74c3e2ef..8174503313b 100644 --- a/atest/testdata/running/for/for_in_zip.robot +++ b/atest/testdata/running/for/for_in_zip.robot @@ -2,7 +2,7 @@ @{result} @{LIST1} a b c @{LIST2} x y z -@{LIST3} 1 2 3 4 5 +@{LIST3} ${1} ${2} ${3} ${4} ${5} *** Test Cases *** Two variables and lists @@ -12,8 +12,8 @@ Two variables and lists Should Be True ${result} == ['a:x', 'b:y', 'c:z'] Uneven lists - [Documentation] This will ignore any elements after the shortest - ... list ends, just like with Python's zip(). + [Documentation] Items in longer lists are ignored. + ... This behavior can be configured using `mode` option. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} @{result} = Create List @{result} ${x}:${y} END @@ -46,9 +46,9 @@ One variable and two lists Should Be True ${result} == ['a:x', 'b:y', 'c:z'] One variable and six lists - FOR ${x} IN ZIP - ... ${LIST1} ${LIST2} ${LIST3} ${LIST3} ${LIST2} ${LIST1} - @{result} = Create List @{result} ${{':'.join($x)}} + FOR ${x} IN ZIP ${LIST1} ${LIST2} ${LIST3} + ... ${LIST3} ${LIST2} ${LIST1} + @{result} = Create List @{result} ${{':'.join(str(i) for i in $x)}} END Should Be True ${result} == ['a:x:1:1:x:a', 'b:y:2:2:y:b', 'c:z:3:3:z:c'] @@ -80,15 +80,88 @@ List variable with iterables can be empty END Log Executed! +Strict mode + [Documentation] FAIL FOR IN ZIP items should have equal lengths in STRICT mode, but lengths are 3, 3 and 5. + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=STRICT + @{result} = Create List @{result} ${x}:${y} + END + Should Be True ${result} == ['a:x', 'b:y', 'c:z'] + FOR ${x} ${y} ${z} IN ZIP ${LIST1} ${LIST2} ${LIST 3} mode=strict + Fail Not executed + END + +Strict mode requires items to have length + [Documentation] FAIL FOR IN ZIP items should have length in STRICT mode, but item 2 does not. + FOR ${x} ${y} IN ZIP ${LIST3} ${{itertools.cycle(['A', 'B'])}} mode=STRICT + Fail Not executed + END + +Shortest mode + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=SHORTEST fill=ignored + @{result} = Create List @{result} ${x}:${y} + END + Should Be True ${result} == ['a:x', 'b:y', 'c:z'] + @{result} = Create List + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=ignored mode=${{'shortest'}} + @{result} = Create List @{result} ${x}:${y} + END + Should Be True ${result} == ['a:1', 'b:2', 'c:3'] + +Shortest mode supports infinite iterators + FOR ${x} ${y} IN ZIP ${LIST3} ${{itertools.cycle(['A', 'B'])}} mode=SHORTEST + @{result} = Create List @{result} ${x}:${y} + END + Should Be True ${result} == ['1:A', '2:B', '3:A', '4:B', '5:A'] + +Longest mode + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=LONGEST + @{result} = Create List @{result} ${x}:${y} + END + Should Be True ${result} == ['a:x', 'b:y', 'c:z'] + @{result} = Create List + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=LoNgEsT + @{result} = Create List @{result} ${{($x, $y)}} + END + Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), (None, 4), (None, 5)] + +Longest mode with custom fill value + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=longest fill=? + @{result} = Create List @{result} ${{($x, $y)}} + END + Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), ('?', 4), ('?', 5)] + @{result} = Create List + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} fill=ignored fill=${0} mode=longest + @{result} = Create List @{result} ${{($x, $y)}} + END + Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), (0, 4), (0, 5)] + +Invalid mode + [Documentation] FAIL Invalid mode: Mode must be 'STRICT', 'SHORTEST' or 'LONGEST', got 'BAD'. + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=bad + @{result} = Create List @{result} ${x}:${y} + END + +Non-existing variable in mode + [Documentation] FAIL Invalid mode: Variable '\${bad}' not found. + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=${bad} fill=${ignored} + @{result} = Create List @{result} ${x}:${y} + END + +Non-existing variable in fill value + [Documentation] FAIL Invalid fill value: Variable '\${bad}' not found. + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=longest fill=${bad} + @{result} = Create List @{result} ${x}:${y} + END + Not iterable value - [Documentation] FAIL FOR IN ZIP items must all be list-like, got integer '42'. + [Documentation] FAIL FOR IN ZIP items must be list-like, but item 2 is integer. FOR ${x} ${y} IN ZIP ${LIST1} ${42} Fail This test case should die before running this. END Strings are not considered iterables - [Documentation] FAIL FOR IN ZIP items must all be list-like, got string 'not list'. - FOR ${x} ${y} IN ZIP ${LIST1} not list + [Documentation] FAIL FOR IN ZIP items must be list-like, but item 3 is string. + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} not list Fail This test case should die before running this. END diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index a3db0b83b7b..31b8b323474 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -124,7 +124,9 @@ <xs:element name="status" type="BodyItemStatus" /> </xs:choice> <xs:attribute name="flavor" type="ForFlavor" /> - <xs:attribute name="start" type="xs:string" /> <!-- Used if IN ENUMERATE uses `start=`. --> + <xs:attribute name="start" type="xs:string" /> <!-- Used if IN ENUMERATE has `start`. --> + <xs:attribute name="mode" type="xs:string" /> <!-- Used if IN ZIP has `mode`. --> + <xs:attribute name="fill" type="xs:string" /> <!-- Used if IN ZIP has `fill`. --> </xs:complexType> <xs:simpleType name="ForFlavor"> <xs:restriction base="xs:string"> diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index b78cc6a1738..021c153ecd1 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -358,10 +358,9 @@ This may be easiest to show with an example: As the example above illustrates, `FOR-IN-ZIP` loops require their own custom separator `IN ZIP` (case-sensitive) between loop variables and values. -Values used with `FOR-IN-ZIP` loops must be lists or list-like objects. Looping -will stop when the shortest list is exhausted. +Values used with `FOR-IN-ZIP` loops must be lists or list-like objects. -Lists to iterate over must always be given either as `scalar variables`_ like +Items to iterate over must always be given either as `scalar variables`_ like `${items}` or as `list variables`_ like `@{lists}` that yield the actual iterated lists. The former approach is more common and it was already demonstrated above. The latter approach works like this: @@ -380,7 +379,7 @@ demonstrated above. The latter approach works like this: END The number of lists to iterate over is not limited, but it must match -the number of loop variables. Alternatively there can be just one loop +the number of loop variables. Alternatively, there can be just one loop variable that then becomes a Python tuple getting items from all lists. .. sourcecode:: robotframework @@ -388,7 +387,7 @@ variable that then becomes a Python tuple getting items from all lists. *** Variables *** @{ABC} a b c @{XYZ} x y z - @{NUM} 1 2 3 4 5 + @{NUM} 1 2 3 *** Test Cases *** FOR-IN-ZIP with multiple lists @@ -402,13 +401,63 @@ variable that then becomes a Python tuple getting items from all lists. Log Many ${items}[0] ${items}[1] ${items}[2] END -If lists have an unequal number of items, the shortest list defines how -many iterations there are and values at the end of longer lists are ignored. -For example, the above examples loop only three times and values `4` and `5` -in the `${NUM}` list are ignored. +Starting from Robot Framework 6.1, it is possible to configure what to do if +lengths of the iterated items differ. By default, the shortest item defines how +many iterations there are and values at the end of longer ones are ignored. +This can be changed by using the `mode` option that has three possible values: -.. note:: Getting lists to iterate over from list variables and using - just one loop variable are new features in Robot Framework 3.2. +- `STRICT`: Items must have equal lengths. If not, execution fails. This is + the same as using `strict=True` with Python's zip__ function. +- `SHORTEST`: Items in longer items are ignored. Infinite iterators are supported + in this mode as long as one of the items is exhausted. This is the default + behavior. +- `LONGEST`: The longest item defines how many iterations there are. Missing + values in shorter items are filled-in with value specified using the `fill` + option or `None` if it is not used. This is the same as using Python's + zip_longest__ function except that it has `fillvalue` argument instead of + `fill`. + +All these modes are illustrated by the following examples: + +.. sourcecode:: robotframework + + *** Variables *** + @{CHARACTERS} a b c d f + @{NUMBERS} 1 2 3 + + *** Test Cases *** + STRICT mode + [Documentation] This loop fails due to lists lengths being different. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=STRICT + Log ${c}: ${n} + END + + SHORTEST mode + [Documentation] This loop executes three times. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=SHORTEST + Log ${c}: ${n} + END + + LONGEST mode + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `None`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST + Log ${c}: ${n} + END + + LONGEST mode with custom fill value + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `0`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST fill=0 + Log ${c}: ${n} + END + +.. note:: The behavior if list lengths differ will change in the future + so that the `STRICT` mode will be the default. If that is not desired, + the `SHORTEST` mode needs to be used explicitly. + +__ https://docs.python.org/library/functions.html#zip +__ https://docs.python.org/library/itertools.html#itertools.zip_longest Dictionary iteration ~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 4869cbd3828..c86c2d431c5 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -24,14 +24,16 @@ class For(BodyItem): type = BodyItem.FOR body_class = Body repr_args = ('variables', 'flavor', 'values') - __slots__ = ['variables', 'flavor', 'values', 'start'] + __slots__ = ['variables', 'flavor', 'values', 'start', 'mode', 'fill'] - def __init__(self, variables=(), flavor='IN', values=(), start=None, - parent=None): + def __init__(self, variables=(), flavor='IN', values=(), start=None, mode=None, + fill=None, parent=None): self.variables = variables self.flavor = flavor self.values = values self.start = start + self.mode = mode + self.fill = fill self.parent = parent self.body = None @@ -62,8 +64,11 @@ def to_dict(self): 'flavor': self.flavor, 'values': list(self.values), 'body': self.body.to_dicts()} - if self.start is not None: - data['start'] = self.start + for name, value in [('start', self.start), + ('mode', self.mode), + ('fill', self.fill)]: + if value is not None: + data[name] = value return data diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 9b5dbef6ba2..7fec7b176a2 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -101,8 +101,11 @@ def end_if_branch(self, branch): def start_for(self, for_): attrs = {'flavor': for_.flavor} - if for_.start is not None: - attrs['start'] = for_.start + for name, value in [('start', for_.start), + ('mode', for_.mode), + ('fill', for_.fill)]: + if value is not None: + attrs[name] = value self._writer.start('for', attrs) for name in for_.variables: self._writer.element('var', name) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index c06bba37390..c89519a21cd 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -202,6 +202,11 @@ def lex(self): if (separator == 'IN ENUMERATE' and self.statement[-1].value.startswith('start=')): self.statement[-1].type = Token.OPTION + elif separator == 'IN ZIP': + for token in reversed(self.statement): + if not token.value.startswith(('mode=', 'fill=')): + break + token.type = Token.OPTION class IfHeaderLexer(TypeAndArguments): diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index f122574b3c7..649f2646348 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -242,6 +242,14 @@ def flavor(self): def start(self): return self.header.start + @property + def mode(self): + return self.header.mode + + @property + def fill(self): + return self.header.fill + def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): self.errors += ('FOR loop cannot be empty.',) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 9b1d4b01ac5..7ba8d34e2bc 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -827,6 +827,14 @@ def flavor(self): def start(self): return self.get_option('start') if self.flavor == 'IN ENUMERATE' else None + @property + def mode(self): + return self.get_option('mode') if self.flavor == 'IN ZIP' else None + + @property + def fill(self): + return self.get_option('fill') if self.flavor == 'IN ZIP' else None + def validate(self, ctx: 'ValidationContext'): if not self.variables: self._add_error('no loop variables') diff --git a/src/robot/result/model.py b/src/robot/result/model.py index ecb2b8726ec..0319be25c0b 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -172,9 +172,10 @@ class For(model.For, StatusMixin, DeprecatedAttributesMixin): iteration_class = ForIteration __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, variables=(), flavor='IN', values=(), start=None, - status='FAIL', starttime=None, endtime=None, doc='', parent=None): - super().__init__(variables, flavor, values, start, parent) + def __init__(self, variables=(), flavor='IN', values=(), start=None, mode=None, + fill=None, status='FAIL', starttime=None, endtime=None, doc='', + parent=None): + super().__init__(variables, flavor, values, start, mode, fill, parent) self.status = status self.starttime = starttime self.endtime = endtime @@ -189,8 +190,11 @@ def body(self, iterations): def name(self): variables = ' | '.join(self.variables) values = ' | '.join(self.values) - if self.start is not None: - values += f' | start={self.start}' + for name, value in [('start', self.start), + ('mode', self.mode), + ('fill', self.fill)]: + if value is not None: + values += f' | {name}={value}' return f'{variables} {self.flavor} [ {values} ]' diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 5e7a7ebb158..1d546c0a2ce 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -179,7 +179,9 @@ class ForHandler(ElementHandler): def start(self, elem, result): return result.body.create_for(flavor=elem.get('flavor'), - start=elem.get('start')) + start=elem.get('start'), + mode=elem.get('mode'), + fill=elem.get('fill')) @ElementHandler.register diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 1870794d659..02bdfd55ec2 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -15,6 +15,7 @@ from collections import OrderedDict from contextlib import contextmanager +from itertools import zip_longest import re import time @@ -25,9 +26,8 @@ TryBranch as TryBranchResult) from robot.output import librarylogger as logger from robot.utils import (cut_assign_value, frange, get_error_message, get_timestamp, - is_string, is_list_like, is_number, plural_or_not as s, - seq2str, split_from_equals, type_name, Matcher, - timestr_to_secs) + is_list_like, is_number, plural_or_not as s, seq2str, + split_from_equals, type_name, Matcher, timestr_to_secs) from robot.variables import is_dict_variable, evaluate_expression from .statusreporter import StatusReporter @@ -101,7 +101,8 @@ def run(self, data): error = DataError(data.error, syntax=True) else: run = True - result = ForResult(data.variables, data.flavor, data.values, data.start) + result = ForResult(data.variables, data.flavor, data.values, data.start, + data.mode, data.fill) with StatusReporter(data, result, self._context, run) as status: if run: try: @@ -261,19 +262,65 @@ def _to_number_with_arithmetic(self, item): class ForInZipRunner(ForInRunner): flavor = 'IN ZIP' + _mode = None + _fill = None + + def _get_values_for_rounds(self, data): + self._mode = self._resolve_mode(data.mode) + self._fill = self._resolve_fill(data.fill) + return super()._get_values_for_rounds(data) + + def _resolve_mode(self, mode): + if not mode or self._context.dry_run: + return None + try: + mode = self._context.variables.replace_string(mode).upper() + if mode in ('STRICT', 'SHORTEST', 'LONGEST'): + return mode + raise DataError(f"Mode must be 'STRICT', 'SHORTEST' or 'LONGEST', " + f"got '{mode}'.") + except DataError as err: + raise DataError(f'Invalid mode: {err}') + + def _resolve_fill(self, fill): + if not fill or self._context.dry_run: + return None + try: + return self._context.variables.replace_scalar(fill) + except DataError as err: + raise DataError(f'Invalid fill value: {err}') def _resolve_dict_values(self, values): raise DataError('FOR IN ZIP loops do not support iterating over dictionaries.', syntax=True) def _map_values_to_rounds(self, values, per_round): - for item in values: - if not is_list_like(item): - raise DataError(f"FOR IN ZIP items must all be list-like, " - f"got {type_name(item)} '{item}'.") + self._validate_types(values) if len(values) % per_round != 0: self._raise_wrong_variable_count(per_round, len(values)) - return zip(*(list(item) for item in values)) + if self._mode == 'LONGEST': + return zip_longest(*values, fillvalue=self._fill) + if self._mode == 'STRICT': + self._validate_strict_lengths(values) + return zip(*values) + + def _validate_types(self, values): + for index, item in enumerate(values, start=1): + if not is_list_like(item): + raise DataError(f"FOR IN ZIP items must be list-like, but item {index} " + f"is {type_name(item)}.") + + def _validate_strict_lengths(self, values): + lengths = [] + for index, item in enumerate(values, start=1): + try: + lengths.append(len(item)) + except TypeError: + raise DataError(f"FOR IN ZIP items should have length in STRICT mode, " + f"but item {index} does not.") + if len(set(lengths)) > 1: + raise DataError(f"FOR IN ZIP items should have equal lengths in STRICT " + f"mode, but lengths are {seq2str(lengths, quote='')}.") class ForInEnumerateRunner(ForInRunner): diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index abad5646e8f..a264e540656 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -348,7 +348,7 @@ def __init__(self, parent): def build(self, node): error = format_error(self._get_errors(node)) self.model = self.parent.body.create_for( - node.variables, node.flavor, node.values, node.start, + node.variables, node.flavor, node.values, node.start, node.mode, node.fill, lineno=node.lineno, error=error ) for step in node.body: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 6b41ac49018..a15a1138aee 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -94,9 +94,9 @@ class For(model.For): __slots__ = ['lineno', 'error'] body_class = Body - def __init__(self, variables=(), flavor='IN', values=(), start=None, parent=None, - lineno=None, error=None): - super().__init__(variables, flavor, values, start, parent) + def __init__(self, variables=(), flavor='IN', values=(), start=None, mode=None, + fill=None, parent=None, lineno=None, error=None): + super().__init__(variables, flavor, values, start, mode, fill, parent) self.lineno = lineno self.error = error From d0d2d77c693c5c4be693367f2d40863e59449fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 14 Mar 2023 01:32:42 +0200 Subject: [PATCH 0422/1592] Take new FOR IN ZIP modes (#4682) into use in tests. Also prepare for STRICT mode being default. --- atest/resources/TestCheckerLibrary.py | 3 ++- .../type_conversion/custom_converters.robot | 2 +- atest/robot/output/flatten_keyword.robot | 6 ++---- atest/robot/running/if/invalid_if.robot | 3 +-- .../dynamic_library_args_and_docs.robot | 13 ++++++++++++- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index bb1b1370a04..fbe307d96e8 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -325,7 +325,8 @@ def check_log_message(self, item, expected, level='INFO', html=False, pattern=Fa b = BuiltIn() matcher = b.should_match if pattern else b.should_be_equal matcher(message, expected.rstrip(), 'Wrong log message') - b.should_be_equal(item.level, 'INFO' if level == 'HTML' else level, 'Wrong log level') + if level != 'IGNORE': + b.should_be_equal(item.level, 'INFO' if level == 'HTML' else level, 'Wrong log level') b.should_be_equal(str(item.html), str(html or level == 'HTML'), 'Wrong HTML status') diff --git a/atest/robot/keywords/type_conversion/custom_converters.robot b/atest/robot/keywords/type_conversion/custom_converters.robot index 6873223942f..d675ed6761f 100644 --- a/atest/robot/keywords/type_conversion/custom_converters.robot +++ b/atest/robot/keywords/type_conversion/custom_converters.robot @@ -81,6 +81,6 @@ Invalid converter dictionary *** Keywords *** Validate Errors [Arguments] @{messages} - FOR ${err} ${msg} IN ZIP ${ERRORS} ${messages} + FOR ${err} ${msg} IN ZIP ${ERRORS} ${messages} mode=SHORTEST Check Log Message ${err} Error in library 'CustomConverters': ${msg} ERROR END diff --git a/atest/robot/output/flatten_keyword.robot b/atest/robot/output/flatten_keyword.robot index 08b41ff7ef5..89f27b8df7e 100644 --- a/atest/robot/output/flatten_keyword.robot +++ b/atest/robot/output/flatten_keyword.robot @@ -75,12 +75,10 @@ Flatten controls in keyword ... 3 2 1 BANG! ... FOR: 0 1 FOR: 1 1 FOR: 2 1 ... WHILE: 2 1 \${i} = 1 WHILE: 1 1 \${i} = 0 + ... AssertionError 1 finally FOR ${msg} ${exp} IN ZIP ${tc.body[0].body} ${expected} - Check Log Message ${msg} ${exp} + Check Log Message ${msg} ${exp} level=IGNORE END - Check log message ${tc.body[0].body[20]} AssertionError level=FAIL - Check log message ${tc.body[0].body[21]} 1 - Check log message ${tc.body[0].body[22]} finally Flatten for loops Run Rebot --flatten For ${OUTFILE COPY} diff --git a/atest/robot/running/if/invalid_if.robot b/atest/robot/running/if/invalid_if.robot index b28d58bbc25..4f52de27720 100644 --- a/atest/robot/running/if/invalid_if.robot +++ b/atest/robot/running/if/invalid_if.robot @@ -116,8 +116,7 @@ Branch statuses should be ${tc} = Check Test Case ${TESTNAME} ${if} = Set Variable ${tc.body}[${index}] Should Be Equal ${if.status} FAIL - FOR ${branch} ${status} IN ZIP ${if.body} ${statuses} + FOR ${branch} ${status} IN ZIP ${if.body} ${statuses} mode=STRICT Should Be Equal ${branch.status} ${status} END - Should Be Equal ${{len($if.body)}} ${{len($statuses)}} RETURN ${tc} diff --git a/atest/robot/test_libraries/dynamic_library_args_and_docs.robot b/atest/robot/test_libraries/dynamic_library_args_and_docs.robot index 9e1d2c56874..dcd76380e30 100644 --- a/atest/robot/test_libraries/dynamic_library_args_and_docs.robot +++ b/atest/robot/test_libraries/dynamic_library_args_and_docs.robot @@ -6,14 +6,19 @@ Resource atest_resource.robot *** Test Cases *** Documentation And Argument Boundaries Work With No Args Keyword documentation for No Arg + ... Executed keyword "No Arg" with arguments (). + ... Keyword 'classes.ArgDocDynamicLibrary.No Arg' expected 0 arguments, got 1. Documentation And Argument Boundaries Work With Mandatory Args Keyword documentation for One Arg + ... Executed keyword "One Arg" with arguments ('arg',). + ... Keyword 'classes.ArgDocDynamicLibrary.One Arg' expected 1 argument, got 0. Documentation And Argument Boundaries Work With Default Args Keyword documentation for One or Two Args ... Executed keyword "One or Two Args" with arguments ('1',). ... Executed keyword "One or Two Args" with arguments ('1', '2'). + ... Keyword 'classes.ArgDocDynamicLibrary.One Or Two Args' expected 1 to 2 arguments, got 3. Default value as tuple Keyword documentation for Default as tuple @@ -22,15 +27,21 @@ Default value as tuple ... Executed keyword "Default as tuple" with arguments ('1', '2', '3'). ... Executed keyword "Default as tuple" with arguments ('1', False, '3'). ... Executed keyword "Default as tuple" with arguments ('1', False, '3'). + ... Keyword 'classes.ArgDocDynamicLibrary.Default As Tuple' expected 1 to 3 arguments, got 4. Documentation And Argument Boundaries Work With Varargs Keyword documentation for Many Args + ... Executed keyword "Many Args" with arguments (). + ... Executed keyword "Many Args" with arguments ('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13'). Documentation and Argument Boundaries Work When Argspec is None Keyword documentation for No Arg Spec + ... Executed keyword "No Arg Spec" with arguments (). + ... Executed keyword "No Arg Spec" with arguments ('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13'). Multiline Documentation Multiline\nshort doc! + ... Executed keyword "Multiline" with arguments (). Keyword Not Created And Warning Shown When Getting Documentation Fails [Template] Check Creating Keyword Failed Due To Invalid Doc Message @@ -62,7 +73,7 @@ Check test case and its doc ${tc} = Check Test case ${TESTNAME} Should Be Equal ${tc.kws[0].doc} ${expected doc} FOR ${kw} ${msg} IN ZIP ${tc.kws} ${msgs} - Check Log Message ${kw.msgs[0]} ${msg} + Check Log Message ${kw.msgs[0]} ${msg} level=IGNORE END Check Creating Keyword Failed Due To Invalid Doc Message From d8892afacf6c3c3dfc9e95f0cc800913f5af1bd1 Mon Sep 17 00:00:00 2001 From: sunday2 <largebear229@gmail.com> Date: Tue, 14 Mar 2023 08:28:21 +0800 Subject: [PATCH 0423/1592] fix: encoding issue when generate the user guide (#4681) Closes #4680 --- doc/userguide/translations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/userguide/translations.py b/doc/userguide/translations.py index d081cbe1aff..bfcfdd6d05c 100644 --- a/doc/userguide/translations.py +++ b/doc/userguide/translations.py @@ -204,7 +204,7 @@ def list_translations(languages): def update(path: Path, content): source = path.read_text(encoding='UTF-8').splitlines() - with open(path, 'w') as file: + with open(path, 'w', encoding="utf-8") as file: write(source, file, end_marker='.. START GENERATED CONTENT') file.write('.. Generated by translations.py used by ug2html.py.\n') write(content, file) From 2b54a563f6f47959f529c5dc8a2b39195c503eb8 Mon Sep 17 00:00:00 2001 From: turunenm <101569494+turunenm@users.noreply.github.com> Date: Tue, 14 Mar 2023 10:02:02 +0200 Subject: [PATCH 0424/1592] Implement CONSOLE pseudo log level Fixes #4536. --- .../robot/standard_libraries/builtin/log.robot | 5 +++++ atest/robot/test_libraries/print_logging.robot | 12 +++++++++--- .../standard_libraries/builtin/log.robot | 3 +++ atest/testdata/test_libraries/PrintLib.py | 6 +++++- .../test_libraries/print_logging.robot | 3 +++ .../CreatingTestLibraries.rst | 12 ++++++++++-- src/robot/api/logger.py | 9 +++++++-- src/robot/libraries/BuiltIn.py | 16 ++++++++++------ src/robot/output/librarylogger.py | 18 +++++++----------- src/robot/output/loggerhelper.py | 15 ++++++++++++++- src/robot/output/stdoutlogsplitter.py | 8 +++++--- utest/api/test_logging_api.py | 3 +++ 12 files changed, 81 insertions(+), 29 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/log.robot b/atest/robot/standard_libraries/builtin/log.robot index 10ae859f4f1..7b4cb43b7db 100644 --- a/atest/robot/standard_libraries/builtin/log.robot +++ b/atest/robot/standard_libraries/builtin/log.robot @@ -54,6 +54,11 @@ Log also to console Stdout Should Contain Hello, console!\n Stdout Should Contain ${HTML}\n +CONSOLE pseudo level + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.kws[0].msgs[0]} Hello, info and console! + Stdout Should Contain Hello, info and console!\n + repr=True ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} The 'repr' argument of 'BuiltIn.Log' is deprecated. Use 'formatter=repr' instead. WARN diff --git a/atest/robot/test_libraries/print_logging.robot b/atest/robot/test_libraries/print_logging.robot index f5abb1f5ad0..345824dfe3f 100644 --- a/atest/robot/test_libraries/print_logging.robot +++ b/atest/robot/test_libraries/print_logging.robot @@ -19,9 +19,10 @@ Logging with levels Check Log Message ${tc.kws[0].msgs[1]} Trace message TRACE Check Log Message ${tc.kws[0].msgs[2]} Debug message DEBUG Check Log Message ${tc.kws[0].msgs[3]} Info message INFO - Check Log Message ${tc.kws[0].msgs[4]} Html message INFO html=True - Check Log Message ${tc.kws[0].msgs[5]} Warn message WARN - Check Log Message ${tc.kws[0].msgs[6]} Error message ERROR + Check Log Message ${tc.kws[0].msgs[4]} Console message INFO + Check Log Message ${tc.kws[0].msgs[5]} Html message INFO html=True + Check Log Message ${tc.kws[0].msgs[6]} Warn message WARN + Check Log Message ${tc.kws[0].msgs[7]} Error message ERROR Check Log Message ${ERRORS[0]} Warn message WARN Check Log Message ${ERRORS[1]} Error message ERROR @@ -64,6 +65,11 @@ Logging HTML Check Log Message ${tc.kws[2].msgs[0]} <i>Hello, stderr!!</i> HTML Stderr Should Contain *HTML* <i>Hello, stderr!!</i> +Logging CONSOLE + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.kws[0].msgs[0]} Hello info and console! + Stdout Should Contain Hello info and console! + FAIL is not valid log level ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} *FAIL* is not failure INFO diff --git a/atest/testdata/standard_libraries/builtin/log.robot b/atest/testdata/standard_libraries/builtin/log.robot index 4f1b92a6605..7a822ee55fb 100644 --- a/atest/testdata/standard_libraries/builtin/log.robot +++ b/atest/testdata/standard_libraries/builtin/log.robot @@ -51,6 +51,9 @@ Log also to console Log Hello, console! console=yepyep html=false Log ${HTML} debug enable both html and console +CONSOLE pseudo level + Log Hello, info and console! console + repr=True [Setup] Set Log Level DEBUG Log Nothing special here repr=false diff --git a/atest/testdata/test_libraries/PrintLib.py b/atest/testdata/test_libraries/PrintLib.py index 0ec385aed51..3b78a533570 100644 --- a/atest/testdata/test_libraries/PrintLib.py +++ b/atest/testdata/test_libraries/PrintLib.py @@ -16,6 +16,10 @@ def print_html_to_stderr(): print('*HTML* <i>Hello, stderr!!</i>', file=sys.stderr) +def print_console(): + print('*CONSOLE* Hello info and console!') + + def print_with_all_levels(): - for level in 'TRACE DEBUG INFO HTML WARN ERROR'.split(): + for level in 'TRACE DEBUG INFO CONSOLE HTML WARN ERROR'.split(): print('*%s* %s message' % (level, level.title())) diff --git a/atest/testdata/test_libraries/print_logging.robot b/atest/testdata/test_libraries/print_logging.robot index b495140ca2b..f7d7fc861d3 100644 --- a/atest/testdata/test_libraries/print_logging.robot +++ b/atest/testdata/test_libraries/print_logging.robot @@ -45,5 +45,8 @@ Logging HTML Print Many HTML Lines Print HTML To Stderr +Logging CONSOLE + Print Console + FAIL is not valid log level Print *FAIL* is not failure diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index ae4ec59ef17..94da0a4d091 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -2169,7 +2169,8 @@ messages, specify the log level explicitly by embedding the level into the message in the format `*LEVEL* Actual log message`, where `*LEVEL*` must be in the beginning of a line and `LEVEL` is one of the available logging levels `TRACE`, `DEBUG`, -`INFO`, `WARN`, `ERROR` and `HTML`. +`INFO`, `WARN`, `ERROR`, `HTML` and `CONSOLE`. Log level `CONSOLE` +is new in Robot Framework 6.1. Errors and warnings ''''''''''''''''''' @@ -2274,7 +2275,8 @@ In most cases, the `INFO` level is adequate. The levels below it, These messages are normally not shown, but they can facilitate debugging possible problems in the library itself. The `WARN` or `ERROR` level can be used to make messages more visible and `HTML` is useful if any -kind of formatting is needed. +kind of formatting is needed. Level `CONSOLE` can be used when the +message needs to shown both in console and in the log file. The following examples clarify how logging with different levels works. @@ -2288,6 +2290,7 @@ works. print('This will be part of the previous message.') print('*INFO* This is a new message.') print('*INFO* This is <b>normal text</b>.') + print('*CONSOLE* This logs into console and log file.') print('*HTML* This is <b>bold</b>.') print('*HTML* <a href="http://robotframework.org">Robot Framework</a>') @@ -2324,6 +2327,11 @@ works. <td class="info level">INFO</td> <td class="msg">This is <b>normal text</b>.</td> </tr> + <tr> + <td class="time">16:18:42.123</td> + <td class="info level">INFO</td> + <td class="msg">This logs into console and log file.</td> + </tr> <tr> <td class="time">16:18:42.123</td> <td class="info level">INFO</td> diff --git a/src/robot/api/logger.py b/src/robot/api/logger.py index 34ea698e970..5e2e75e6b10 100644 --- a/src/robot/api/logger.py +++ b/src/robot/api/logger.py @@ -75,8 +75,12 @@ def write(msg, level='INFO', html=False): """Writes the message to the log file using the given level. Valid log levels are ``TRACE``, ``DEBUG``, ``INFO`` (default), ``WARN``, and - ``ERROR``. Additionally it is possible to use ``HTML`` pseudo log level that - logs the message as HTML using the ``INFO`` level. + ``ERROR``. Additionally there are two pseudo log levels: ``HTML``and ``CONSOLE``. + ``HTML`` pseudo log level logs the message as HTML using the ``INFO`` level. + ``CONSOLE`` pseudo log level logs the message to stdout and to the log file + using ``INFO`` level. Pseudo log levels are are converted to ``INFO`` level if + Robot Framework is not running when calling this function. + Log level ``CONSOLE`` is new in Robot Framework 6.1. Instead of using this method, it is generally better to use the level specific methods such as ``info`` and ``debug`` that have separate @@ -89,6 +93,7 @@ def write(msg, level='INFO', html=False): level = {'TRACE': logging.DEBUG // 2, 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, + 'CONSOLE': logging.INFO, 'HTML': logging.INFO, 'WARN': logging.WARN, 'ERROR': logging.ERROR}[level] diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index ad302ba2d75..726c4710c87 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -2976,10 +2976,11 @@ def log(self, message, level='INFO', html=False, console=False, repr='DEPRECATED', formatter='str'): r"""Logs the given message with the given level. - Valid levels are TRACE, DEBUG, INFO (default), HTML, WARN, and ERROR. + Valid levels are TRACE, DEBUG, INFO (default), CONSOLE, HTML, WARN, and ERROR. Messages below the current active log level are ignored. See `Set Log Level` keyword and ``--loglevel`` command line option for more details about setting the level. + Log level CONSOLE is new in Robot Framework 6.1. Messages logged with the WARN or ERROR levels will be automatically visible also in the console and in the Test Execution Errors section @@ -2993,11 +2994,13 @@ def log(self, message, level='INFO', html=False, console=False, the ``html`` argument is using the HTML pseudo log level. It logs the message as HTML using the INFO level. - If the ``console`` argument is true, the message will be written to - the console where test execution was started from in addition to - the log file. This keyword always uses the standard output stream - and adds a newline after the written message. Use `Log To Console` - instead if either of these is undesirable, + If the ``console`` argument is true or the log level is ``CONSOLE``, + the message will be written to the console where test execution was + started from in addition to the log file. This keyword always uses the + standard output stream and adds a newline after the written message. + Use `Log To Console` instead if either of these is undesirable, + Mimic html section... + The ``formatter`` argument controls how to format the string representation of the message. Possible values are ``str`` (default), @@ -3018,6 +3021,7 @@ def log(self, message, level='INFO', html=False, console=False, | Log | <b>Hello</b>, world! | HTML | | # Same as above. | | Log | <b>Hello</b>, world! | DEBUG | html=true | # DEBUG as HTML. | | Log | Hello, console! | console=yes | | # Log also to the console. | + | Log | Hello, console! | CONSOLE | | # Log also to the console. | | Log | Null is \x00 | formatter=repr | | # Log ``'Null is \x00'``. | See `Log Many` if you want to log multiple messages in one go, and diff --git a/src/robot/output/librarylogger.py b/src/robot/output/librarylogger.py index 7de27723618..5daeb3b8941 100644 --- a/src/robot/output/librarylogger.py +++ b/src/robot/output/librarylogger.py @@ -19,13 +19,10 @@ here to avoid cyclic imports. """ -import sys import threading -from robot.utils import console_encode - from .logger import LOGGER -from .loggerhelper import Message +from .loggerhelper import Message, write_to_console LOGGING_THREADS = ('MainThread', 'RobotFrameworkTimeoutThread') @@ -38,7 +35,11 @@ def write(msg, level, html=False): if callable(msg): msg = str(msg) if level.upper() not in ('TRACE', 'DEBUG', 'INFO', 'HTML', 'WARN', 'ERROR'): - raise RuntimeError("Invalid log level '%s'." % level) + if level.upper() == 'CONSOLE': + level = 'INFO' + console(msg) + else: + raise RuntimeError("Invalid log level '%s'." % level) if threading.current_thread().name in LOGGING_THREADS: LOGGER.log_message(Message(msg, level, html)) @@ -66,9 +67,4 @@ def error(msg, html=False): def console(msg, newline=True, stream='stdout'): - msg = str(msg) - if newline: - msg += '\n' - stream = sys.__stdout__ if stream.lower() != 'stderr' else sys.__stderr__ - stream.write(console_encode(msg, stream=stream)) - stream.flush() + write_to_console(msg, newline, stream) diff --git a/src/robot/output/loggerhelper.py b/src/robot/output/loggerhelper.py index be37bccd53a..4253f8948a2 100644 --- a/src/robot/output/loggerhelper.py +++ b/src/robot/output/loggerhelper.py @@ -13,9 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys + from robot.errors import DataError from robot.model import Message as BaseMessage -from robot.utils import get_timestamp, is_string, safe_str +from robot.utils import get_timestamp, is_string, safe_str, console_encode LEVELS = { @@ -30,6 +32,15 @@ } +def write_to_console(msg, newline=True, stream='stdout'): + msg = str(msg) + if newline: + msg += '\n' + stream = sys.__stdout__ if stream.lower() != 'stderr' else sys.__stderr__ + stream.write(console_encode(msg, stream=stream)) + stream.flush() + + class AbstractLogger: def __init__(self, level='TRACE'): @@ -96,6 +107,8 @@ def _get_level_and_html(self, level, html): level = level.upper() if level == 'HTML': return 'INFO', True + if level == 'CONSOLE': + level = 'INFO' if level not in LEVELS: raise DataError("Invalid log level '%s'." % level) return level, html diff --git a/src/robot/output/stdoutlogsplitter.py b/src/robot/output/stdoutlogsplitter.py index dae95e64520..94ccece8e25 100644 --- a/src/robot/output/stdoutlogsplitter.py +++ b/src/robot/output/stdoutlogsplitter.py @@ -16,15 +16,14 @@ import re from robot.utils import format_time - -from .loggerhelper import Message +from .loggerhelper import Message, write_to_console class StdoutLogSplitter: """Splits messages logged through stdout (or stderr) into Message objects""" _split_from_levels = re.compile(r'^(?:\*' - r'(TRACE|DEBUG|INFO|HTML|WARN|ERROR)' + r'(TRACE|DEBUG|INFO|CONSOLE|HTML|WARN|ERROR)' r'(:\d+(?:\.\d+)?)?' # Optional timestamp r'\*)', re.MULTILINE) @@ -33,6 +32,9 @@ def __init__(self, output): def _get_messages(self, output): for level, timestamp, msg in self._split_output(output): + if level == 'CONSOLE': + write_to_console(msg) + level = 'INFO' if timestamp: timestamp = self._format_timestamp(timestamp[1:]) yield Message(msg.strip(), level, timestamp=timestamp) diff --git a/utest/api/test_logging_api.py b/utest/api/test_logging_api.py index 30c5f7fbe89..bb8c23aca90 100644 --- a/utest/api/test_logging_api.py +++ b/utest/api/test_logging_api.py @@ -85,6 +85,9 @@ def test_logger_to_python_with_html(self): logger.write("Joo", 'HTML') assert_equal(self.handler.messages, ['Foo', 'Doo', 'Joo']) + def test_logger_to_python_with_console(self): + logger.write("Foo", 'CONSOLE') + assert_equal(self.handler.messages, ['Foo']) if __name__ == '__main__': unittest.main() From c441e1d503a5b6a3a70be44783b01d446ad76370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 14 Mar 2023 11:09:17 +0200 Subject: [PATCH 0425/1592] Remove leading spece when using CONSOLE pseudo level. When using `print('*CONSOLE* Message')`, the space before `Message` was included into the message logged to the console. It's removed from the message written to the log file later, but needs to be removed separately here. This minor fix is related to #4536. --- atest/robot/test_libraries/print_logging.robot | 3 ++- atest/testdata/test_libraries/print_logging.robot | 1 + src/robot/output/stdoutlogsplitter.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/atest/robot/test_libraries/print_logging.robot b/atest/robot/test_libraries/print_logging.robot index 345824dfe3f..4015e8ecb92 100644 --- a/atest/robot/test_libraries/print_logging.robot +++ b/atest/robot/test_libraries/print_logging.robot @@ -68,7 +68,8 @@ Logging HTML Logging CONSOLE ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} Hello info and console! - Stdout Should Contain Hello info and console! + Check Log Message ${tc.kws[1].msgs[0]} Hello info and console! + Stdout Should Contain Hello info and console!\nHello info and console!\n FAIL is not valid log level ${tc} = Check Test Case ${TEST NAME} diff --git a/atest/testdata/test_libraries/print_logging.robot b/atest/testdata/test_libraries/print_logging.robot index f7d7fc861d3..79472989099 100644 --- a/atest/testdata/test_libraries/print_logging.robot +++ b/atest/testdata/test_libraries/print_logging.robot @@ -47,6 +47,7 @@ Logging HTML Logging CONSOLE Print Console + Print Console FAIL is not valid log level Print *FAIL* is not failure diff --git a/src/robot/output/stdoutlogsplitter.py b/src/robot/output/stdoutlogsplitter.py index 94ccece8e25..6bcf86a24d0 100644 --- a/src/robot/output/stdoutlogsplitter.py +++ b/src/robot/output/stdoutlogsplitter.py @@ -33,7 +33,7 @@ def __init__(self, output): def _get_messages(self, output): for level, timestamp, msg in self._split_output(output): if level == 'CONSOLE': - write_to_console(msg) + write_to_console(msg.lstrip()) level = 'INFO' if timestamp: timestamp = self._format_timestamp(timestamp[1:]) From 9dec30c402aad2112bbc1d87c4971436f87b9344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 14 Mar 2023 11:33:54 +0200 Subject: [PATCH 0426/1592] Fine-tune documentation of new CONSOLE pseudo log level. Related to #4536. --- .../CreatingTestLibraries.rst | 39 ++++++++++++------- src/robot/api/logger.py | 13 +++---- src/robot/libraries/BuiltIn.py | 39 ++++++++++--------- 3 files changed, 52 insertions(+), 39 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 94da0a4d091..811a2f81433 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -2166,11 +2166,12 @@ Using log levels To use other log levels than `INFO`, or to create several messages, specify the log level explicitly by embedding the level into -the message in the format `*LEVEL* Actual log message`, where -`*LEVEL*` must be in the beginning of a line and `LEVEL` is -one of the available logging levels `TRACE`, `DEBUG`, -`INFO`, `WARN`, `ERROR`, `HTML` and `CONSOLE`. Log level `CONSOLE` -is new in Robot Framework 6.1. +the message in the format `*LEVEL* Actual log message`. +In this formant `*LEVEL*` must be in the beginning of a line and `LEVEL` +must be one of the available concrete log levels `TRACE`, `DEBUG`, +`INFO`, `WARN` or `ERROR`, or a pseudo log level `HTML` or `CONSOLE`. +The pseudo levels can be used for `logging HTML`_ and `logging to console`_, +respectively. Errors and warnings ''''''''''''''''''' @@ -2235,12 +2236,23 @@ __ `Using log levels`_ Logging to console '''''''''''''''''' -If libraries need to write something to the console they have several -options. As already discussed, warnings and all messages written to the +Libraries have several options for writing messages to the console. +As already discussed, warnings and all messages written to the standard error stream are written both to the log file and to the console. Both of these options have a limitation that the messages end -up to the console only after the currently executing keyword -finishes. +up to the console only after the currently executing keyword finishes. + +Starting from Robot Framework 6.1, libraries can use a pseudo log level +`CONSOLE` for logging messages *both* to the log file and to the console: + +.. sourcecode:: python + + def my_keyword(arg): + print('*CONSOLE* Message both to log and to console.') + +These messages will be logged to the log file using the `INFO` level similarly +as with the `HTML` pseudo log level. When using this approach, messages +are logged to the console only after the keyword execution ends. Another option is writing messages to `sys.__stdout__` or `sys.__stderr__`. When using this approach, messages are written to the console immediately @@ -2252,9 +2264,10 @@ and are not written to the log file at all: def my_keyword(arg): - sys.__stdout__.write('Got arg %s\n' % arg) + print('Message only to console.', file=sys.__stdout__) -The final option is using the `public logging API`_: +The final option is using the `public logging API`_. Also in with this approach +messages are written to the console immediately: .. sourcecode:: python @@ -2262,10 +2275,10 @@ The final option is using the `public logging API`_: def log_to_console(arg): - logger.console('Got arg %s' % arg) + logger.console('Message only to console.') def log_to_console_and_log_file(arg): - logger.info('Got arg %s' % arg, also_console=True) + logger.info('Message both to log and to console.', also_console=True) Logging example ''''''''''''''' diff --git a/src/robot/api/logger.py b/src/robot/api/logger.py index 5e2e75e6b10..d4d47eb021b 100644 --- a/src/robot/api/logger.py +++ b/src/robot/api/logger.py @@ -74,13 +74,12 @@ def my_keyword(arg): def write(msg, level='INFO', html=False): """Writes the message to the log file using the given level. - Valid log levels are ``TRACE``, ``DEBUG``, ``INFO`` (default), ``WARN``, and - ``ERROR``. Additionally there are two pseudo log levels: ``HTML``and ``CONSOLE``. - ``HTML`` pseudo log level logs the message as HTML using the ``INFO`` level. - ``CONSOLE`` pseudo log level logs the message to stdout and to the log file - using ``INFO`` level. Pseudo log levels are are converted to ``INFO`` level if - Robot Framework is not running when calling this function. - Log level ``CONSOLE`` is new in Robot Framework 6.1. + Valid log levels are ``TRACE``, ``DEBUG``, ``INFO`` (default), ``WARN``, + and ``ERROR``. In addition to that, there are pseudo log levels ``HTML`` + and ``CONSOLE`` for logging messages as HTML and for logging messages + both to the log file and to the console, respectively. With both of these + pseudo levels the level in the log file will be ``INFO``. The ``CONSOLE`` + level is new in Robot Framework 6.1. Instead of using this method, it is generally better to use the level specific methods such as ``info`` and ``debug`` that have separate diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 726c4710c87..04ea3619f06 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -2976,31 +2976,31 @@ def log(self, message, level='INFO', html=False, console=False, repr='DEPRECATED', formatter='str'): r"""Logs the given message with the given level. - Valid levels are TRACE, DEBUG, INFO (default), CONSOLE, HTML, WARN, and ERROR. - Messages below the current active log level are ignored. See - `Set Log Level` keyword and ``--loglevel`` command line option - for more details about setting the level. - Log level CONSOLE is new in Robot Framework 6.1. + Valid levels are TRACE, DEBUG, INFO (default), WARN and ERROR. + In addition to that, there are pseudo log levels HTML and CONSOLE that + both log messages using INFO. - Messages logged with the WARN or ERROR levels will be automatically + Messages below the current active log + level are ignored. See `Set Log Level` keyword and ``--loglevel`` + command line option for more details about setting the level. + + Messages logged with the WARN or ERROR levels are automatically visible also in the console and in the Test Execution Errors section in the log file. If the ``html`` argument is given a true value (see `Boolean - arguments`), the message will be considered HTML and special characters + arguments`) or the HTML pseudo log level is used, the message is + considered to be HTML and special characters such as ``<`` are not escaped. For example, logging - ``<img src="image.png">`` creates an image when ``html`` is true, but - otherwise the message is that exact string. An alternative to using - the ``html`` argument is using the HTML pseudo log level. It logs - the message as HTML using the INFO level. - - If the ``console`` argument is true or the log level is ``CONSOLE``, - the message will be written to the console where test execution was - started from in addition to the log file. This keyword always uses the - standard output stream and adds a newline after the written message. - Use `Log To Console` instead if either of these is undesirable, - Mimic html section... - + ``<img src="image.png">`` creates an image in this case, but + otherwise the message is that exact string. When using the HTML pseudo + level, the messages is logged using the INFO level. + + If the ``console`` argument is true or the CONSOLE pseudo level is + used, the message is written both to the console and to the log file. + When using the CONSOLE pseudo level, the message is logged using the + INFO level. If the message should not be logged to the log file or there + are special formatting needs, use the `Log To Console` keyword instead. The ``formatter`` argument controls how to format the string representation of the message. Possible values are ``str`` (default), @@ -3028,6 +3028,7 @@ def log(self, message, level='INFO', html=False, console=False, `Log To Console` if you only want to write to the console. Formatter options ``type`` and ``len`` are new in Robot Framework 5.0. + The CONSOLE level is new in Robot Framework 6.1. """ # TODO: Remove `repr` altogether in RF 7.0. It was deprecated in RF 5.0. if repr == 'DEPRECATED': From 10b03f1712cedb5a37885b286d12316ae4f5da27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 01:18:39 +0200 Subject: [PATCH 0427/1592] Enhance For, While and Try string reprs --- src/robot/model/control.py | 32 ++++++++++++++++++++-------- src/robot/model/modelobject.py | 10 +++++---- src/robot/running/model.py | 7 +++--- utest/model/test_control.py | 39 ++++++++++++++++++++++++++-------- 4 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index c86c2d431c5..1d892b1cc37 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -23,7 +23,7 @@ class For(BodyItem): type = BodyItem.FOR body_class = Body - repr_args = ('variables', 'flavor', 'values') + repr_args = ('variables', 'flavor', 'values', 'start', 'mode', 'fill') __slots__ = ['variables', 'flavor', 'values', 'start', 'mode', 'fill'] def __init__(self, variables=(), flavor='IN', values=(), start=None, mode=None, @@ -54,9 +54,16 @@ def visit(self, visitor): visitor.visit_for(self) def __str__(self): - variables = ' '.join(self.variables) - values = ' '.join(self.values) - return 'FOR %s %s %s' % (variables, self.flavor, values) + parts = ['FOR', *self.variables, self.flavor, *self.values] + for name, value in [('start', self.start), + ('mode', self.mode), + ('fill', self.fill)]: + if value is not None: + parts.append(f'{name}={value}') + return ' '.join(parts) + + def _include_in_repr(self, name, value): + return name not in ('start', 'mode', 'fill') or value is not None def to_dict(self): data = {'type': self.type, @@ -93,7 +100,15 @@ def visit(self, visitor): visitor.visit_while(self) def __str__(self): - return f'WHILE {self.condition}' + (f' {self.limit}' if self.limit else '') + parts = ['WHILE'] + if self.condition is not None: + parts.append(self.condition) + if self.limit is not None: + parts.append(f'limit={self.limit}') + return ' '.join(parts) + + def _include_in_repr(self, name, value): + return name == 'condition' or value is not None def to_dict(self): data = {'type': self.type} @@ -208,16 +223,15 @@ def id(self): def __str__(self): if self.type != BodyItem.EXCEPT: return self.type - parts = ['EXCEPT'] + list(self.patterns) + parts = ['EXCEPT', *self.patterns] if self.pattern_type: parts.append(f'type={self.pattern_type}') if self.variable: parts.extend(['AS', self.variable]) return ' '.join(parts) - def __repr__(self): - repr_args = self.repr_args if self.type == BodyItem.EXCEPT else ['type'] - return self._repr(repr_args) + def _include_in_repr(self, name, value): + return name == 'type' or value def visit(self, visitor): visitor.visit_try_branch(self) diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index 0fc8997e2b1..dea2fa3c857 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -136,11 +136,13 @@ def deepcopy(self, **attributes): return copied def __repr__(self): - return self._repr(self.repr_args) + arguments = [(name, getattr(self, name)) for name in self.repr_args] + args_repr = ', '.join(f'{name}={value!r}' for name, value in arguments + if self._include_in_repr(name, value)) + return f"{full_name(self)}({args_repr})" - def _repr(self, repr_args): - args = ', '.join(f'{a}={getattr(self, a)!r}' for a in repr_args) - return f"{full_name(self)}({args})" + def _include_in_repr(self, name, value): + return True def full_name(obj): diff --git a/src/robot/running/model.py b/src/robot/running/model.py index a15a1138aee..9c58bae2eb6 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -763,10 +763,6 @@ def __init__(self, type, name, args=(), alias=None, parent=None, lineno=None): self.parent = parent self.lineno = lineno - def _repr(self, repr_args): - repr_args = [a for a in repr_args if a in ('type', 'name') or getattr(self, a)] - return super()._repr(repr_args) - @property def source(self) -> Path: return self.parent.source if self.parent is not None else None @@ -804,6 +800,9 @@ def to_dict(self): data['lineno'] = self.lineno return data + def _include_in_repr(self, name, value): + return name in ('type', 'name') or value + class Imports(model.ItemList): diff --git a/utest/model/test_control.py b/utest/model/test_control.py index 65a87da77cd..9b541a1813e 100644 --- a/utest/model/test_control.py +++ b/utest/model/test_control.py @@ -1,6 +1,6 @@ import unittest -from robot.model import For, If, IfBranch, TestCase, Try, TryBranch +from robot.model import For, If, IfBranch, TestCase, Try, TryBranch, While from robot.utils.asserts import assert_equal @@ -17,7 +17,7 @@ class TestFor(unittest.TestCase): def test_string_reprs(self): for for_, exp_str, exp_repr in [ (For(), - 'FOR IN ', + 'FOR IN', "For(variables=(), flavor='IN', values=())"), (For(('${x}',), 'IN RANGE', ('10',)), 'FOR ${x} IN RANGE 10', @@ -25,14 +25,35 @@ def test_string_reprs(self): (For(('${x}', '${y}'), 'IN ENUMERATE', ('a', 'b')), 'FOR ${x} ${y} IN ENUMERATE a b', "For(variables=('${x}', '${y}'), flavor='IN ENUMERATE', values=('a', 'b'))"), - (For([u'${\xfc}'], 'IN', [u'f\xf6\xf6']), - u'FOR ${\xfc} IN f\xf6\xf6', - u"For(variables=[%r], flavor='IN', values=[%r])" % (u'${\xfc}', u'f\xf6\xf6')) + (For(['${x}'], 'IN ENUMERATE', ['@{stuff}'], start='1'), + 'FOR ${x} IN ENUMERATE @{stuff} start=1', + "For(variables=['${x}'], flavor='IN ENUMERATE', values=['@{stuff}'], start='1')"), + (For(('${x}', '${y}'), 'IN ZIP', ('${xs}', '${ys}'), mode='LONGEST', fill='-'), + 'FOR ${x} ${y} IN ZIP ${xs} ${ys} mode=LONGEST fill=-', + "For(variables=('${x}', '${y}'), flavor='IN ZIP', values=('${xs}', '${ys}'), mode='LONGEST', fill='-')"), + (For(['${ü}'], 'IN', ['föö']), + 'FOR ${ü} IN föö', + "For(variables=['${ü}'], flavor='IN', values=['föö'])") ]: assert_equal(str(for_), exp_str) assert_equal(repr(for_), 'robot.model.' + exp_repr) +class TestWhile(unittest.TestCase): + + def test_string_reprs(self): + for while_, exp_str, exp_repr in [ + (While(), + 'WHILE', + "While(condition=None)"), + (While('$x', limit='100'), + 'WHILE $x limit=100', + "While(condition='$x', limit='100')") + ]: + assert_equal(str(while_), exp_str) + assert_equal(repr(while_), 'robot.model.' + exp_repr) + + class TestIf(unittest.TestCase): def test_type(self): @@ -142,16 +163,16 @@ def test_string_reprs(self): "TryBranch(type='TRY')"), (TryBranch(EXCEPT), 'EXCEPT', - "TryBranch(type='EXCEPT', patterns=(), pattern_type=None, variable=None)"), + "TryBranch(type='EXCEPT')"), (TryBranch(EXCEPT, ('Message',)), 'EXCEPT Message', - "TryBranch(type='EXCEPT', patterns=('Message',), pattern_type=None, variable=None)"), + "TryBranch(type='EXCEPT', patterns=('Message',))"), (TryBranch(EXCEPT, ('M', 'S', 'G', 'S')), 'EXCEPT M S G S', - "TryBranch(type='EXCEPT', patterns=('M', 'S', 'G', 'S'), pattern_type=None, variable=None)"), + "TryBranch(type='EXCEPT', patterns=('M', 'S', 'G', 'S'))"), (TryBranch(EXCEPT, (), None, '${x}'), 'EXCEPT AS ${x}', - "TryBranch(type='EXCEPT', patterns=(), pattern_type=None, variable='${x}')"), + "TryBranch(type='EXCEPT', variable='${x}')"), (TryBranch(EXCEPT, ('Message',), 'glob', '${x}'), 'EXCEPT Message type=glob AS ${x}', "TryBranch(type='EXCEPT', patterns=('Message',), pattern_type='glob', variable='${x}')"), From f4b7d326caaf3182e352b6ee569808bf72f56b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 02:21:04 +0200 Subject: [PATCH 0428/1592] f-strigs They are supposed to be fastest formatting approach so hopefully there's at least a small performance gain. --- src/robot/utils/markupwriters.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot/utils/markupwriters.py b/src/robot/utils/markupwriters.py index cd962fdc703..30c329b877d 100644 --- a/src/robot/utils/markupwriters.py +++ b/src/robot/utils/markupwriters.py @@ -41,13 +41,13 @@ def start(self, name, attrs=None, newline=True): self._start(name, attrs, newline) def _start(self, name, attrs, newline): - self._write('<%s %s>' % (name, attrs) if attrs else '<%s>' % name, newline) + self._write(f'<{name} {attrs}>' if attrs else f'<{name}>', newline) def _format_attrs(self, attrs): if not attrs: return '' write_empty = self._write_empty - return ' '.join('%s="%s"' % (name, attribute_escape(value or '')) + return ' '.join(f"{name}=\"{attribute_escape(value or '')}\"" for name, value in self._order_attrs(attrs) if write_empty or value) @@ -62,7 +62,7 @@ def _escape(self, content): raise NotImplementedError def end(self, name, newline=True): - self._write('</%s>' % name, newline) + self._write(f'</{name}>', newline) def element(self, name, content=None, attrs=None, escape=True, newline=True): attrs = self._format_attrs(attrs) @@ -107,7 +107,7 @@ def element(self, name, content=None, attrs=None, escape=True, newline=True): def _self_closing_element(self, name, attrs, newline): attrs = self._format_attrs(attrs) if self._write_empty or attrs: - self._write('<%s %s/>' % (name, attrs) if attrs else '<%s/>' % name, newline) + self._write(f'<{name} {attrs}/>' if attrs else f'<{name}/>', newline) class NullMarkupWriter: From de99af134fd9f55f81abe378e7994ff8d0278d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 02:28:43 +0200 Subject: [PATCH 0429/1592] Simplify writing WHILE attrs to output.xml No need to filter out empty/None values here, they are filtered out later anyway. --- src/robot/output/xmllogger.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 7fec7b176a2..0b6e0596347 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -100,13 +100,10 @@ def end_if_branch(self, branch): self._writer.end('branch') def start_for(self, for_): - attrs = {'flavor': for_.flavor} - for name, value in [('start', for_.start), - ('mode', for_.mode), - ('fill', for_.fill)]: - if value is not None: - attrs[name] = value - self._writer.start('for', attrs) + self._writer.start('for', {'flavor': for_.flavor, + 'start': for_.start, + 'mode': for_.mode, + 'fill': for_.fill}) for name in for_.variables: self._writer.element('var', name) for value in for_.values: From 69c880890ae80d2ac80bb51a812aae92c4c8bc65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 02:30:07 +0200 Subject: [PATCH 0430/1592] Increase output.xml schema version after recent changes --- doc/schema/robot.xsd | 4 ++-- src/robot/output/xmllogger.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index 31b8b323474..4418b2d1b9d 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="3"> +<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="4"> <xs:annotation> <xs:documentation xml:lang="en"> = Robot Framework output.xml schema = @@ -33,7 +33,7 @@ <xs:simpleType name="SpecVersion"> <xs:restriction base="xs:integer"> <xs:minInclusive value="3" /> - <xs:maxInclusive value="3" /> + <xs:maxInclusive value="4" /> </xs:restriction> </xs:simpleType> <xs:complexType name="Suite"> diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 0b6e0596347..34745e2372b 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -35,7 +35,7 @@ def _get_writer(self, path, rpa, generator): writer.start('robot', {'generator': get_full_version(generator), 'generated': get_timestamp(), 'rpa': 'true' if rpa else 'false', - 'schemaversion': '3'}) + 'schemaversion': '4'}) return writer def close(self): From c8233cbc46c1fde1f6474aaa0883e634c0e52a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 02:41:53 +0200 Subject: [PATCH 0431/1592] Remove apparently accidentally added empty file --- atest/robot/output/listener_interface/keyword_attributes.robot | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 atest/robot/output/listener_interface/keyword_attributes.robot diff --git a/atest/robot/output/listener_interface/keyword_attributes.robot b/atest/robot/output/listener_interface/keyword_attributes.robot deleted file mode 100644 index e69de29bb2d..00000000000 From 723a469e709e21bcc9d406343babc35f9a953df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 10:49:56 +0200 Subject: [PATCH 0432/1592] Fix passing ELSE IF condition to listeners. Fixes #4692. --- atest/robot/output/listener_interface/listener_methods.robot | 2 +- src/robot/output/listenerarguments.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/atest/robot/output/listener_interface/listener_methods.robot b/atest/robot/output/listener_interface/listener_methods.robot index 46b40ede58c..1ab50720f21 100644 --- a/atest/robot/output/listener_interface/listener_methods.robot +++ b/atest/robot/output/listener_interface/listener_methods.robot @@ -64,7 +64,7 @@ Keyword Arguments Are Always Strings Should Not Contain ${status} FAILED Keyword Attributes For Control Structures - Run Tests --listener VerifyAttributes misc/for_loops.robot misc/while.robot misc/try_except.robot + Run Tests --listener VerifyAttributes misc/for_loops.robot misc/while.robot misc/try_except.robot misc/if_else.robot Stderr Should Be Empty ${status} = Log File %{TEMPDIR}/${ATTR_TYPE_FILE} Should Not Contain ${status} FAILED diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index c7950f90d5c..8eff9da99e5 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -134,7 +134,7 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): _type_attributes = { BodyItem.FOR: ('variables', 'flavor', 'values'), BodyItem.IF: ('condition',), - BodyItem.ELSE_IF: ('condition'), + BodyItem.ELSE_IF: ('condition',), BodyItem.EXCEPT: ('patterns', 'pattern_type', 'variable'), BodyItem.WHILE: ('condition', 'limit'), BodyItem.RETURN: ('values',), From 243e63940470369965a29ca04f67665073d2cc28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 11:29:58 +0200 Subject: [PATCH 0433/1592] Pass FOR IN ENUMERATE/ZIP extra info to listeners. This includes `start` with IN ENUMERATE (#4684) and `mode` and `fill` with IN ZIP (#4682). --- .../listener_interface/listener_methods.robot | 2 +- atest/testresources/listeners/VerifyAttributes.py | 13 ++++++++++--- .../ExtendingRobotFramework/ListenerInterface.rst | 9 ++++++++- src/robot/output/listenerarguments.py | 13 ++++++++++--- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/atest/robot/output/listener_interface/listener_methods.robot b/atest/robot/output/listener_interface/listener_methods.robot index 1ab50720f21..f52d7a5a54a 100644 --- a/atest/robot/output/listener_interface/listener_methods.robot +++ b/atest/robot/output/listener_interface/listener_methods.robot @@ -26,7 +26,7 @@ Correct Attributes To Listener Methods Keyword Tags ${status} = Log File %{TEMPDIR}/${ATTR_TYPE_FILE} - Should Contain X Times ${status} PASSED | tags: [force, keyword, tags] 6 + Should Contain X Times ${status} passed | tags: [force, keyword, tags] 6 Suite And Test Counts Run Tests --listener listeners.SuiteAndTestCounts misc/suites/subsuites misc/suites/subsuites2 diff --git a/atest/testresources/listeners/VerifyAttributes.py b/atest/testresources/listeners/VerifyAttributes.py index bc26632761f..e30f489a241 100644 --- a/atest/testresources/listeners/VerifyAttributes.py +++ b/atest/testresources/listeners/VerifyAttributes.py @@ -12,6 +12,8 @@ 'ELSE IF': 'condition', 'EXCEPT': 'patterns pattern_type variable', 'RETURN': 'values'} +FOR_FLAVOR_EXTRA = {'IN ENUMERATE': ' start', + 'IN ZIP': ' mode fill'} EXPECTED_TYPES = {'tags': [str], 'args': [str], 'assign': [str], @@ -36,8 +38,9 @@ def verify_attrs(method_name, attrs, names): names = set(names.split()) OUTFILE.write(method_name + '\n') if len(names) != len(attrs): - OUTFILE.write('FAILED: wrong number of attributes\n') - OUTFILE.write('Expected: %s\nActual: %s\n' % (names, attrs.keys())) + OUTFILE.write(f'FAILED: wrong number of attributes\n') + OUTFILE.write(f'Expected: {sorted(names)}\n') + OUTFILE.write(f'Actual: {sorted(attrs)}\n') return for name in names: value = attrs[name] @@ -58,7 +61,7 @@ def verify_attrs(method_name, attrs, names): def verify_attr(name, value, exp_type): if isinstance(value, exp_type): - OUTFILE.write('PASSED | %s: %s\n' % (name, format_value(value))) + OUTFILE.write('passed | %s: %s\n' % (name, format_value(value))) else: OUTFILE.write('FAILED | %s: %r, Expected: %s, Actual: %s\n' % (name, value, exp_type, type(value))) @@ -113,6 +116,8 @@ def start_keyword(self, name, attrs): extra = KW_TYPES.get(type_, '') if type_ == 'ITERATION' and self._keyword_stack[-1] == 'FOR': extra += ' variables' + if type_ == 'FOR': + extra += FOR_FLAVOR_EXTRA.get(attrs['flavor'], '') verify_attrs('START ' + type_, attrs, START + KW + extra) verify_name(name, **attrs) self._keyword_stack.append(type_) @@ -123,6 +128,8 @@ def end_keyword(self, name, attrs): extra = KW_TYPES.get(type_, '') if type_ == 'ITERATION' and self._keyword_stack[-1] == 'FOR': extra += ' variables' + if type_ == 'FOR': + extra += FOR_FLAVOR_EXTRA.get(attrs['flavor'], '') verify_attrs('END ' + type_, attrs, END + KW + extra) verify_name(name, **attrs) diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 3b0971d0f3f..1bf541c0800 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -255,8 +255,13 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | * `flavor`: Type of loop (e.g. `IN RANGE`). | | | | * `values`: List of values being looped over | | | | as a list or strings. | + | | | * `start`: Start configuration. Only used with `IN ENUMERATE` | + | | | loops. | + | | | * `mode`: Mode configuration. Only used with `IN ZIP` loops. | + | | | * `fill`: Fill value configuration. Only used with `IN ZIP` | + | | | loops. | | | | | - | | | Additional attributes for `ITERATION` types: | + | | | Additional attributes for `ITERATION` types with `FOR` loops: | | | | | | | | * `variables`: Variables and string representations of their | | | | contents for one `FOR` loop iteration as a dictionary. | @@ -282,6 +287,8 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | * `values`: Return values from a keyword as a list or strings. | | | | | | | | Additional attributes for control structures are new in RF 6.0.| + | | | `ELSE IF` `condition` as well as `FOR` loop `start`, `mode` | + | | | and `fill` are new in RF 6.1. | +------------------+------------------+----------------------------------------------------------------+ | end_keyword | name, attributes | Called when a keyword ends. | | | | | diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 8eff9da99e5..2c80ce9727e 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -140,6 +140,10 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): BodyItem.RETURN: ('values',), BodyItem.ITERATION: ('variables',) } + _for_flavor_attributes = { + 'IN ENUMERATE': ('start',), + 'IN ZIP': ('mode', 'fill') + } def _get_extra_attributes(self, kw): attrs = {'kwname': kw.kwname or '', @@ -147,9 +151,12 @@ def _get_extra_attributes(self, kw): 'args': [a if is_string(a) else safe_str(a) for a in kw.args], 'source': str(kw.source or '')} if kw.type in self._type_attributes: - attrs.update({name: self._get_attribute_value(kw, name) - for name in self._type_attributes[kw.type] - if hasattr(kw, name)}) + for name in self._type_attributes[kw.type]: + if hasattr(kw, name): + attrs[name] = self._get_attribute_value(kw, name) + if kw.type == BodyItem.FOR: + for name in self._for_flavor_attributes.get(kw.flavor, ()): + attrs[name] = self._get_attribute_value(kw, name) return attrs From 8f4f3d432cb50f79c9b83020545d466a44c959fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 15 Mar 2023 22:51:16 +0200 Subject: [PATCH 0434/1592] Lex and parse invalid sections correctly Instead of creating an ERROR node inside the last block element, InvalidSection is created in the model from an invalid header Relates to #4689 --- src/robot/parsing/lexer/blocklexers.py | 8 ++--- src/robot/parsing/lexer/context.py | 3 +- src/robot/parsing/lexer/statementlexers.py | 10 +++++- src/robot/parsing/lexer/tokens.py | 6 +++- src/robot/parsing/model/__init__.py | 4 +-- src/robot/parsing/model/blocks.py | 4 +++ src/robot/parsing/model/statements.py | 8 ++++- src/robot/parsing/parser/blockparsers.py | 4 --- src/robot/parsing/parser/fileparser.py | 8 ++++- src/robot/running/builder/transformers.py | 8 +++-- utest/parsing/test_lexer.py | 10 +++--- utest/parsing/test_model.py | 37 +++++++++++++--------- 12 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index 9ad7a3f1fe2..b2e1ae70a8e 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -24,7 +24,7 @@ TaskSectionHeaderLexer, KeywordSectionHeaderLexer, CommentSectionHeaderLexer, CommentLexer, ImplicitCommentLexer, - ErrorSectionHeaderLexer, + InvalidSectionHeaderLexer, FatalInvalidSectionHeaderLexer, TestOrKeywordSettingLexer, KeywordCallLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, @@ -86,7 +86,7 @@ def lexer_classes(self): return (SettingSectionLexer, VariableSectionLexer, TestCaseSectionLexer, TaskSectionLexer, KeywordSectionLexer, CommentSectionLexer, - ErrorSectionLexer, ImplicitCommentSectionLexer) + InvalidSectionLexer, ImplicitCommentSectionLexer) class SectionLexer(BlockLexer): @@ -165,14 +165,14 @@ def lexer_classes(self): return (ImplicitCommentLexer,) -class ErrorSectionLexer(SectionLexer): +class InvalidSectionLexer(SectionLexer): @classmethod def handles(cls, statement: list, ctx: FileContext): return statement and statement[0].value.startswith('*') def lexer_classes(self): - return (ErrorSectionHeaderLexer, CommentLexer) + return (InvalidSectionHeaderLexer, FatalInvalidSectionHeaderLexer, CommentLexer) class TestOrKeywordLexer(BlockLexer): diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 4fcc193e9a6..929c78cdcf9 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -67,7 +67,8 @@ def comment_section(self, statement): def lex_invalid_section(self, statement): message, fatal = self._get_invalid_section_error(statement[0].value) - statement[0].set_error(message, fatal) + statement[0].error = message + statement[0].type = Token.INVALID_HEADER if not fatal else Token.FATAL_INVALID_HEADER for token in statement[1:]: token.type = Token.COMMENT diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index c89519a21cd..b2733a2c121 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -105,7 +105,15 @@ class CommentSectionHeaderLexer(SectionHeaderLexer): token_type = Token.COMMENT_HEADER -class ErrorSectionHeaderLexer(SectionHeaderLexer): +class InvalidSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.INVALID_HEADER + + def lex(self): + self.ctx.lex_invalid_section(self.statement) + + +class FatalInvalidSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.FATAL_INVALID_HEADER def lex(self): self.ctx.lex_invalid_section(self.statement) diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index fef909165f9..6fc89710072 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -44,6 +44,8 @@ class Token: TASK_HEADER = 'TASK HEADER' KEYWORD_HEADER = 'KEYWORD HEADER' COMMENT_HEADER = 'COMMENT HEADER' + INVALID_HEADER = 'INVALID HEADER' + FATAL_INVALID_HEADER = 'FATAL INVALID HEADER' TESTCASE_NAME = 'TESTCASE NAME' KEYWORD_NAME = 'KEYWORD NAME' @@ -142,7 +144,9 @@ class Token: TESTCASE_HEADER, TASK_HEADER, KEYWORD_HEADER, - COMMENT_HEADER + COMMENT_HEADER, + INVALID_HEADER, + FATAL_INVALID_HEADER )) ALLOW_VARIABLES = frozenset(( NAME, diff --git a/src/robot/parsing/model/__init__.py b/src/robot/parsing/model/__init__.py index 9993f37b3bf..85b0fa4af63 100644 --- a/src/robot/parsing/model/__init__.py +++ b/src/robot/parsing/model/__init__.py @@ -14,7 +14,7 @@ # limitations under the License. from .blocks import (File, SettingSection, VariableSection, TestCaseSection, - KeywordSection, CommentSection, TestCase, Keyword, For, - If, Try, While) + KeywordSection, CommentSection, InvalidSection, + TestCase, Keyword, For, If, Try, While) from .statements import Statement from .visitor import ModelTransformer, ModelVisitor diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 649f2646348..0bbec4a93fc 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -122,6 +122,10 @@ class CommentSection(Section): pass +class InvalidSection(Section): + pass + + class TestCase(HeaderAndBody): @property diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 7ba8d34e2bc..8b04909d320 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -222,7 +222,8 @@ def args(self): class SectionHeader(Statement): handles_types = (Token.SETTING_HEADER, Token.VARIABLE_HEADER, Token.TESTCASE_HEADER, Token.TASK_HEADER, - Token.KEYWORD_HEADER, Token.COMMENT_HEADER) + Token.KEYWORD_HEADER, Token.COMMENT_HEADER, + Token.INVALID_HEADER, Token.FATAL_INVALID_HEADER) @classmethod def from_params(cls, type, name=None, eol=EOL): @@ -247,6 +248,11 @@ def name(self): token = self.get_token(*self.handles_types) return normalize_whitespace(token.value).strip('* ') + def validate(self, context: 'ValidationContext'): + tokens = self.get_tokens(Token.INVALID_HEADER, Token.FATAL_INVALID_HEADER) + for t in tokens: + self.errors += (t.error, ) + @Statement.register class LibraryImport(Statement): diff --git a/src/robot/parsing/parser/blockparsers.py b/src/robot/parsing/parser/blockparsers.py index d4f4a41e52e..82a2fb85b4f 100644 --- a/src/robot/parsing/parser/blockparsers.py +++ b/src/robot/parsing/parser/blockparsers.py @@ -45,10 +45,6 @@ def __init__(self, model): } def handles(self, statement): - # FIXME: this needs to be handled better - if statement.type == Token.ERROR and \ - statement.errors[0].startswith('Unrecognized section header'): - return False return statement.type not in self.unhandled_tokens def parse(self, statement): diff --git a/src/robot/parsing/parser/fileparser.py b/src/robot/parsing/parser/fileparser.py index 296d0a8f522..b9e6f7a49f5 100644 --- a/src/robot/parsing/parser/fileparser.py +++ b/src/robot/parsing/parser/fileparser.py @@ -19,7 +19,7 @@ from ..lexer import Token from ..model import (File, CommentSection, SettingSection, VariableSection, - TestCaseSection, KeywordSection) + TestCaseSection, KeywordSection, InvalidSection) from .blockparsers import Parser, TestCaseParser, KeywordParser @@ -49,6 +49,8 @@ def parse(self, statement): Token.TASK_HEADER: TestCaseSectionParser, Token.KEYWORD_HEADER: KeywordSectionParser, Token.COMMENT_HEADER: CommentSectionParser, + Token.INVALID_HEADER: InvalidSectionParser, + Token.FATAL_INVALID_HEADER: InvalidSectionParser, Token.CONFIG: ImplicitCommentSectionParser, Token.COMMENT: ImplicitCommentSectionParser, Token.ERROR: ImplicitCommentSectionParser, @@ -85,6 +87,10 @@ class CommentSectionParser(SectionParser): model_class = CommentSection +class InvalidSectionParser(SectionParser): + model_class = InvalidSection + + class ImplicitCommentSectionParser(SectionParser): def model_class(self, statement): diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index a264e540656..3a27337053a 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -625,10 +625,14 @@ def visit_TestCase(self, node): def visit_Keyword(self, node): pass - def visit_Error(self, node): - fatal = node.get_token(Token.FATAL_ERROR) + def visit_SectionHeader(self, node): + fatal = node.get_token(Token.FATAL_INVALID_HEADER) if fatal: raise DataError(self._format_message(fatal)) + if node.errors: + LOGGER.error(self._format_message(node.get_token(Token.INVALID_HEADER))) + + def visit_Error(self, node): for error in node.get_tokens(Token.ERROR): LOGGER.error(self._format_message(error)) diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index fba3ca11bf4..6f92e7ef7c3 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -693,21 +693,21 @@ def test_test_case_section(self): def test_case_section_causes_error_in_init_file(self): assert_tokens('*** Test Cases ***', [ - (T.ERROR, '*** Test Cases ***', 1, 0, + (T.INVALID_HEADER, '*** Test Cases ***', 1, 0, "'Test Cases' section is not allowed in suite initialization file."), (T.EOS, '', 1, 18), ], get_init_tokens, data_only=True) def test_case_section_causes_fatal_error_in_resource_file(self): assert_tokens('*** Test Cases ***', [ - (T.FATAL_ERROR, '*** Test Cases ***', 1, 0, + (T.FATAL_INVALID_HEADER, '*** Test Cases ***', 1, 0, "Resource file with 'Test Cases' section is invalid."), (T.EOS, '', 1, 18), ], get_resource_tokens, data_only=True) def test_invalid_section_in_test_case_file(self): assert_tokens('*** Invalid ***', [ - (T.ERROR, '*** Invalid ***', 1, 0, + (T.INVALID_HEADER, '*** Invalid ***', 1, 0, "Unrecognized section header '*** Invalid ***'. Valid sections: " "'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' and 'Comments'."), (T.EOS, '', 1, 15), @@ -715,7 +715,7 @@ def test_invalid_section_in_test_case_file(self): def test_invalid_section_in_init_file(self): assert_tokens('*** S e t t i n g s ***', [ - (T.ERROR, '*** S e t t i n g s ***', 1, 0, + (T.INVALID_HEADER, '*** S e t t i n g s ***', 1, 0, "Unrecognized section header '*** S e t t i n g s ***'. Valid sections: " "'Settings', 'Variables', 'Keywords' and 'Comments'."), (T.EOS, '', 1, 23), @@ -723,7 +723,7 @@ def test_invalid_section_in_init_file(self): def test_invalid_section_in_resource_file(self): assert_tokens('*', [ - (T.ERROR, '*', 1, 0, + (T.INVALID_HEADER, '*', 1, 0, "Unrecognized section header '*'. Valid sections: " "'Settings', 'Variables', 'Keywords' and 'Comments'."), (T.EOS, '', 1, 1), diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 9bfc9819b8d..eb7e29dfbff 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -6,7 +6,7 @@ from robot.parsing import get_model, get_resource_model, ModelVisitor, ModelTransformer, Token from robot.parsing.model.blocks import ( - CommentSection, File, For, If, Try, While, + CommentSection, File, For, If, InvalidSection, Try, While, Keyword, KeywordSection, SettingSection, TestCase, TestCaseSection, VariableSection ) from robot.parsing.model.statements import ( @@ -1050,10 +1050,12 @@ def test_model_error(self): ) inv_setting = "Non-existing setting 'Invalid'." expected = File([ - CommentSection( - body=[ - Error([Token('ERROR', '*** Invalid ***', 1, 0, inv_header)]) - ] + InvalidSection( + header=SectionHeader( + [Token('INVALID HEADER', '*** Invalid ***', 1, 0, inv_header)], + (inv_header,) + ) + ), SettingSection( header=SectionHeader([ @@ -1073,10 +1075,9 @@ def test_model_error_with_fatal_error(self): ''', data_only=True) inv_testcases = "Resource file with 'Test Cases' section is invalid." expected = File([ - CommentSection( - body=[ - Error([Token('FATAL ERROR', '*** Test Cases ***', 1, 0, inv_testcases)]) - ] + InvalidSection( + header=SectionHeader( + [Token('FATAL INVALID HEADER', '*** Test Cases ***', 1, 0, inv_testcases)], (inv_testcases,)) ) ]) assert_model(model, expected) @@ -1096,10 +1097,11 @@ def test_model_error_with_error_and_fatal_error(self): inv_setting = "Non-existing setting 'Invalid'." inv_testcases = "Resource file with 'Test Cases' section is invalid." expected = File([ - CommentSection( - body=[ - Error([Token('ERROR', '*** Invalid ***', 1, 0, inv_header)]) - ] + InvalidSection( + header=SectionHeader( + [Token('INVALID HEADER', '*** Invalid ***', 1, 0, inv_header)], + (inv_header,) + ) ), SettingSection( header=SectionHeader([ @@ -1108,9 +1110,14 @@ def test_model_error_with_error_and_fatal_error(self): body=[ Error([Token('ERROR', 'Invalid', 3, 0, inv_setting)]), Documentation([Token('DOCUMENTATION', 'Documentation', 4, 0)]), - Error([Token('FATAL ERROR', '*** Test Cases ***', 5, 0, inv_testcases)]) ] - ) + ), + InvalidSection( + header=SectionHeader( + [Token('FATAL INVALID HEADER', '*** Test Cases ***', 5, 0, inv_testcases)], + (inv_testcases,) + ) + ), ]) assert_model(model, expected) From a5e9c27281cccee9da02abe99e4c57c7c0594786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 15 Mar 2023 22:52:52 +0200 Subject: [PATCH 0435/1592] Remove usages of unused token FATAL ERROR The only place this was used was when a resource file had a test case sections, and this case is now handled with the new FATAL INVALID SECTION token. The Token definition is left in place and should be removed in RF 7.0 Relates to 4689 --- src/robot/parsing/lexer/tokens.py | 5 +++-- src/robot/parsing/model/statements.py | 5 ++--- utest/parsing/test_model.py | 25 +++---------------------- 3 files changed, 8 insertions(+), 27 deletions(-) diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 6fc89710072..32976588818 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -106,6 +106,7 @@ class Token: EOS = 'EOS' ERROR = 'ERROR' + # TODO: FATAL_ERROR is no longer used, remove in RF 7.0 FATAL_ERROR = 'FATAL ERROR' NON_DATA_TOKENS = frozenset(( @@ -183,8 +184,8 @@ def end_col_offset(self): return -1 return self.col_offset + len(self.value) - def set_error(self, error, fatal=False): - self.type = Token.ERROR if not fatal else Token.FATAL_ERROR + def set_error(self, error): + self.type = Token.ERROR self.error = error def tokenize_variables(self): diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 8b04909d320..fd3a21111f2 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -1125,7 +1125,6 @@ def language(self): @Statement.register class Error(Statement): type = Token.ERROR - handles_types = (Token.ERROR, Token.FATAL_ERROR) _errors = () @property @@ -1134,12 +1133,12 @@ def values(self): @property def errors(self): - """Errors got from the underlying ``ERROR`` and ``FATAL_ERROR`` tokens. + """Errors got from the underlying ``ERROR``token. Errors can be set also explicitly. When accessing errors, they are returned along with errors got from tokens. """ - tokens = self.get_tokens(Token.ERROR, Token.FATAL_ERROR) + tokens = self.get_tokens(Token.ERROR) return tuple(t.error for t in tokens) + self._errors @errors.setter diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index eb7e29dfbff..8d8708c88c0 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1019,24 +1019,6 @@ def test_get_errors_from_tokens(self): assert_equal(Error([Token('ERROR', error=e) for e in '0123456789']).errors, tuple('0123456789')) - def test_get_fatal_errors_from_tokens(self): - assert_equal(Error([Token('FATAL ERROR', error='xxx')]).errors, - ('xxx',)) - assert_equal(Error([Token('FATAL ERROR', error='xxx'), - Token('ARGUMENT'), - Token('FATAL ERROR', error='yyy')]).errors, - ('xxx', 'yyy')) - assert_equal(Error([Token('FATAL ERROR', error=e) for e in '0123456789']).errors, - tuple('0123456789')) - - def test_get_errors_and_fatal_errors_from_tokens(self): - assert_equal(Error([Token('ERROR', error='error'), - Token('ARGUMENT'), - Token('FATAL ERROR', error='fatal error')]).errors, - ('error', 'fatal error')) - assert_equal(Error([Token('FATAL ERROR', error=e) for e in '0123456789']).errors, - tuple('0123456789')) - def test_model_error(self): model = get_model('''\ *** Invalid *** @@ -1125,12 +1107,11 @@ def test_set_errors_explicitly(self): error = Error([]) error.errors = ('explicitly set', 'errors') assert_equal(error.errors, ('explicitly set', 'errors')) - error.tokens = [Token('ERROR', error='normal error'), - Token('FATAL ERROR', error='fatal error')] - assert_equal(error.errors, ('normal error', 'fatal error', + error.tokens = [Token('ERROR', error='normal error'),] + assert_equal(error.errors, ('normal error', 'explicitly set', 'errors')) error.errors = ['errors', 'as', 'list'] - assert_equal(error.errors, ('normal error', 'fatal error', + assert_equal(error.errors, ('normal error', 'errors', 'as', 'list')) From fe5a7aef19248ff7e11b26b7e1728541777d7a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Thu, 16 Mar 2023 08:12:27 +0200 Subject: [PATCH 0436/1592] Make all invalid tables in resource files parsing errors This means that it is no longer possible to use keywords from a resource file that contains any invalid tables. It is also now possible to remove the FATAL_INVALID_HEADER token, which was used to distinguish between fatal and non-fatal invalid tables in resource files. Relates to #4689 --- atest/robot/parsing/table_names.robot | 10 ++++---- .../parsing/invalid_table_names.robot | 3 ++- .../parsing/invalid_tables_resource.robot | 2 +- src/robot/parsing/lexer/blocklexers.py | 4 +-- src/robot/parsing/lexer/context.py | 25 ++++++++----------- src/robot/parsing/lexer/statementlexers.py | 7 ------ src/robot/parsing/lexer/tokens.py | 3 +-- src/robot/parsing/model/statements.py | 7 +----- src/robot/parsing/parser/fileparser.py | 1 - src/robot/running/builder/transformers.py | 16 ++++++------ utest/parsing/test_lexer.py | 2 +- utest/parsing/test_model.py | 11 +++----- 12 files changed, 36 insertions(+), 55 deletions(-) diff --git a/atest/robot/parsing/table_names.robot b/atest/robot/parsing/table_names.robot index 6224f229644..4030f284bdf 100644 --- a/atest/robot/parsing/table_names.robot +++ b/atest/robot/parsing/table_names.robot @@ -30,14 +30,14 @@ Section Names Are Space Sensitive Invalid Tables [Setup] Run Tests ${EMPTY} parsing/invalid_table_names.robot ${tc} = Check Test Case Test in valid table + ${path} = Normalize Path ${DATADIR}/parsing/invalid_tables_resource.robot Check Log Message ${tc.kws[0].kws[0].msgs[0]} Keyword in valid table - Check Log Message ${tc.kws[1].kws[0].msgs[0]} Keyword in valid table in resource - Length Should Be ${ERRORS} 5 + Length Should Be ${ERRORS} 4 Invalid Section Error 0 invalid_table_names.robot 1 *** Error *** Invalid Section Error 1 invalid_table_names.robot 8 *** *** - Invalid Section Error 2 invalid_table_names.robot 17 *one more table cause an error - Invalid Section Error 3 invalid_tables_resource.robot 1 *** *** test and task= - Invalid Section Error 4 invalid_tables_resource.robot 10 ***Resource Error*** test and task= + Invalid Section Error 2 invalid_table_names.robot 18 *one more table cause an error + Error In File 3 parsing/invalid_table_names.robot 6 Error in file '${path}' on line 1: Unrecognized section header '*** ***'. Valid sections: 'Settings', 'Variables', 'Keywords' and 'Comments'. + *** Keywords *** Check First Log Entry diff --git a/atest/testdata/parsing/invalid_table_names.robot b/atest/testdata/parsing/invalid_table_names.robot index 3c3c2b553cf..d416477cce8 100644 --- a/atest/testdata/parsing/invalid_table_names.robot +++ b/atest/testdata/parsing/invalid_table_names.robot @@ -11,8 +11,9 @@ https://github.com/robotframework/robotframework/issues/793 *** Test Cases *** Test in valid table + [Documentation] FAIL No keyword with name 'Kw in valid table in resource' found. Keyword in valid table - Keyword in valid table in resource + Kw in valid table in resource *one more table cause an error diff --git a/atest/testdata/parsing/invalid_tables_resource.robot b/atest/testdata/parsing/invalid_tables_resource.robot index 1d126de38b5..47c8afa9deb 100644 --- a/atest/testdata/parsing/invalid_tables_resource.robot +++ b/atest/testdata/parsing/invalid_tables_resource.robot @@ -4,7 +4,7 @@ https://github.com/robotframework/robotframework/issues/793 ***Keywords*** Keyword in valid table in resource - Log Keyword in valid table in resource + Log Kw in valid table in resource Directory Should Exist ${DIR} ***Resource Error*** diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index b2e1ae70a8e..776fff2f195 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -24,7 +24,7 @@ TaskSectionHeaderLexer, KeywordSectionHeaderLexer, CommentSectionHeaderLexer, CommentLexer, ImplicitCommentLexer, - InvalidSectionHeaderLexer, FatalInvalidSectionHeaderLexer, + InvalidSectionHeaderLexer, TestOrKeywordSettingLexer, KeywordCallLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, @@ -172,7 +172,7 @@ def handles(cls, statement: list, ctx: FileContext): return statement and statement[0].value.startswith('*') def lexer_classes(self): - return (InvalidSectionHeaderLexer, FatalInvalidSectionHeaderLexer, CommentLexer) + return (InvalidSectionHeaderLexer, CommentLexer) class TestOrKeywordLexer(BlockLexer): diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 929c78cdcf9..fb4fa99146d 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -66,9 +66,9 @@ def comment_section(self, statement): return self._handles_section(statement, 'Comments') def lex_invalid_section(self, statement): - message, fatal = self._get_invalid_section_error(statement[0].value) + message = self._get_invalid_section_error(statement[0].value) statement[0].error = message - statement[0].type = Token.INVALID_HEADER if not fatal else Token.FATAL_INVALID_HEADER + statement[0].type = Token.INVALID_HEADER for token in statement[1:]: token.type = Token.COMMENT @@ -99,7 +99,7 @@ def task_section(self, statement): def _get_invalid_section_error(self, header): return (f"Unrecognized section header '{header}'. Valid sections: " f"'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' " - f"and 'Comments'."), False + f"and 'Comments'.") class ResourceFileContext(FileContext): @@ -108,13 +108,10 @@ class ResourceFileContext(FileContext): def _get_invalid_section_error(self, header): name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): - message = f"Resource file with '{name}' section is invalid." - fatal = True - else: - message = (f"Unrecognized section header '{header}'. Valid sections: " - f"'Settings', 'Variables', 'Keywords' and 'Comments'.") - fatal = False - return message, fatal + return f"Resource file with '{name}' section is invalid." + return (f"Unrecognized section header '{header}'. Valid sections: " + f"'Settings', 'Variables', 'Keywords' and 'Comments'.") + class InitFileContext(FileContext): @@ -123,11 +120,9 @@ class InitFileContext(FileContext): def _get_invalid_section_error(self, header): name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): - message = f"'{name}' section is not allowed in suite initialization file." - else: - message = (f"Unrecognized section header '{header}'. Valid sections: " - f"'Settings', 'Variables', 'Keywords' and 'Comments'.") - return message, False + return f"'{name}' section is not allowed in suite initialization file." + return (f"Unrecognized section header '{header}'. Valid sections: " + f"'Settings', 'Variables', 'Keywords' and 'Comments'.") class TestOrKeywordContext(LexingContext): diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index b2733a2c121..258f456f89a 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -112,13 +112,6 @@ def lex(self): self.ctx.lex_invalid_section(self.statement) -class FatalInvalidSectionHeaderLexer(SectionHeaderLexer): - token_type = Token.FATAL_INVALID_HEADER - - def lex(self): - self.ctx.lex_invalid_section(self.statement) - - class CommentLexer(SingleType): token_type = Token.COMMENT diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 32976588818..09d3fba6f24 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -146,8 +146,7 @@ class Token: TASK_HEADER, KEYWORD_HEADER, COMMENT_HEADER, - INVALID_HEADER, - FATAL_INVALID_HEADER + INVALID_HEADER )) ALLOW_VARIABLES = frozenset(( NAME, diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index fd3a21111f2..f221b80710d 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -223,7 +223,7 @@ class SectionHeader(Statement): handles_types = (Token.SETTING_HEADER, Token.VARIABLE_HEADER, Token.TESTCASE_HEADER, Token.TASK_HEADER, Token.KEYWORD_HEADER, Token.COMMENT_HEADER, - Token.INVALID_HEADER, Token.FATAL_INVALID_HEADER) + Token.INVALID_HEADER) @classmethod def from_params(cls, type, name=None, eol=EOL): @@ -248,11 +248,6 @@ def name(self): token = self.get_token(*self.handles_types) return normalize_whitespace(token.value).strip('* ') - def validate(self, context: 'ValidationContext'): - tokens = self.get_tokens(Token.INVALID_HEADER, Token.FATAL_INVALID_HEADER) - for t in tokens: - self.errors += (t.error, ) - @Statement.register class LibraryImport(Statement): diff --git a/src/robot/parsing/parser/fileparser.py b/src/robot/parsing/parser/fileparser.py index b9e6f7a49f5..f8973149048 100644 --- a/src/robot/parsing/parser/fileparser.py +++ b/src/robot/parsing/parser/fileparser.py @@ -50,7 +50,6 @@ def parse(self, statement): Token.KEYWORD_HEADER: KeywordSectionParser, Token.COMMENT_HEADER: CommentSectionParser, Token.INVALID_HEADER: InvalidSectionParser, - Token.FATAL_INVALID_HEADER: InvalidSectionParser, Token.CONFIG: ImplicitCommentSectionParser, Token.COMMENT: ImplicitCommentSectionParser, Token.ERROR: ImplicitCommentSectionParser, diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 3a27337053a..2151b59f548 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -132,7 +132,7 @@ def __init__(self, resource: ResourceFile): self.defaults = Defaults() def build(self, model: File): - ErrorReporter(model.source).visit(model) + ErrorReporter(model.source, raise_on_invalid_header=True).visit(model) self.visit(model) def visit_Documentation(self, node): @@ -616,8 +616,9 @@ def deprecate_tags_starting_with_hyphen(node, source): class ErrorReporter(NodeVisitor): - def __init__(self, source): + def __init__(self, source, raise_on_invalid_header=False): self.source = source + self.raise_on_invalid_header = raise_on_invalid_header def visit_TestCase(self, node): pass @@ -626,11 +627,12 @@ def visit_Keyword(self, node): pass def visit_SectionHeader(self, node): - fatal = node.get_token(Token.FATAL_INVALID_HEADER) - if fatal: - raise DataError(self._format_message(fatal)) - if node.errors: - LOGGER.error(self._format_message(node.get_token(Token.INVALID_HEADER))) + token = node.get_token(Token.INVALID_HEADER) + if token: + if self.raise_on_invalid_header: + raise DataError(self._format_message(token)) + else: + LOGGER.error(self._format_message(token)) def visit_Error(self, node): for error in node.get_tokens(Token.ERROR): diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 6f92e7ef7c3..c64e5ea36c1 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -700,7 +700,7 @@ def test_case_section_causes_error_in_init_file(self): def test_case_section_causes_fatal_error_in_resource_file(self): assert_tokens('*** Test Cases ***', [ - (T.FATAL_INVALID_HEADER, '*** Test Cases ***', 1, 0, + (T.INVALID_HEADER, '*** Test Cases ***', 1, 0, "Resource file with 'Test Cases' section is invalid."), (T.EOS, '', 1, 18), ], get_resource_tokens, data_only=True) diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 8d8708c88c0..9e9bb487ae2 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1034,8 +1034,7 @@ def test_model_error(self): expected = File([ InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Invalid ***', 1, 0, inv_header)], - (inv_header,) + [Token('INVALID HEADER', '*** Invalid ***', 1, 0, inv_header)] ) ), @@ -1059,7 +1058,7 @@ def test_model_error_with_fatal_error(self): expected = File([ InvalidSection( header=SectionHeader( - [Token('FATAL INVALID HEADER', '*** Test Cases ***', 1, 0, inv_testcases)], (inv_testcases,)) + [Token('INVALID HEADER', '*** Test Cases ***', 1, 0, inv_testcases)]) ) ]) assert_model(model, expected) @@ -1081,8 +1080,7 @@ def test_model_error_with_error_and_fatal_error(self): expected = File([ InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Invalid ***', 1, 0, inv_header)], - (inv_header,) + [Token('INVALID HEADER', '*** Invalid ***', 1, 0, inv_header)] ) ), SettingSection( @@ -1096,8 +1094,7 @@ def test_model_error_with_error_and_fatal_error(self): ), InvalidSection( header=SectionHeader( - [Token('FATAL INVALID HEADER', '*** Test Cases ***', 5, 0, inv_testcases)], - (inv_testcases,) + [Token('INVALID HEADER', '*** Test Cases ***', 5, 0, inv_testcases)] ) ), ]) From 302e3e03334d3f9c60df960d93f1a702b4c8824f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 15:58:17 +0200 Subject: [PATCH 0437/1592] f-strings --- src/robot/running/usererrorhandler.py | 8 ++++---- src/robot/running/userkeywordrunner.py | 22 +++++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/robot/running/usererrorhandler.py b/src/robot/running/usererrorhandler.py index e8a80f8c552..6f6be062cf9 100644 --- a/src/robot/running/usererrorhandler.py +++ b/src/robot/running/usererrorhandler.py @@ -22,10 +22,10 @@ class UserErrorHandler: - """Created if creating handlers fail -- running raises DataError. + """Created if creating handlers fail. Running it raises DataError. The idea is not to raise DataError at processing time and prevent all - tests in affected test case file from executing. Instead UserErrorHandler + tests in affected test case file from executing. Instead, UserErrorHandler is created and if it is ever run DataError is raised then. """ supports_embedded_arguments = False @@ -49,11 +49,11 @@ def __init__(self, error, name, libname=None, source=None, lineno=None): @property def longname(self): - return '%s.%s' % (self.libname, self.name) if self.libname else self.name + return f'{self.libname}.{self.name}' if self.libname else self.name @property def doc(self): - return '*Creating keyword failed:* %s' % self.error + return f'*Creating keyword failed:* {self.error}' @property def shortdoc(self): diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 260665899ae..f3df055d52e 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -126,11 +126,11 @@ def _set_variables(self, positional, kwargs, variables): for name, value in chain(zip(spec.positional, args), kwonly): if isinstance(value, DefaultValue): value = value.resolve(variables) - variables['${%s}' % name] = value + variables[f'${{{name}}}'] = value if spec.var_positional: - variables['@{%s}' % spec.var_positional] = varargs + variables[f'@{{{spec.var_positional}}}'] = varargs if spec.var_named: - variables['&{%s}' % spec.var_named] = DotDict(kwargs) + variables[f'&{{{spec.var_named}}}'] = DotDict(kwargs) def _split_args_and_varargs(self, args): if not self.arguments.var_positional: @@ -151,16 +151,16 @@ def _trace_log_args_message(self, variables): self._format_args_for_trace_logging(), variables) def _format_args_for_trace_logging(self): - args = ['${%s}' % arg for arg in self.arguments.positional] + args = [f'${{{arg}}}' for arg in self.arguments.positional] if self.arguments.var_positional: - args.append('@{%s}' % self.arguments.var_positional) + args.append(f'@{{{self.arguments.var_positional}}}') if self.arguments.var_named: - args.append('&{%s}' % self.arguments.var_named) + args.append(f'&{{{self.arguments.var_named}}}') return args def _format_trace_log_args_message(self, args, variables): - args = ['%s=%s' % (name, prepr(variables[name])) for name in args] - return 'Arguments: [ %s ]' % ' | '.join(args) + args = ' | '.join(f'{name}={prepr(variables[name])}' for name in args) + return f'Arguments: [ {args} ]' def _execute(self, context): handler = self._handler @@ -195,8 +195,8 @@ def _get_return_value(self, variables, return_): try: ret = variables.replace_list(ret) except DataError as err: - raise VariableError('Replacing variables from keyword return ' - 'value failed: %s' % err.message) + raise VariableError(f'Replacing variables from keyword return ' + f'value failed: {err}') if len(ret) != 1 or contains_list_var: return ret return ret[0] @@ -257,7 +257,7 @@ def _resolve_arguments(self, args, variables=None): def _set_arguments(self, args, context): variables = context.variables for name, value in self.embedded_args: - variables['${%s}' % name] = value + variables[f'${{{name}}}'] = value super()._set_arguments(args, context) context.output.trace(lambda: self._trace_log_args_message(variables), write_if_flat=False) From 6cfd954fd490268aaf89946b9487b10515b7ca3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 17:27:29 +0200 Subject: [PATCH 0438/1592] Avoid accessing setup/teardown attributes during execution. Accessing these attributes creates Keyword objects representing setup/teardown. They use some memory so better avoid that. --- src/robot/running/suiterunner.py | 24 ++++++++++++++---------- src/robot/running/userkeyword.py | 2 +- src/robot/running/userkeywordrunner.py | 15 ++++++++------- utest/running/test_userhandlers.py | 2 +- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index 2d145a25581..1652dfc712c 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -86,7 +86,7 @@ def start_suite(self, suite): test_count=suite.test_count)) self._output.register_error_listener(self._suite_status.error_occurred) if self._any_test_run(suite): - self._run_setup(suite.setup, self._suite_status) + self._run_setup(suite, self._suite_status) def _any_test_run(self, suite): skipped_tags = self._skipped_tags @@ -108,7 +108,7 @@ def end_suite(self, suite): self._context.report_suite_status(self._suite.status, self._suite.full_message) with self._context.suite_teardown(): - failure = self._run_teardown(suite.teardown, self._suite_status) + failure = self._run_teardown(suite, self._suite_status) if failure: if failure.skip: self._suite.suite_teardown_skipped(str(failure)) @@ -156,7 +156,7 @@ def visit_test(self, test): status.test_skipped( test_or_task("{Test} skipped using '--skip' command line option.", settings.rpa)) - self._run_setup(test.setup, status, result) + self._run_setup(test, status, result) if status.passed: try: BodyRunner(self._context, templated=bool(test.template)).run(test.body) @@ -175,7 +175,7 @@ def visit_test(self, test): result.status = status.status result.message = status.message or result.message with self._context.test_teardown(result): - self._run_teardown(test.teardown, status, result) + self._run_teardown(test, status, result) if status.passed and result.timeout and result.timeout.timed_out(): status.test_failed(result.timeout.get_message()) result.message = status.message @@ -199,18 +199,24 @@ def _get_timeout(self, test): return None return TestTimeout(test.timeout, self._variables, rpa=test.parent.rpa) - def _run_setup(self, setup, status, result=None): + def _run_setup(self, item, status, result=None): if status.passed: - exception = self._run_setup_or_teardown(setup) + if item.has_setup: + exception = self._run_setup_or_teardown(item.setup) + else: + exception = None status.setup_executed(exception) if result and isinstance(exception, PassExecution): result.message = exception.message elif status.parent and status.parent.skipped: status.skipped = True - def _run_teardown(self, teardown, status, result=None): + def _run_teardown(self, item, status, result=None): if status.teardown_allowed: - exception = self._run_setup_or_teardown(teardown) + if item.has_teardown: + exception = self._run_setup_or_teardown(item.teardown) + else: + exception = None status.teardown_executed(exception) failed = exception and not isinstance(exception, PassExecution) if result and exception: @@ -223,8 +229,6 @@ def _run_teardown(self, teardown, status, result=None): return exception if failed else None def _run_setup_or_teardown(self, data): - if not data: - return None try: name = self._variables.replace_string(data.name) except DataError as err: diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index e2ab7aedfe5..2c98653389f 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -79,7 +79,7 @@ def __init__(self, keyword, libname): self.timeout = keyword.timeout self.body = keyword.body self.return_value = tuple(keyword.return_) - self.teardown = keyword.teardown + self.teardown = keyword.teardown if keyword.has_teardown else None @property def longname(self): diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index f3df055d52e..911b60f5340 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -181,8 +181,11 @@ def _execute(self, context): error.continue_on_failure = False except ExecutionFailed as exception: error = exception - with context.keyword_teardown(error): - td_error = self._run_teardown(context) + if handler.teardown: + with context.keyword_teardown(error): + td_error = self._run_teardown(handler.teardown, context) + else: + td_error = None if error or td_error: error = UserKeywordExecutionFailed(error, td_error) return error or pass_, return_ @@ -201,11 +204,9 @@ def _get_return_value(self, variables, return_): return ret return ret[0] - def _run_teardown(self, context): - if not self._handler.teardown: - return None + def _run_teardown(self, teardown, context): try: - name = context.variables.replace_string(self._handler.teardown.name) + name = context.variables.replace_string(teardown.name) except DataError as err: if context.dry_run: return None @@ -213,7 +214,7 @@ def _run_teardown(self, context): if name.upper() in ('', 'NONE'): return None try: - KeywordRunner(context).run(self._handler.teardown, name) + KeywordRunner(context).run(teardown, name) except PassExecution: return None except ExecutionStatus as err: diff --git a/utest/running/test_userhandlers.py b/utest/running/test_userhandlers.py index a32167b5535..bc864c3e4fc 100644 --- a/utest/running/test_userhandlers.py +++ b/utest/running/test_userhandlers.py @@ -42,7 +42,7 @@ def __init__(self, name, args=[]): self.timeout = Fake() self.return_ = Fake() self.tags = () - self.teardown = None + self.has_teardown = False def EAT(name, args=[]): From 3fa08d22352381fefd2ae5bf16d347a544cfeb77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 15 Mar 2023 22:47:30 +0200 Subject: [PATCH 0439/1592] API doc enhancements --- src/robot/model/modelobject.py | 50 ++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index dea2fa3c857..71d9b0a6df4 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -41,17 +41,20 @@ def from_dict(cls, data): def from_json(cls, source): """Create this object based on JSON data. - The data is given as the ``source`` parameter. It can be + The data is given as the ``source`` parameter. It can be: + - a string (or bytes) containing the data directly, - an open file object where to read the data, or - - a path (string or ``pathlib.Path``) to a UTF-8 encoded file to read. + - a path (string or `pathlib.Path`__) to a UTF-8 encoded file to read. + + __ https://docs.python.org/3/library/pathlib.html The JSON data is first converted to a Python dictionary and the object created using the :meth:`from_dict` method. - Notice that ``source`` is considered to be JSON data if it is a string - and contains ``{``. If you need to use ``{`` in a file path, pass it in - as a ``pathlib.Path`` instance. + Notice that the ``source`` is considered to be JSON data if it is + a string and contains ``{``. If you need to use ``{`` in a file system + path, pass it in as a ``pathlib.Path`` instance. """ try: data = JsonLoader().load(source) @@ -74,14 +77,17 @@ def to_json(self, file=None, *, ensure_ascii=False, indent=0, :meth:`to_dict` method and then the dictionary is converted to JSON. The ``file`` parameter controls what to do with the resulting JSON data. - It can be + It can be: + - ``None`` (default) to return the data as a string, - an open file object where to write the data, or - a path to a file where to write the data using UTF-8 encoding. JSON formatting can be configured using optional parameters that - are passed directly to the underlying ``json`` module. Notice that + are passed directly to the underlying json__ module. Notice that the defaults differ from what ``json`` uses. + + __ https://docs.python.org/3/library/json.html """ return JsonDumper(ensure_ascii=ensure_ascii, indent=indent, separators=separators).dump(self.to_dict(), file) @@ -100,20 +106,22 @@ def config(self, **attributes): except AttributeError as err: # Ignore error setting attribute if the object already has it. # Avoids problems with `to/from_dict` roundtrip with body items - # having unsettable `type` attribute that is needed in dict data. + # having un-settable `type` attribute that is needed in dict data. if getattr(self, name, object()) != attributes[name]: raise AttributeError(f"Setting attribute '{name}' failed: {err}") return self def copy(self, **attributes): - """Return shallow copy of this object. + """Return a shallow copy of this object. + + :param attributes: Attributes to be set to the returned copy. + For example, ``obj.copy(name='New name')``. - :param attributes: Attributes to be set for the returned copy - automatically. For example, ``test.copy(name='New name')``. + See also :meth:`deepcopy`. The difference between ``copy`` and + ``deepcopy`` is the same as with the methods having same names in + the copy__ module. - See also :meth:`deepcopy`. The difference between these two is the same - as with the standard ``copy.copy`` and ``copy.deepcopy`` functions - that these methods also use internally. + __ https://docs.python.org/3/library/copy.html """ copied = copy.copy(self) for name in attributes: @@ -121,14 +129,16 @@ def copy(self, **attributes): return copied def deepcopy(self, **attributes): - """Return deep copy of this object. + """Return a deep copy of this object. + + :param attributes: Attributes to be set to the returned copy. + For example, ``obj.deepcopy(name='New name')``. - :param attributes: Attributes to be set for the returned copy - automatically. For example, ``test.deepcopy(name='New name')``. + See also :meth:`copy`. The difference between ``deepcopy`` and + ``copy`` is the same as with the methods having same names in + the copy__ module. - See also :meth:`copy`. The difference between these two is the same - as with the standard ``copy.copy`` and ``copy.deepcopy`` functions - that these methods also use internally. + __ https://docs.python.org/3/library/copy.html """ copied = copy.deepcopy(self) for name in attributes: From 650b9d3748279985f51b81d8488e91f6920b1f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 17 Mar 2023 01:14:03 +0200 Subject: [PATCH 0440/1592] API doc enhancements --- src/robot/model/control.py | 15 +++++++++++++++ src/robot/parsing/model/statements.py | 9 ++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 1d892b1cc37..bada85f2bb2 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -21,6 +21,11 @@ @Body.register class For(BodyItem): + """Represents ``FOR`` loops. + + :attr:`flavor` specifies the flavor, and it can be ``IN``, ``IN RANGE``, + ``IN ENUMERATE`` or ``IN ZIP``. + """ type = BodyItem.FOR body_class = Body repr_args = ('variables', 'flavor', 'values', 'start', 'mode', 'fill') @@ -81,6 +86,7 @@ def to_dict(self): @Body.register class While(BodyItem): + """Represents ``WHILE`` loops.""" type = BodyItem.WHILE body_class = Body repr_args = ('condition', 'limit') @@ -121,6 +127,7 @@ def to_dict(self): class IfBranch(BodyItem): + """Represents individual ``IF``, ``ELSE IF`` or ``ELSE`` branch.""" body_class = Body repr_args = ('type', 'condition') __slots__ = ['type', 'condition'] @@ -192,6 +199,7 @@ def to_dict(self): class TryBranch(BodyItem): + """Represents individual ``TRY``, ``EXCEPT``, ``ELSE`` or ``FINALLY`` branch.""" body_class = Body repr_args = ('type', 'patterns', 'pattern_type', 'variable') __slots__ = ['type', 'patterns', 'pattern_type', 'variable'] @@ -301,6 +309,7 @@ def to_dict(self): @Body.register class Return(BodyItem): + """Represents ``RETURN``.""" type = BodyItem.RETURN repr_args = ('values',) __slots__ = ['values'] @@ -318,6 +327,7 @@ def to_dict(self): @Body.register class Continue(BodyItem): + """Represents ``CONTINUE``.""" type = BodyItem.CONTINUE __slots__ = [] @@ -333,6 +343,7 @@ def to_dict(self): @Body.register class Break(BodyItem): + """Represents ``BREAK``.""" type = BodyItem.BREAK __slots__ = [] @@ -348,6 +359,10 @@ def to_dict(self): @Body.register class Error(BodyItem): + """Represents syntax error in data. + + For example, an invalid setting like ``[Setpu]`` or ``END`` in wrong place. + """ type = BodyItem.ERROR __slots__ = ['values'] diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index f221b80710d..206ba4cb52b 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -88,6 +88,7 @@ def from_params(cls, *args, **kwargs): settings header or test/keyword. Most implementations support following general properties: + - ``separator`` whitespace inserted between each token. Default is four spaces. - ``indent`` whitespace inserted before first token. Default is four spaces. - ``eol`` end of line sign. Default is ``'\\n'``. @@ -721,9 +722,11 @@ class Return(MultiValue): """Represents the deprecated ``[Return]`` setting. In addition to the ``[Return]`` setting itself, also the ``Return`` node - in the parsing model is deprecated. ``ReturnSetting`` (new in RF 6.1) should - be used instead. ``ReturnStatement`` will be renamed to ``Return`` in - the future, most likely already in RF 7.0. + in the parsing model is deprecated and :class:`ReturnSetting` (new in + Robot Framework 6.1) should be used instead. :class:`ReturnStatement` will + be renamed to ``Return`` in Robot Framework 7.0. + + Eventually ``[Return]`` and ``ReturnSetting`` will be removed altogether. """ type = Token.RETURN From d60dda88115c5ecc9205c4ac08b8e32f7c380592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 17 Mar 2023 01:28:27 +0200 Subject: [PATCH 0441/1592] Fix Error.to_dict/json. Related to #4683. Also expose running.Error properly and add some more unit tests for Error in general. --- src/robot/model/control.py | 4 ++-- src/robot/running/__init__.py | 4 ++-- src/robot/running/model.py | 2 +- utest/result/test_resultmodel.py | 23 ++++++++++++++--------- utest/running/test_run_model.py | 12 ++++++++---- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index bada85f2bb2..00a424b4e37 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -366,7 +366,7 @@ class Error(BodyItem): type = BodyItem.ERROR __slots__ = ['values'] - def __init__(self, values, parent=None): + def __init__(self, values=(), parent=None): self.values = values self.parent = parent @@ -374,4 +374,4 @@ def visit(self, visitor): visitor.visit_error(self) def to_dict(self): - return {'type': self.type, 'data': self.data} + return {'type': self.type, 'values': list(self.values)} diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index e0580a16ecf..23d35f657af 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -104,8 +104,8 @@ from .arguments import ArgInfo, ArgumentSpec, TypeConverter, TypeInfo from .builder import ResourceFileBuilder, TestSuiteBuilder from .context import EXECUTION_CONTEXTS -from .model import (Break, Continue, For, If, IfBranch, Keyword, Return, TestCase, - TestSuite, Try, TryBranch, While) +from .model import (Break, Continue, Error, For, If, IfBranch, Keyword, Return, + TestCase, TestSuite, Try, TryBranch, While) from .runkwregister import RUN_KW_REGISTER from .testlibraries import TestLibrary from .usererrorhandler import UserErrorHandler diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 9c58bae2eb6..c57542bbbe2 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -327,7 +327,7 @@ def to_dict(self): class Error(model.Error): __slots__ = ['lineno', 'error'] - def __init__(self, values, parent=None, lineno=None, error=None): + def __init__(self, values=(), parent=None, lineno=None, error=None): super().__init__(values, parent) self.lineno = lineno self.error = error diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 8ecbf37f41e..6fa9d9c777a 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -2,7 +2,7 @@ import warnings from robot.model import Tags -from robot.result import (Break, Continue, For, If, IfBranch, Keyword, Message, +from robot.result import (Break, Continue, Error, For, If, IfBranch, Keyword, Message, Return, TestCase, TestSuite, Try, While) from robot.utils.asserts import (assert_equal, assert_false, assert_raises, assert_raises_with_msg, assert_true) @@ -166,16 +166,13 @@ def test_while(self): self._verify(While()) self._verify(While().body.create_iteration()) - def test_while_name(self): - assert_equal(While().name, '') - assert_equal(While('$x > 0').name, '$x > 0') - assert_equal(While('True', '1 minute').name, 'True | limit=1 minute') - assert_equal(While(limit='1 minute').name, 'limit=1 minute') - def test_break_continue_return(self): for cls in Break, Continue, Return: self._verify(cls()) + def test_error(self): + self._verify(Error()) + def test_message(self): self._verify(Message()) @@ -204,8 +201,10 @@ def test_status_propertys_with_keyword(self): self._verify_status_propertys(Keyword()) def test_status_propertys_with_control_structures(self): - for obj in (Break(), Continue(), Return(), For(), For().body.create_iteration(), - If(), If().body.create_branch(), Try(), Try().body.create_branch(), + for obj in (Break(), Continue(), Return(), Error(), + For(), For().body.create_iteration(), + If(), If().body.create_branch(), + Try(), Try().body.create_branch(), While(), While().body.create_iteration()): self._verify_status_propertys(obj) @@ -325,6 +324,12 @@ def test_if_parents(self): kw = branch.body.create_keyword() assert_equal(kw.parent, branch) + def test_while_name(self): + assert_equal(While().name, '') + assert_equal(While('$x > 0').name, '$x > 0') + assert_equal(While('True', '1 minute').name, 'True | limit=1 minute') + assert_equal(While(limit='1 minute').name, 'limit=1 minute') + class TestBody(unittest.TestCase): diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index e7ec2c2026a..85ab973b57c 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -7,9 +7,9 @@ from robot import api, model from robot.model.modelobject import ModelObject -from robot.running.model import (Break, Continue, For, If, IfBranch, Keyword, - ResourceFile, Return, TestCase, TestSuite, Try, - TryBranch, UserKeyword, While) +from robot.running import (Break, Continue, Error, For, If, IfBranch, Keyword, + Return, TestCase, TestSuite, Try, TryBranch, While) +from robot.running.model import ResourceFile, UserKeyword from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, assert_raises, assert_true) @@ -246,7 +246,7 @@ def _assert_lineno_and_source(self, item, lineno): assert_equal(item.lineno, lineno) -class TestToFromDict(unittest.TestCase): +class TestToFromDictAndJson(unittest.TestCase): def test_keyword(self): self._verify(Keyword(), name='') @@ -330,6 +330,10 @@ def test_return_continue_break(self): self._verify(Break(lineno=11, error='E'), type='BREAK', lineno=11, error='E') + def test_error(self): + self._verify(Error(), type='ERROR', values=[]) + self._verify(Error(('bad', 'things')), type='ERROR', values=['bad', 'things']) + def test_test(self): self._verify(TestCase(), name='', body=[]) self._verify(TestCase('N', 'D', 'T', '1s', lineno=12), From 231635c80099211b34dac91807c1b1c86b7b5797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 17 Mar 2023 01:43:16 +0200 Subject: [PATCH 0442/1592] Initial release notes for 6.1a1 --- doc/releasenotes/rf-6.1a1.rst | 847 ++++++++++++++++++++++++++++++++++ 1 file changed, 847 insertions(+) create mode 100644 doc/releasenotes/rf-6.1a1.rst diff --git a/doc/releasenotes/rf-6.1a1.rst b/doc/releasenotes/rf-6.1a1.rst new file mode 100644 index 00000000000..75d88221a00 --- /dev/null +++ b/doc/releasenotes/rf-6.1a1.rst @@ -0,0 +1,847 @@ +=========================== +Robot Framework 6.1 alpha 1 +=========================== + +.. default-role:: code + +`Robot Framework`_ 6.1 is a new feature release with support for converting +Robot Framework data to JSON and back as well as various other interesting +new features both for normal users and for external tool developers. +This first alpha release is especially +targeted for those interested to test JSON serialization. It also contains +all planned `backwards incompatible changes`_ and `deprecated features`_, +so everyone interested to make sure their tests, tasks or tools are compatible, +should test it in their environment. + +All issues targeted for Robot Framework 6.1 can be found +from the `issue tracker milestone`_. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.1a1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.1 alpha 1 will be released on Friday March 17, 2023. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +JSON data format +---------------- + +The biggest new feature in Robot Framework 6.1 is the possibility to convert +test/task data to JSON and back (`#3902`_). This functionality has three main +use cases: + +- Transferring suites between processes and machines. A suite can be converted + to JSON in one machine and recreated somewhere else. +- Possibility to save a suite, possible a nested suite, constructed from data + on the file system into a single file that is faster to parse. +- Alternative data format for external tools generating tests or tasks. + +This feature is designed more for tool developers than for regular Robot Framework +users and we expect new interesting tools to emerge in the future. The feature +feature is not finalized yet, but the following things already work: + +1. You can serialize a suite structure into JSON by using `TestSuite.to_json`__ + method. When used without arguments, it returns JSON data as a string, but + it also accepts a path or an open file where to write JSON data along with + configuration options related to JSON formatting: + + .. sourcecode:: python + + from robot.api import TestSuite + + suite = TestSuite.from_file_system('path/to/tests') + suite.to_json('tests.rbt') + +2. You can create a suite based on JSON data using `TestSuite.from_json`__. + It works both with JSON strings and paths to JSON files: + + .. sourcecode:: python + + from robot.api import TestSuite + + suite = TestSuite.from_json('tests.rbt') + +3. When using `robot` normally, it parses files with the `.rbt` extension + automatically. This includes running individual JSON files like `robot tests.rbt` + and running directories containing `.rbt` files. + +We recommend everyone interested in this new API to test it and give us feedback. +It is a lot easier for us to make change before the final release is out and we +need to take backwards compatibility into account. If you encounter bugs or have +enhancement ideas, you can comment the issue or start discussion on the `#devel` +channel on our Slack_. + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_json +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_json + +User keywords with both embedded and normal arguments +----------------------------------------------------- + +User keywords can nowadays mix embedded arguments and normal arguments (`#4234`_). +For example, this kind of usage is possible: + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Number of horses is 2 + Number of dogs is 3 + + *** Keywords *** + Number of ${animals} is + [Arguments] ${count} + Log to console There are ${count} ${animals}. + +This only works with user keywords at least for now. If there is interest, +the support can be extended to library keywords in future releases. + +Possibility to flatten keyword structures during execution +---------------------------------------------------------- + +With nested keyword structures, especially with recursive keyword calls and with +WHILE and FOR loops, the log file can get hard do understand with many different +nesting levels. Such nested structures also increase the size of the output.xml +file. For example, even a simple keyword like: + +.. sourcecode:: robotframework + + *** Keywords *** + Keyword + Log Robot + Log Framework + +creates this much content in output.xml: + +.. sourcecode:: xml + + <kw name="Keyword"> + <kw name="Log" library="BuiltIn"> + <arg>Robot</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.663"/> + </kw> + <kw name="Log" library="BuiltIn"> + <arg>Framework</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + +We already have the `--flattenkeywords` option for "flattening" such structures +and it works great. When a keyword is flattened, its child keywords and control +structures are removed otherwise, but all their messages (`<msg>` elements) are +preserved. Using `--flattenkeywords` does not affect output.xml generated during +execution, but flattening happens when output.xml files are parsed and can save +huge amounts of memory. When `--flattenkeywords` is used with Rebot, it is +possible to create a new flattened output.xml. For example, the above structure +is converted into this if `Keyword` is flattened: + +.. sourcecode:: xml + + <kw name="Keyword"> + <doc>_*Content flattened.*_</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + +Starting from Robot Framework 6.1, this kind of flattening can be done also +during execution and without using command line options. The only thing needed +is using the new keyword tag `robot:flatten` (`#4584`_) and Robot handles +flattening automatically. For example, if the earlier `Keyword` is changed +to: + +.. sourcecode:: robotframework + + *** Keywords *** + Keyword + [Tags] robot:flatten + Log Robot + Log Framework + +the result in output.xml will be this: + +.. sourcecode:: xml + + <kw name="Keyword"> + <tag>robot:flatten</tag> + <msg timestamp="20230317 00:54:34.772" level="INFO">Robot</msg> + <msg timestamp="20230317 00:54:34.772" level="INFO">Framework</msg> + <status status="PASS" starttime="20230317 00:54:34.771" endtime="20230317 00:54:34.772"/> + </kw> + +A benefit of using `robot:flatten` instead of `--flattenkeywords` is that +it used already during execution making the resulting output.xml file smaller +without using Rebot separately afterwards. + +Custom argument converters can access library +--------------------------------------------- + +Support for custom argument converters was added in Robot Framework 5.0 +(`#4088`__) and they have turned out to be really useful. This functionality +is now enhanced so that converters can easily get an access to the +library containing the keyword that is used and can thus do conversion +based on the library state (`#4510`_). This can be done simply by creating +a converter that accepts two values. The first value is the value used in +the data, exactly as earlier, and the second is the library instance or module: + +.. sourcecode:: python + + def converter(value, library): + ... + +Converters accepting only one argument keep working as earlier and there are no +plans to require changing them to accept two values. + +__ https://github.com/robotframework/robotframework/issues/4088 + +JSON variable file support +-------------------------- + +It has been possible to create variable files using YAML in addition to Python +for long time, and nowadays also JSON variable files are supported (`#4532`_). +For example, a JSON file containing: + +.. sourcecode:: json + + { + "STRING": "Hello, world!", + "INTEGER": 42 + } + +could be used like this: + +.. sourcecode:: robotframework + + *** Settings *** + Variables example.json + + *** Test Cases *** + Example + Should Be Equal ${STRING} Hello, world! + Should Be Equal ${INTEGER} ${42} + +New pseudo log level `CONSOLE` +------------------------------ + +There are often needs to log something to the console while tests or tasks +are running. Some keywords support it out-of-the-box and there is also +separate `Log To Console` keyword for that purpose. + +The new `CONSOLE` pseudo log level (`#4536`_) adds this support to *any* +keyword that accepts a log level such as `Log List` in Collections and +`Page Should Contain` in SeleniumLibrary. When this level is used, the message +is logged both to the console and on `INFO` level to the log file. + +Configuring virtual root suite when running multiple suites +----------------------------------------------------------- + +When execution multiple suites like `robot first.robot second.robot`, +Robot Framework creates a virtual root suite containing the executed +suites as child suites. Earlier this virtual suite could be +configured only by using command line options like `--name`, but now +it is possible to use normal suite initialization files (`__init__.robot`) +for that purpose (`#4015`_). If an initialization file is included +in the call like `robot __init__.robot first.robot second.robot`, the root +suite is configured based on data it contains. + +The most important feature this enhancement allows is specifying suite +setup and teardown to the root suite. Earlier that was not possible at all. + +`FOR IN ZIP` loop behavior if lists lengths differ can be configured +-------------------------------------------------------------------- + +Robot Framework's `FOR IN ZIP` loop behaves like Python's zip__ function so +that if lists lengths are not the same, items from longer ones ignored. +For example, the following loop would be executed only twice: + +.. sourcecode:: robotframework + + *** Variables *** + @{ANIMALS} dog cat horse cow elephant + @{ELÄIMET} koira kissa + + *** Test Cases *** + Example + FOR ${en} ${fi} IN ZIP ${ANIMALS} ${ELÄIMET} + Log ${en} is ${fi} in Finnish + END + +This behavior can cause problems when iterating over items received from +the automated system. For example, the following test would pass regardless +how many things `Get something` returns as long as the returned items match +the expected values. The example succeeds if `Get something` returns ten items +if three first ones match. What's even worse, it succeeds even if `Get something` +returns nothing. + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Validate something expected 1 expected 2 expected 3 + + *** Keywords **** + Validate something + [Arguments] @{expected} + @{actual} = Get something + FOR ${act} ${exp} IN ZIP ${actual} ${expected} + Validate one thing ${act} ${exp} + END + +This situation is pretty bad because it can cause false positives where +automation succeeds but nothing is actually done. Python itself has this +same issue, and Python 3.10 added new optional `strict` argument to `zip` +(`PEP 681`__). In addition to that, Python has for long time had a separate +`zip_longest`__ function that loops over all values possibly filling-in +values to shorter lists. + +To support all the same use cases as Python, Robot Framework's `FOR IN ZIP` +loops now have an optional `mode` configuration option that accepts three +values (`#4682`_): + +- `STRICT`: Lists must have equal lengths. If not, execution fails. This is + the same as using `strict=True` with Python's `zip` function. +- `SHORTEST`: Items in longer lists are ignored. Infinitely long lists are supported + in this mode as long as one of the lists is exhausted. This is the current + default behavior. +- `LONGEST`: The longest list defines how many iterations there are. Missing + values in shorter lists are filled-in with value specified using the `fill` + option or `None` if it is not used. This is the same as using Python's + `zip_longest` function except that it has `fillvalue` argument instead of + `fill`. + +All these modes are illustrated by the following examples: + +.. sourcecode:: robotframework + + *** Variables *** + @{CHARACTERS} a b c d f + @{NUMBERS} 1 2 3 + + *** Test Cases *** + STRICT mode + [Documentation] This loop fails due to lists lengths being different. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=STRICT + Log ${c}: ${n} + END + + SHORTEST mode + [Documentation] This loop executes three times. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=SHORTEST + Log ${c}: ${n} + END + + LONGEST mode + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `None`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST + Log ${c}: ${n} + END + + LONGEST mode with custom fill value + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `-`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST fill=- + Log ${c}: ${n} + END + +This enhancement makes it easy to activate strict validation and avoid +false positives. The default behavior is still problematic, though, and +the plan is to change it to `STRICT` in `Robot Framework 7.0`__. +Those who want to keep using the `SHORTEST` mode need to enable it explicitly + +__ https://docs.python.org/3/library/functions.html#zip +__ https://peps.python.org/pep-0618/ +__ https://docs.python.org/3/library/itertools.html#itertools.zip_longest +__ https://github.com/robotframework/robotframework/issues/4686 + +Backwards incompatible changes +============================== + +We try to avoid backwards incompatible changes in general and especially in +non-major version. They cannot always be avoided, though, and there are some +features and fixes in this release that are not fully backwards compatible. +These changes *should not* cause problems in normal usage, but especially +tools using Robot Framework may nevertheless be affected. + +Changes to output.xml +--------------------- + +Syntax errors such as invalid settings and `END` or `ELSE` in wrong place +are nowadays reported better (`#4683`_). Part of that change was storing +invalid constructs in output.xml as `<error>` elements. Tools processing +output.xml files so that they go through all elements need to take them into +account, but tools just querying information using xpath expression or +otherwise should not be affected. + +Another change is that with `FOR IN ENUMERATE` loops the `<for>` element +may get `start` attribute (`#4684`_) and with `FOR IN ZIP` loops it may get +`mode` and `fill` attributes (`#4682`_). This affects tools processing +all possible attributes, but such tools ought to be very rare. + +Changes to `TestSuite` model structure +-------------------------------------- + +The aforementioned enhancements for handling invalid syntax better (`#4683`_) +required changes also to the TestSuite__ model structure. Syntax errors are +nowadays represented as Error__ objects and they can appear in the `body` of +TestCase__, Keyword__, and other such model objects. Tools interacting with +the `TestSuite` structure should in general take `Error` objects into account, +but tools using the `visitor API`__ should nevertheless not be affected. + +Another related change is that `doc`, `tags`, `timeout` and `teardown` attributes +were removed from the `robot.running.Keyword`__ object (`#4589`_). They were +left there accidentally and were not used for anything by Robot Framework. +Tools accessing them need to be updated. + +Finally, the `TestSuite.source`__ attribute is nowadays a `pathlib.Path`__ +instance instead of a string (`#4596`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testsuite.TestSuite +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.control.Error +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testcase.TestCase +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.keyword.Keyword +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#module-robot.model.visitor +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.Keyword +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testsuite.TestSuite.source +__ https://docs.python.org/3/library/pathlib.html + +Changes to parsing model +------------------------ + +Invalid section headers like `*** Bad ***` are nowadays represented in the +parsing model as InvalidSection__ objects when they earlier were generic +Error__ objects (`#4689`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.blocks.InvalidSection +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Error + +Changes to Libdoc spec files +---------------------------- + +Libdoc did not handle parameterized types like `list[int]` properly earlier. +Fixing that problem required storing information about nested types into +the spec files along with the top level type. In addition to the parameterized +types, also unions are now handled differently than earlier, but with normal +types there are no changes. With JSON spec files changes were pretty small, +but XML spec files required a bit bigger changes. What exactly was changed +and how is explained in comments of issue `#4538`_. + +Argument conversion changes +--------------------------- + +If an argument has multiple types, Robot Framework tries to do argument +conversion with all of them, from left to right, until one of them succeeds. +Earlier if a type was not recognized at all, the used value was returned +as-is without trying conversion with the remaining types. For example, if +a keyword like: + +.. sourcecode:: python + + def example(arg: Union[UnknownType, int]): + ... + +would be called like:: + + Example 42 + +the integer conversion would not be attempted and the keyword would get +string `42`. This was changed so that unrecognized types are just skipped +and in the above case integer conversion would be done (`#4648`_). That +obviously changes the value the keyword gets to an integer. + +Another argument conversion change is that the `Any` type is now recognized +so that any value is accepted without conversion (`#4647`_). This change is +mostly backwards compatible, but in a special case where such an argument has +a default value like `arg: Any = 1` the behavior changes. Earlier when `Any` +was not recognized at all, conversion was attempted based on the default value +type. Nowadays when `Any` is recognized and explicitly not converted, +no conversion based on the default value is done either. The behavior change +can be avoided by using `arg: Union[int, Any] = 1` which is much better +typing in general. + +Changes affecting execution +--------------------------- + +Invalid settings in tests and keywords are nowadays considered syntax +errors that cause failures at execution time (`#4683`_). They were reported +also earlier, but they did not affect execution. + +All invalid sections in resource files are considered to be syntax errors that +prevent importing the resource file (`#4689`_). Earlier having a `*** Test Cases ***` +header in a resource file caused such an error, but other invalid headers were +just reported as errors but imports succeeded. + +Deprecated features +=================== + +Python 3.7 support +------------------ + +Python 3.7 will reach its end-of-life in `June 2023`__. We have decided to +support it with Robot Framework 6.1 and subsequent 6.x releases, but +Robot Framework 7.0 will not support it anymore (`#4637`_). + +We have already earlier deprecated Python 3.6 that reached its end-of-life +already in `December 2021`__ the same way. The reason we still support it +is that it is the default Python version in Red Hat Enterprise Linux 8 +that is still `actively supported`__. + +__ https://peps.python.org/pep-0537/ +__ https://peps.python.org/pep-0494/ +__ https://endoflife.date/rhel + +Old elements in Libdoc spec files +--------------------------------- + +Libdoc spec files have been enhanced in latest releases. For backwards +compatibility reasons old information has been preserved, but all such data +will be removed in Robot Framework 7.0. For more details about what will be +removed see issue `#4667`__. + +__ https://github.com/robotframework/robotframework/issues/4667 + +Other deprecated features +------------------------- + +- Return__ node in the parsing model has been deprecated and ReturnSetting__ + should be used instead (`#4656`_). +- `name` argument of `TestSuite.from_model`__ has been deprecated and will be + removed in the future (`#4598`_). +- `accept_plain_values` argument of `robot.utils.timestr_to_secs` has been + deprecated and will be removed in the future (`#4522`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_model +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Return +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.ReturnSetting + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its ~50 member organizations. If your organization is using Robot Framework +and benefiting from it, consider joining the foundation to support its +development as well. + +Robot Framework 6.1 team funded by the foundation consists of +`Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the community has provided great contributions: + +- `@sunday2 <https://github.com/sunday2>`__ implemented JSON variable file support + (`#4532`_) and fixed User Guide generation on Windows (`#4680`_). + +- `@turunenm <https://github.com/turunenm>`__ implemented `CONSOLE` pseudo log level + (`#4536`_). + +- `@Vincema <https://github.com/Vincema>`__ added support for long command line + options with hyphens like `--pre-run-modifier` (`#4547`_). + +There are several pull requests still in the pipeline to be accepted before +Robot Framework 6.1 final is released. If there is something you would like +to see in the release, there is still a little time to get it included. + +Big thanks to Robot Framework Foundation for the continued support, to community +members listed above for their valuable contributions, and to everyone else who +has submitted bug reports, proposed enhancements, debugged problems, or otherwise +helped to make Robot Framework 6.1 such a great release! + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#3902`_ + - enhancement + - critical + - Support serializing executable suite into JSON + - alpha 1 + * - `#4234`_ + - enhancement + - critical + - Support user keywords with both embedded and normal arguments + - alpha 1 + * - `#4015`_ + - enhancement + - high + - Support configuring virtual suite created when running multiple suites with `__init__.robot` + - alpha 1 + * - `#4510`_ + - enhancement + - high + - Make it possible for custom converters to get access to the library + - alpha 1 + * - `#4532`_ + - enhancement + - high + - JSON variable file support + - alpha 1 + * - `#4536`_ + - enhancement + - high + - Add new pseudo log level `CONSOLE` that logs to console and to log file + - alpha 1 + * - `#4584`_ + - enhancement + - high + - New `robot:flatten` tag for "flattening" keyword structures + - alpha 1 + * - `#4637`_ + - enhancement + - high + - Deprecate Python 3.7 + - alpha 1 + * - `#4682`_ + - enhancement + - high + - Make `FOR IN ZIP` loop behavior if lists have different lengths configurable + - alpha 1 + * - `#4538`_ + - bug + - medium + - Libdoc doesn't handle parameterized types like `list[int]` properly + - alpha 1 + * - `#4571`_ + - bug + - medium + - Suite setup and teardown are executed even if all tests are skipped + - alpha 1 + * - `#4589`_ + - bug + - medium + - Remove unused attributes from `robot.running.Keyword` model object + - alpha 1 + * - `#4604`_ + - bug + - medium + - Listeners do not get source information for keywords executed with `Run Keyword` + - alpha 1 + * - `#4626`_ + - bug + - medium + - Inconsistent argument conversion when using `None` as default value with Python 3.11 and earlier + - alpha 1 + * - `#4635`_ + - bug + - medium + - Dialogs created by `Dialogs` on Windows don't have focus + - alpha 1 + * - `#4648`_ + - bug + - medium + - Argument conversion should be attempted with all possible types even if some type wouldn't be recognized + - alpha 1 + * - `#4680`_ + - bug + - medium + - User Guide generation broken on Windows + - alpha 1 + * - `#4689`_ + - bug + - medium + - Invalid sections are not represented properly in parsing model + - alpha 1 + * - `#4692`_ + - bug + - medium + - `ELSE IF` condition not passed to listeners + - alpha 1 + * - `#4210`_ + - enhancement + - medium + - Enhance error detection at parsing time + - alpha 1 + * - `#4547`_ + - enhancement + - medium + - Support long command line options with hyphens like `--pre-run-modifier` + - alpha 1 + * - `#4567`_ + - enhancement + - medium + - Add optional typed base class for dynamic library API + - alpha 1 + * - `#4568`_ + - enhancement + - medium + - Add optional typed base classes for listener API + - alpha 1 + * - `#4569`_ + - enhancement + - medium + - Add type information to the visitor API + - alpha 1 + * - `#4601`_ + - enhancement + - medium + - Add `robot.running.TestSuite.from_string` method + - alpha 1 + * - `#4647`_ + - enhancement + - medium + - Add explicit argument converter for `Any` that does no conversion + - alpha 1 + * - `#4666`_ + - enhancement + - medium + - Add public API to query is Robot running and is dry-run active + - alpha 1 + * - `#4676`_ + - enhancement + - medium + - Propose using `$var` syntax if evaluation IF or WHILE condition using `${var}` fails + - alpha 1 + * - `#4683`_ + - enhancement + - medium + - Report syntax errors better in log file + - alpha 1 + * - `#4684`_ + - enhancement + - medium + - Handle start index with `FOR IN ENUMERATE` loops already in parser + - alpha 1 + * - `#4611`_ + - bug + - low + - Some unit tests cannot be run independently + - alpha 1 + * - `#4634`_ + - bug + - low + - Dialogs created by `Dialogs` are not centered and their minimum size is too small + - alpha 1 + * - `#4638`_ + - bug + - low + - (:lady_beetle:) Using bare `Union` as annotation is not handled properly + - alpha 1 + * - `#4646`_ + - bug + - low + - (🐞) Bad error message when function is annotated with an empty tuple `()` + - alpha 1 + * - `#4663`_ + - bug + - low + - `BuiltIn.Log` documentation contains a defect + - alpha 1 + * - `#4522`_ + - enhancement + - low + - Deprecate `accept_plain_values` argument used by `timestr_to_secs` + - alpha 1 + * - `#4596`_ + - enhancement + - low + - Make `TestSuite.source` attribute `pathlib.Path` instance + - alpha 1 + * - `#4598`_ + - enhancement + - low + - Deprecate `name` argument of `TestSuite.from_model` + - alpha 1 + * - `#4619`_ + - enhancement + - low + - Dialogs created by `Dialogs` should bind `Enter` key to `OK` button + - alpha 1 + * - `#4636`_ + - enhancement + - low + - Buttons in dialogs created by `Dialogs` should get keyboard shortcuts + - alpha 1 + * - `#4656`_ + - enhancement + - low + - Deprecate `Return` node in parsing model + - alpha 1 + +Altogether 41 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1>`__. + +.. _#3902: https://github.com/robotframework/robotframework/issues/3902 +.. _#4234: https://github.com/robotframework/robotframework/issues/4234 +.. _#4015: https://github.com/robotframework/robotframework/issues/4015 +.. _#4510: https://github.com/robotframework/robotframework/issues/4510 +.. _#4532: https://github.com/robotframework/robotframework/issues/4532 +.. _#4536: https://github.com/robotframework/robotframework/issues/4536 +.. _#4584: https://github.com/robotframework/robotframework/issues/4584 +.. _#4637: https://github.com/robotframework/robotframework/issues/4637 +.. _#4682: https://github.com/robotframework/robotframework/issues/4682 +.. _#4538: https://github.com/robotframework/robotframework/issues/4538 +.. _#4571: https://github.com/robotframework/robotframework/issues/4571 +.. _#4589: https://github.com/robotframework/robotframework/issues/4589 +.. _#4604: https://github.com/robotframework/robotframework/issues/4604 +.. _#4626: https://github.com/robotframework/robotframework/issues/4626 +.. _#4635: https://github.com/robotframework/robotframework/issues/4635 +.. _#4648: https://github.com/robotframework/robotframework/issues/4648 +.. _#4680: https://github.com/robotframework/robotframework/issues/4680 +.. _#4689: https://github.com/robotframework/robotframework/issues/4689 +.. _#4692: https://github.com/robotframework/robotframework/issues/4692 +.. _#4210: https://github.com/robotframework/robotframework/issues/4210 +.. _#4547: https://github.com/robotframework/robotframework/issues/4547 +.. _#4567: https://github.com/robotframework/robotframework/issues/4567 +.. _#4568: https://github.com/robotframework/robotframework/issues/4568 +.. _#4569: https://github.com/robotframework/robotframework/issues/4569 +.. _#4601: https://github.com/robotframework/robotframework/issues/4601 +.. _#4647: https://github.com/robotframework/robotframework/issues/4647 +.. _#4666: https://github.com/robotframework/robotframework/issues/4666 +.. _#4676: https://github.com/robotframework/robotframework/issues/4676 +.. _#4683: https://github.com/robotframework/robotframework/issues/4683 +.. _#4684: https://github.com/robotframework/robotframework/issues/4684 +.. _#4611: https://github.com/robotframework/robotframework/issues/4611 +.. _#4634: https://github.com/robotframework/robotframework/issues/4634 +.. _#4638: https://github.com/robotframework/robotframework/issues/4638 +.. _#4646: https://github.com/robotframework/robotframework/issues/4646 +.. _#4663: https://github.com/robotframework/robotframework/issues/4663 +.. _#4522: https://github.com/robotframework/robotframework/issues/4522 +.. _#4596: https://github.com/robotframework/robotframework/issues/4596 +.. _#4598: https://github.com/robotframework/robotframework/issues/4598 +.. _#4619: https://github.com/robotframework/robotframework/issues/4619 +.. _#4636: https://github.com/robotframework/robotframework/issues/4636 +.. _#4656: https://github.com/robotframework/robotframework/issues/4656 From 3075aa3e9689cbbd7cc4457fb40dc369da86d52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 17 Mar 2023 13:44:41 +0200 Subject: [PATCH 0443/1592] RF 6.1 release notes tuning --- doc/releasenotes/rf-6.1a1.rst | 118 +++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/doc/releasenotes/rf-6.1a1.rst b/doc/releasenotes/rf-6.1a1.rst index 75d88221a00..679e00bb00d 100644 --- a/doc/releasenotes/rf-6.1a1.rst +++ b/doc/releasenotes/rf-6.1a1.rst @@ -36,7 +36,7 @@ to install exactly this version. Alternatively you can download the source distribution from PyPI_ and install it manually. For more details and other installation approaches, see the `installation instructions`_. -Robot Framework 6.1 alpha 1 will be released on Friday March 17, 2023. +Robot Framework 6.1 alpha 1 was released on Friday March 17, 2023. .. _Robot Framework: http://robotframework.org .. _Robot Framework Foundation: http://robotframework.org/foundation @@ -71,7 +71,7 @@ use cases: This feature is designed more for tool developers than for regular Robot Framework users and we expect new interesting tools to emerge in the future. The feature -feature is not finalized yet, but the following things already work: +is not finalized yet, but the following things already work: 1. You can serialize a suite structure into JSON by using `TestSuite.to_json`__ method. When used without arguments, it returns JSON data as a string, but @@ -94,15 +94,15 @@ feature is not finalized yet, but the following things already work: suite = TestSuite.from_json('tests.rbt') -3. When using `robot` normally, it parses files with the `.rbt` extension - automatically. This includes running individual JSON files like `robot tests.rbt` - and running directories containing `.rbt` files. +3. When using the `robot` command normally, JSON files with the `.rbt` extension + are parsed automatically. This includes running individual JSON files like + `robot tests.rbt` and running directories containing `.rbt` files. -We recommend everyone interested in this new API to test it and give us feedback. -It is a lot easier for us to make change before the final release is out and we -need to take backwards compatibility into account. If you encounter bugs or have -enhancement ideas, you can comment the issue or start discussion on the `#devel` -channel on our Slack_. +We recommend everyone interested in this new functionality to test it and give +us feedback. It is a lot easier for us to make changes before the final release +is out and we need to take backwards compatibility into account. If you +encounter bugs or have enhancement ideas, you can comment the issue or start +discussion on the `#devel` channel on our Slack_. __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_json __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_json @@ -148,19 +148,19 @@ creates this much content in output.xml: .. sourcecode:: xml <kw name="Keyword"> - <kw name="Log" library="BuiltIn"> - <arg>Robot</arg> - <doc>Logs the given message with the given level.</doc> - <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> - <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.663"/> - </kw> - <kw name="Log" library="BuiltIn"> - <arg>Framework</arg> - <doc>Logs the given message with the given level.</doc> - <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> - <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> - </kw> - <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + <kw name="Log" library="BuiltIn"> + <arg>Robot</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.663"/> + </kw> + <kw name="Log" library="BuiltIn"> + <arg>Framework</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> </kw> We already have the `--flattenkeywords` option for "flattening" such structures @@ -170,15 +170,15 @@ preserved. Using `--flattenkeywords` does not affect output.xml generated during execution, but flattening happens when output.xml files are parsed and can save huge amounts of memory. When `--flattenkeywords` is used with Rebot, it is possible to create a new flattened output.xml. For example, the above structure -is converted into this if `Keyword` is flattened: +is converted into this if `Keyword` is flattened using `--flattenkeywords`: .. sourcecode:: xml <kw name="Keyword"> - <doc>_*Content flattened.*_</doc> - <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> - <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> - <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + <doc>_*Content flattened.*_</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> </kw> Starting from Robot Framework 6.1, this kind of flattening can be done also @@ -200,23 +200,25 @@ the result in output.xml will be this: .. sourcecode:: xml <kw name="Keyword"> - <tag>robot:flatten</tag> - <msg timestamp="20230317 00:54:34.772" level="INFO">Robot</msg> - <msg timestamp="20230317 00:54:34.772" level="INFO">Framework</msg> - <status status="PASS" starttime="20230317 00:54:34.771" endtime="20230317 00:54:34.772"/> + <tag>robot:flatten</tag> + <msg timestamp="20230317 00:54:34.772" level="INFO">Robot</msg> + <msg timestamp="20230317 00:54:34.772" level="INFO">Framework</msg> + <status status="PASS" starttime="20230317 00:54:34.771" endtime="20230317 00:54:34.772"/> </kw> -A benefit of using `robot:flatten` instead of `--flattenkeywords` is that -it used already during execution making the resulting output.xml file smaller -without using Rebot separately afterwards. +The main benefit of using `robot:flatten` instead of `--flattenkeywords` is that +it is used already during execution making the resulting output.xml file +smaller. `--flattenkeywords` has more configuration options than `robot:flatten`, +though, but `robot:flatten` can be enhanced in that regard later if there are +needs. Custom argument converters can access library --------------------------------------------- Support for custom argument converters was added in Robot Framework 5.0 (`#4088`__) and they have turned out to be really useful. This functionality -is now enhanced so that converters can easily get an access to the -library containing the keyword that is used and can thus do conversion +is now enhanced so, that converters can easily get an access to the +library containing the keyword that is used, and can thus do conversion based on the library state (`#4510`_). This can be done simply by creating a converter that accepts two values. The first value is the value used in the data, exactly as earlier, and the second is the library instance or module: @@ -226,7 +228,7 @@ the data, exactly as earlier, and the second is the library instance or module: def converter(value, library): ... -Converters accepting only one argument keep working as earlier and there are no +Converters accepting only one argument keep working as earlier. There are no plans to require changing them to accept two values. __ https://github.com/robotframework/robotframework/issues/4088 @@ -278,8 +280,11 @@ suites as child suites. Earlier this virtual suite could be configured only by using command line options like `--name`, but now it is possible to use normal suite initialization files (`__init__.robot`) for that purpose (`#4015`_). If an initialization file is included -in the call like `robot __init__.robot first.robot second.robot`, the root -suite is configured based on data it contains. +in the call like:: + + robot __init__.robot first.robot second.robot` + +the root suite is configured based on data it contains. The most important feature this enhancement allows is specifying suite setup and teardown to the root suite. Earlier that was not possible at all. @@ -288,7 +293,7 @@ setup and teardown to the root suite. Earlier that was not possible at all. -------------------------------------------------------------------- Robot Framework's `FOR IN ZIP` loop behaves like Python's zip__ function so -that if lists lengths are not the same, items from longer ones ignored. +that if lists lengths are not the same, items from longer ones are ignored. For example, the following loop would be executed only twice: .. sourcecode:: robotframework @@ -307,7 +312,7 @@ This behavior can cause problems when iterating over items received from the automated system. For example, the following test would pass regardless how many things `Get something` returns as long as the returned items match the expected values. The example succeeds if `Get something` returns ten items -if three first ones match. What's even worse, it succeeds even if `Get something` +if three first ones match. What's even worse, it succeeds also if `Get something` returns nothing. .. sourcecode:: robotframework @@ -331,7 +336,7 @@ same issue, and Python 3.10 added new optional `strict` argument to `zip` `zip_longest`__ function that loops over all values possibly filling-in values to shorter lists. -To support all the same use cases as Python, Robot Framework's `FOR IN ZIP` +To support the same features as Python, Robot Framework's `FOR IN ZIP` loops now have an optional `mode` configuration option that accepts three values (`#4682`_): @@ -403,12 +408,12 @@ tools using Robot Framework may nevertheless be affected. Changes to output.xml --------------------- -Syntax errors such as invalid settings and `END` or `ELSE` in wrong place +Syntax errors such as invalid settings like `[Setpu]` or `END` in a wrong place are nowadays reported better (`#4683`_). Part of that change was storing invalid constructs in output.xml as `<error>` elements. Tools processing -output.xml files so that they go through all elements need to take them into -account, but tools just querying information using xpath expression or -otherwise should not be affected. +output.xml files so that they go through all elements need to take `<error>` +elements into account, but tools just querying information using xpath +expression or otherwise should not be affected. Another change is that with `FOR IN ENUMERATE` loops the `<for>` element may get `start` attribute (`#4684`_) and with `FOR IN ZIP` loops it may get @@ -422,8 +427,8 @@ The aforementioned enhancements for handling invalid syntax better (`#4683`_) required changes also to the TestSuite__ model structure. Syntax errors are nowadays represented as Error__ objects and they can appear in the `body` of TestCase__, Keyword__, and other such model objects. Tools interacting with -the `TestSuite` structure should in general take `Error` objects into account, -but tools using the `visitor API`__ should nevertheless not be affected. +the `TestSuite` structure should take `Error` objects into account, but tools +using the `visitor API`__ should in general not be affected. Another related change is that `doc`, `tags`, `timeout` and `teardown` attributes were removed from the `robot.running.Keyword`__ object (`#4589`_). They were @@ -449,8 +454,15 @@ Invalid section headers like `*** Bad ***` are nowadays represented in the parsing model as InvalidSection__ objects when they earlier were generic Error__ objects (`#4689`_). +New ReturnSetting__ object has been introduced as an alias for Return__. +This does not yet change anything, but in the future `Return` will be used +for other purposes tools using it should be updated to use `ReturnSetting` +instead (`#4656`_). + __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.blocks.InvalidSection __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Error +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Return +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.ReturnSetting Changes to Libdoc spec files ---------------------------- @@ -483,7 +495,7 @@ would be called like:: the integer conversion would not be attempted and the keyword would get string `42`. This was changed so that unrecognized types are just skipped -and in the above case integer conversion would be done (`#4648`_). That +and in the above case integer conversion is nowadays done (`#4648`_). That obviously changes the value the keyword gets to an integer. Another argument conversion change is that the `Any` type is now recognized @@ -499,9 +511,9 @@ typing in general. Changes affecting execution --------------------------- -Invalid settings in tests and keywords are nowadays considered syntax -errors that cause failures at execution time (`#4683`_). They were reported -also earlier, but they did not affect execution. +Invalid settings in tests and keywords like `[Tasg]` are nowadays considered +syntax errors that cause failures at execution time (`#4683`_). They were +reported also earlier, but they did not affect execution. All invalid sections in resource files are considered to be syntax errors that prevent importing the resource file (`#4689`_). Earlier having a `*** Test Cases ***` From 56979bf685e78a4b36ba7c808ef8d9a45ef40803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 17 Mar 2023 13:48:14 +0200 Subject: [PATCH 0444/1592] Updated version to 6.1a1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index d6188fff804..d0400882f60 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1.dev1' +VERSION = '6.1a1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 3be9a14084a..1a90e0a3db9 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1.dev1' +VERSION = '6.1a1' def get_version(naked=False): From 5a6d2ac1456d137dbf6b804892a268463bf09893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 17 Mar 2023 13:49:38 +0200 Subject: [PATCH 0445/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index d0400882f60..08eb693bb22 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1a1' +VERSION = '6.1a2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 1a90e0a3db9..aca3a8641e5 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1a1' +VERSION = '6.1a2.dev1' def get_version(naked=False): From 524b871ad337d5004465dbdea3e5995b12d647f8 Mon Sep 17 00:00:00 2001 From: KotlinIsland <kotlinisland@users.noreply.github.com> Date: Wed, 1 Feb 2023 21:42:12 +1000 Subject: [PATCH 0446/1592] Support only vararg in custom converters --- .../type_conversion/custom_converters.robot | 3 +++ .../keywords/type_conversion/CustomConverters.py | 13 ++++++++++++- .../type_conversion/custom_converters.robot | 3 +++ src/robot/running/arguments/customconverters.py | 5 +++-- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/atest/robot/keywords/type_conversion/custom_converters.robot b/atest/robot/keywords/type_conversion/custom_converters.robot index d675ed6761f..28a076e43c1 100644 --- a/atest/robot/keywords/type_conversion/custom_converters.robot +++ b/atest/robot/keywords/type_conversion/custom_converters.robot @@ -33,6 +33,9 @@ Failing conversion `None` as strict converter Check Test Case ${TESTNAME} +Only vararg + Check Test Case ${TESTNAME} + With library as argument to converter Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index 2924579d6d7..e2af4e15eb7 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -78,6 +78,11 @@ def __init__(self, numbers: List[int]): self.sum = sum(numbers) +class OnlyVarArg: + def __init__(self, *varargs): + self.value = varargs[0] + + class Strict: pass @@ -96,7 +101,7 @@ def __init__(self, one, two, three): class NoPositionalArg: - def __init__(self, *varargs): + def __init__(self, *, args): pass @@ -113,6 +118,7 @@ def __init__(self, arg, *, kwo, another): ClassAsConverter: ClassAsConverter, ClassWithHintsAsConverter: ClassWithHintsAsConverter, AcceptSubscriptedGenerics: AcceptSubscriptedGenerics, + OnlyVarArg: OnlyVarArg, Strict: None, Invalid: 666, TooFewArgs: TooFewArgs, @@ -122,6 +128,11 @@ def __init__(self, arg, *, kwo, another): 'Bad': int} +def only_var_arg(argument: OnlyVarArg, expected): + assert isinstance(argument, OnlyVarArg) + assert argument.value == expected + + def number(argument: Number, expected: int = 0): if argument != expected: raise AssertionError(f'Expected value to be {expected!r}, got {argument!r}.') diff --git a/atest/testdata/keywords/type_conversion/custom_converters.robot b/atest/testdata/keywords/type_conversion/custom_converters.robot index 7125086a53a..fc13bbf7358 100644 --- a/atest/testdata/keywords/type_conversion/custom_converters.robot +++ b/atest/testdata/keywords/type_conversion/custom_converters.robot @@ -69,6 +69,9 @@ Failing conversion Conversion should fail Strict wrong type ... type=Strict error=TypeError: Only Strict instances are accepted, got string. +Only vararg + Only var arg 10 10 + With library as argument to converter String ${123} diff --git a/src/robot/running/arguments/customconverters.py b/src/robot/running/arguments/customconverters.py index 931fb61abbb..08276d66210 100644 --- a/src/robot/running/arguments/customconverters.py +++ b/src/robot/running/arguments/customconverters.py @@ -78,7 +78,7 @@ def converter(arg): raise TypeError(f'Custom converters must be callable, converter for ' f'{type_name(type_)} is {type_name(converter)}.') spec = cls._get_arg_spec(converter) - arg_type = spec.types.get(spec.positional[0]) + arg_type = spec.types.get(spec.positional and spec.positional[0] or spec.var_positional) if arg_type is None: accepts = () elif is_union(arg_type): @@ -96,7 +96,7 @@ def _get_arg_spec(cls, converter): required = seq2str([a for a in spec.positional if a not in spec.defaults]) raise TypeError(f"Custom converters cannot have more than two mandatory " f"arguments, '{converter.__name__}' has {required}.") - if not spec.positional: + if not spec.maxargs: raise TypeError(f"Custom converters must accept one positional argument, " f"'{converter.__name__}' accepts none.") if spec.named_only and set(spec.named_only) - set(spec.defaults): @@ -109,3 +109,4 @@ def convert(self, value): if not self.library: return self.converter(value) return self.converter(value, self.library.get_instance()) + From da75a00380529b3afc00e7715009253e9f6c88e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sat, 18 Mar 2023 14:29:12 +0200 Subject: [PATCH 0447/1592] custom converters: pass library to varargs converters This ensures that varargs converters get the same arguments as converters with two positional args. --- atest/testdata/keywords/type_conversion/CustomConverters.py | 6 ++++++ src/robot/running/arguments/customconverters.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index e2af4e15eb7..ee2caea2af9 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -81,6 +81,12 @@ def __init__(self, numbers: List[int]): class OnlyVarArg: def __init__(self, *varargs): self.value = varargs[0] + library = varargs[1] + if library is None: + raise AssertionError('Expected library, got none') + if not isinstance(library, ModuleType): + raise AssertionError(f'Expected library to be instance of {ModuleType}, was {type(library)}') + class Strict: diff --git a/src/robot/running/arguments/customconverters.py b/src/robot/running/arguments/customconverters.py index 08276d66210..1a3eff12af1 100644 --- a/src/robot/running/arguments/customconverters.py +++ b/src/robot/running/arguments/customconverters.py @@ -87,7 +87,8 @@ def converter(arg): accepts = (arg_type.__origin__,) else: accepts = (arg_type,) - return cls(type_, converter, accepts, library if spec.minargs == 2 else None) + pass_library = spec.minargs == 2 or spec.var_positional + return cls(type_, converter, accepts, library if pass_library else None) @classmethod def _get_arg_spec(cls, converter): From c3e1765f6104813f78cf1e010513ae721d69ab78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sat, 18 Mar 2023 14:49:35 +0200 Subject: [PATCH 0448/1592] ug: documentation for vararg custom converter --- .../src/ExtendingRobotFramework/CreatingTestLibraries.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 811a2f81433..22cd332b81f 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1804,7 +1804,8 @@ should be parsed like this: The `library` argument to converter function is optional, i.e. if the converter function -only accepts one argument, the `library` argument is omitted. +only accepts one argument, the `library` argument is omitted. Similar result can be achieved +by making the converter function accept only variadic arguments, e.g. `def parse_date(*varargs)`. Converter documentation ``````````````````````` From 0d90aba958ee16a903bec5bca0258968b5eeb831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 21 Mar 2023 10:49:19 +0200 Subject: [PATCH 0449/1592] Fix `Documentation.from_params(...).value`. 1. Fix `value` when tokens don't have no line numbers. 2. Fix `from_params` when there are empty lines. Fixes #4670. --- src/robot/parsing/model/statements.py | 61 +++++++-------- utest/parsing/test_model.py | 108 ++++++++++++++++++++++++++ utest/parsing/test_statements.py | 24 +++--- utest/parsing/test_tokenizer.py | 2 +- 4 files changed, 154 insertions(+), 41 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 206ba4cb52b..400e0bc987b 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -163,28 +163,37 @@ def __repr__(self): class DocumentationOrMetadata(Statement): - def _join_value(self, tokens): - lines = self._get_lines(tokens) - return ''.join(self._yield_lines_with_newlines(lines)) + @property + def value(self): + return ''.join(self._get_lines_with_newlines()).rstrip() + + def _get_lines_with_newlines(self): + for parts in self._get_line_parts(): + line = ' '.join(parts) + yield line + if not self._escaped_or_has_newline(line): + yield '\n' - def _get_lines(self, tokens): - lines = [] - line = None + def _get_line_parts(self): + line = [] lineno = -1 - for t in tokens: - if t.lineno != lineno: + # There are no EOLs during execution or if data has been parsed with + # `data_only=True` otherwise, so we need to look at line numbers to + # know when lines change. If model is created programmatically using + # `from_params` or otherwise, line numbers may not be set, but there + # ought to be EOLs. If both EOLs and line numbers are missing, + # everything is considered to be on the same line. + for token in self.get_tokens(Token.ARGUMENT, Token.EOL): + eol = token.type == Token.EOL + if token.lineno != lineno or eol: + if line: + yield line line = [] - lines.append(line) - line.append(t.value) - lineno = t.lineno - return [' '.join(line) for line in lines] - - def _yield_lines_with_newlines(self, lines): - last_index = len(lines) - 1 - for index, line in enumerate(lines): + if not eol: + line.append(token.value) + lineno = token.lineno + if line: yield line - if index < last_index and not self._escaped_or_has_newline(line): - yield '\n' def _escaped_or_has_newline(self, line): match = re.search(r'(\\+)n?$', line) @@ -350,16 +359,11 @@ def from_params(cls, value, indent=FOUR_SPACES, separator=FOUR_SPACES, tokens.append(Token(Token.SEPARATOR, indent)) tokens.append(Token(Token.CONTINUATION)) if line: - tokens.extend([Token(Token.SEPARATOR, multiline_separator), - Token(Token.ARGUMENT, line)]) - tokens.append(Token(Token.EOL, eol)) + tokens.append(Token(Token.SEPARATOR, multiline_separator)) + tokens.extend([Token(Token.ARGUMENT, line), + Token(Token.EOL, eol)]) return cls(tokens) - @property - def value(self): - tokens = self.get_tokens(Token.ARGUMENT) - return self._join_value(tokens) - @Statement.register class Metadata(DocumentationOrMetadata): @@ -386,11 +390,6 @@ def from_params(cls, name, value, separator=FOUR_SPACES, eol=EOL): def name(self): return self.get_value(Token.NAME) - @property - def value(self): - tokens = self.get_tokens(Token.ARGUMENT) - return self._join_value(tokens) - @Statement.register class ForceTags(MultiValue): diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 9e9bb487ae2..eee07e6e1a6 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1007,6 +1007,114 @@ def test_continue(self): get_and_assert_model(data, expected) +class TestDocumentation(unittest.TestCase): + + def test_empty(self): + data = '''\ +*** Settings *** +Documentation +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.EOL, '\n', 2, 13)] + ) + self._verify_documentation(data, expected, '') + + def test_one_line(self): + data = '''\ +*** Settings *** +Documentation Hello! +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.SEPARATOR, ' ', 2, 13), + Token(Token.ARGUMENT, 'Hello!', 2, 17), + Token(Token.EOL, '\n', 2, 23)] + ) + self._verify_documentation(data, expected, 'Hello!') + + def test_multi_part(self): + data = '''\ +*** Settings *** +Documentation Hello world +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.SEPARATOR, ' ', 2, 13), + Token(Token.ARGUMENT, 'Hello', 2, 17), + Token(Token.SEPARATOR, ' ', 2, 22), + Token(Token.ARGUMENT, 'world', 2, 26), + Token(Token.EOL, '\n', 2, 31)] + ) + self._verify_documentation(data, expected, 'Hello world') + + def test_multi_line(self): + data = '''\ +*** Settings *** +Documentation Documentation +... in +... multiple lines and parts +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.SEPARATOR, ' ', 2, 13), + Token(Token.ARGUMENT, 'Documentation', 2, 17), + Token(Token.EOL, '\n', 2, 30), + Token(Token.CONTINUATION, '...', 3, 0), + Token(Token.SEPARATOR, ' ', 3, 3), + Token(Token.ARGUMENT, 'in', 3, 17), + Token(Token.EOL, '\n', 3, 19), + Token(Token.CONTINUATION, '...', 4, 0), + Token(Token.SEPARATOR, ' ', 4, 3), + Token(Token.ARGUMENT, 'multiple lines', 4, 17), + Token(Token.SEPARATOR, ' ', 4, 31), + Token(Token.ARGUMENT, 'and parts', 4, 35), + Token(Token.EOL, '\n', 4, 44)] + ) + self._verify_documentation(data, expected, + 'Documentation\nin\nmultiple lines and parts') + + def test_multi_line_with_empty_lines(self): + data = '''\ +*** Settings *** +Documentation Documentation +... +... with empty +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.SEPARATOR, ' ', 2, 13), + Token(Token.ARGUMENT, 'Documentation', 2, 17), + Token(Token.EOL, '\n', 2, 30), + Token(Token.CONTINUATION, '...', 3, 0), + Token(Token.ARGUMENT, '', 3, 3), + Token(Token.EOL, '\n', 3, 3), + Token(Token.CONTINUATION, '...', 4, 0), + Token(Token.SEPARATOR, ' ', 4, 3), + Token(Token.ARGUMENT, 'with empty', 4, 17), + Token(Token.EOL, '\n', 4, 27)] + ) + self._verify_documentation(data, expected, 'Documentation\n\nwith empty') + + def _verify_documentation(self, data, expected, value): + # Model has both EOLs and line numbers. + doc = get_model(data).sections[0].body[0] + assert_model(doc, expected) + assert_equal(doc.value, value) + # Model has only line numbers, no EOLs or other non-data tokens. + doc = get_model(data, data_only=True).sections[0].body[0] + expected.tokens = [token for token in expected.tokens + if token.type not in Token.NON_DATA_TOKENS] + assert_model(doc, expected) + assert_equal(doc.value, value) + # Model has only EOLS, no line numbers. + doc = Documentation.from_params(value) + assert_equal(doc.value, value) + # Model has no EOLs nor line numbers. Everything is just one line. + doc.tokens = [token for token in doc.tokens if token.type != Token.EOL] + assert_equal(doc.value, ' '.join(value.splitlines())) + + class TestError(unittest.TestCase): def test_get_errors_from_tokens(self): diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index fe2a4b7d0da..13124ada8bd 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -7,24 +7,25 @@ def assert_created_statement(tokens, base_class, **params): - new_statement = base_class.from_params(**params) + statement = base_class.from_params(**params) assert_statements( - new_statement, + statement, base_class(tokens) ) assert_statements( - new_statement, + statement, base_class.from_tokens(tokens) ) assert_statements( - new_statement, + statement, Statement.from_tokens(tokens) ) - if len(set(id(t) for t in new_statement.tokens)) != len(tokens): + if len(set(id(t) for t in statement.tokens)) != len(tokens): lines = '\n'.join(f'{i:18}{t}' for i, t in [('ID', 'TOKEN')] + - [(str(id(t)), repr(t)) for t in new_statement.tokens]) + [(str(id(t)), repr(t)) for t in statement.tokens]) raise AssertionError(f'Tokens should not be reused!\n\n{lines}') + return statement def compare_statements(first, second): @@ -407,11 +408,12 @@ def test_Documentation(self): Token(Token.ARGUMENT, 'Example documentation'), Token(Token.EOL, '\n') ] - assert_created_statement( + doc = assert_created_statement( tokens, Documentation, value='Example documentation' ) + assert_equal(doc.value, 'Example documentation') # Documentation First line. # ... Second line aligned. @@ -427,17 +429,19 @@ def test_Documentation(self): Token(Token.ARGUMENT, 'Second line aligned.'), Token(Token.EOL), Token(Token.CONTINUATION), + Token(Token.ARGUMENT, ''), Token(Token.EOL), Token(Token.CONTINUATION), Token(Token.SEPARATOR, ' '), Token(Token.ARGUMENT, 'Second paragraph.'), Token(Token.EOL), ] - assert_created_statement( + doc = assert_created_statement( tokens, Documentation, value='First line.\nSecond line aligned.\n\nSecond paragraph.' ) + assert_equal(doc.value, 'First line.\nSecond line aligned.\n\nSecond paragraph.') # Test/Keyword # [Documentation] First line @@ -457,6 +461,7 @@ def test_Documentation(self): Token(Token.EOL), Token(Token.SEPARATOR, ' '), Token(Token.CONTINUATION), + Token(Token.ARGUMENT, ''), Token(Token.EOL), Token(Token.SEPARATOR, ' '), Token(Token.CONTINUATION), @@ -464,7 +469,7 @@ def test_Documentation(self): Token(Token.ARGUMENT, 'Second paragraph.'), Token(Token.EOL), ] - assert_created_statement( + doc = assert_created_statement( tokens, Documentation, value='First line.\nSecond line aligned.\n\nSecond paragraph.\n', @@ -472,6 +477,7 @@ def test_Documentation(self): separator=' ', settings_section=False ) + assert_equal(doc.value, 'First line.\nSecond line aligned.\n\nSecond paragraph.') def test_Metadata(self): tokens = [ diff --git a/utest/parsing/test_tokenizer.py b/utest/parsing/test_tokenizer.py index 3bc3bc86a2e..728e803bc62 100644 --- a/utest/parsing/test_tokenizer.py +++ b/utest/parsing/test_tokenizer.py @@ -58,7 +58,7 @@ def test_internal_spaces(self): (DATA, 'S p a c e s', 1, 17), (EOL, '', 1, 28)]) - def test_single_tab_is_enough_as_sepator(self): + def test_single_tab_is_enough_as_separator(self): verify_split('\tT\ta\t\t\tb\t\t', [(DATA, '', 1, 0), (SEPA, '\t', 1, 0), From 810afc83bb82b5059bfb130109fe533195ced856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 21 Mar 2023 15:32:39 +0200 Subject: [PATCH 0450/1592] Add type hints to `setter`. This is enough for Mypy and most likely also to pyright that VSCode uses. Unfortunately PyCharm intellisense is buggy: https://youtrack.jetbrains.com/issue/PY-59658 Related to #4570. --- src/robot/utils/setter.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/robot/utils/setter.py b/src/robot/utils/setter.py index 23a4a84917b..075ca025564 100644 --- a/src/robot/utils/setter.py +++ b/src/robot/utils/setter.py @@ -13,15 +13,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Any, Callable, Generic, overload, TypeVar -class setter: - def __init__(self, method): +T = TypeVar('T') +V = TypeVar('V') + + +class setter(Generic[V]): + + def __init__(self, method: Callable[[T, Any], V]): self.method = method self.attr_name = '_setter__' + method.__name__ self.__doc__ = method.__doc__ - def __get__(self, instance, owner): + @overload + def __get__(self, instance: None, owner: 'type[T]') -> 'setter': + ... + + @overload + def __get__(self, instance: T, owner: 'type[T]') -> V: + ... + + def __get__(self, instance: 'T|None', owner: 'type[T]') -> 'V|setter': if instance is None: return self try: @@ -29,10 +43,9 @@ def __get__(self, instance, owner): except AttributeError: raise AttributeError(self.method.__name__) - def __set__(self, instance, value): - if instance is None: - return - setattr(instance, self.attr_name, self.method(instance, value)) + def __set__(self, instance: T, value: Any): + if instance is not None: + setattr(instance, self.attr_name, self.method(instance, value)) class SetterAwareType(type): From 7fb508ca1bd6d866457fda1c1a8f9d4653266ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 22 Mar 2023 11:25:40 +0200 Subject: [PATCH 0451/1592] Enhance `setter` typing and documentation. 1. Use TypeVar also with value passed to the setter methods. I don't like one letter type names T, V, A too much, but that seems to be a convention and it also keeps signature lengths reasonable. 2. Add docstrings. Related to #4570. --- src/robot/utils/setter.py | 48 +++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/robot/utils/setter.py b/src/robot/utils/setter.py index 075ca025564..be7ccfb26ec 100644 --- a/src/robot/utils/setter.py +++ b/src/robot/utils/setter.py @@ -13,29 +13,62 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Callable, Generic, overload, TypeVar +from typing import Callable, Generic, overload, TypeVar, Type, Union T = TypeVar('T') V = TypeVar('V') +A = TypeVar('A') -class setter(Generic[V]): +class setter(Generic[T, V, A]): + """Modify instance attributes only when they are set, not when they are get. - def __init__(self, method: Callable[[T, Any], V]): + Usage:: + + @setter + def source(self, source: str|Path) -> Path: + return source if isinstance(source, Path) else Path(source) + + The setter method is called when the attribute is assigned like:: + + instance.source = 'example.txt' + + and the returned value is stored in the instance in an attribute like + ``_setter__source``. When the attribute is accessed, the stored value is + returned. + + The above example is equivalent to using the standard ``property`` as + follows. The main benefit of using ``setter`` is that it avoids a dummy + getter method:: + + @property + def source(self) -> Path: + return self._source + + @source.setter + def source(self, source: src|Path): + self._source = source if isinstance(source, Path) else Path(source) + + When using ``setter`` with ``__slots__``, the special ``_setter__xxx`` + attributes needs to be added to ``__slots__`` as well. The provided + :class:`SetterAwareType` metaclass can take care of that automatically. + """ + + def __init__(self, method: Callable[[T, V], A]): self.method = method self.attr_name = '_setter__' + method.__name__ self.__doc__ = method.__doc__ @overload - def __get__(self, instance: None, owner: 'type[T]') -> 'setter': + def __get__(self, instance: None, owner: Type[T]) -> 'setter': ... @overload - def __get__(self, instance: T, owner: 'type[T]') -> V: + def __get__(self, instance: T, owner: Type[T]) -> A: ... - def __get__(self, instance: 'T|None', owner: 'type[T]') -> 'V|setter': + def __get__(self, instance: Union[T, None], owner: Type[T]) -> Union[A, 'setter']: if instance is None: return self try: @@ -43,12 +76,13 @@ def __get__(self, instance: 'T|None', owner: 'type[T]') -> 'V|setter': except AttributeError: raise AttributeError(self.method.__name__) - def __set__(self, instance: T, value: Any): + def __set__(self, instance: T, value: V): if instance is not None: setattr(instance, self.attr_name, self.method(instance, value)) class SetterAwareType(type): + """Metaclass for adding attributes used by :class:`setter` to ``__slots__``.""" def __new__(cls, name, bases, dct): if '__slots__' in dct: From 0c88fc0377540db5034b5bc680380354756fe7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 22 Mar 2023 17:39:05 +0200 Subject: [PATCH 0452/1592] Make ItemList generic. Part of #4570. ItemList usages need to still be updated to actually get some benefits from this, but quick prototyping indicated that this change along with earlier `setter` enhancements really help with intellisense at least with VSCode. Also add configuration to `.sort()` to be compatible with `list.sort()`. --- src/robot/model/itemlist.py | 107 +++++++++++++++++++++-------------- utest/model/test_itemlist.py | 8 ++- 2 files changed, 72 insertions(+), 43 deletions(-) diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index 1aef82b95d1..8a24f8b8f09 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -13,14 +13,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections.abc import MutableSequence from functools import total_ordering +from typing import (Iterable, Iterator, List, MutableSequence, overload, + TYPE_CHECKING, Type, TypeVar, Union) from robot.utils import type_name +if TYPE_CHECKING: + from .visitor import SuiteVisitor + + +T = TypeVar('T') +Self = TypeVar('Self', bound='ItemList') + @total_ordering -class ItemList(MutableSequence): +class ItemList(MutableSequence[T]): """List of items of a certain enforced type. New items can be created using the :meth:`create` method and existing items @@ -36,23 +44,25 @@ class ItemList(MutableSequence): __slots__ = ['_item_class', '_common_attrs', '_items'] - def __init__(self, item_class, common_attrs=None, items=None): + def __init__(self, item_class: Type[T], + common_attrs: Union[dict, None] = None, + items: Union[Iterable[Union[T, dict]], None] = None): self._item_class = item_class self._common_attrs = common_attrs - self._items = [] + self._items: List[T] = [] if items: self.extend(items) - def create(self, *args, **kwargs): + def create(self, *args, **kwargs) -> T: """Create a new item using the provided arguments.""" return self.append(self._item_class(*args, **kwargs)) - def append(self, item): + def append(self, item: Union[T, dict]): item = self._check_type_and_set_attrs(item) self._items.append(item) return item - def _check_type_and_set_attrs(self, item): + def _check_type_and_set_attrs(self, item: Union[T, dict]) -> T: if not isinstance(item, self._item_class): if isinstance(item, dict): item = self._item_from_dict(item) @@ -64,40 +74,48 @@ def _check_type_and_set_attrs(self, item): setattr(item, attr, value) return item - def _item_from_dict(self, data): + def _item_from_dict(self, data: dict) -> T: if hasattr(self._item_class, 'from_dict'): - return self._item_class.from_dict(data) + return self._item_class.from_dict(data) # type: ignore return self._item_class(**data) - def extend(self, items): + def extend(self, items: Iterable[Union[T, dict]]): self._items.extend(self._check_type_and_set_attrs(i) for i in items) - def insert(self, index, item): + def insert(self, index: int, item: Union[T, dict]): item = self._check_type_and_set_attrs(item) self._items.insert(index, item) - def index(self, item, *start_and_end): + def index(self, item: T, *start_and_end) -> int: return self._items.index(item, *start_and_end) def clear(self): self._items = [] - def visit(self, visitor): + def visit(self, visitor: 'SuiteVisitor'): for item in self: - item.visit(visitor) + item.visit(visitor) # type: ignore - def __iter__(self): + def __iter__(self) -> Iterator[T]: index = 0 while index < len(self._items): yield self._items[index] index += 1 + @overload + def __getitem__(self, index: int) -> T: + ... + + @overload + def __getitem__(self: Self, index: slice) -> Self: + ... + def __getitem__(self, index): if isinstance(index, slice): return self._create_new_from(self._items[index]) return self._items[index] - def _create_new_from(self, items): + def _create_new_from(self: Self, items: Iterable[T]) -> Self: # Cannot pass common_attrs directly to new object because all # subclasses don't have compatible __init__. new = type(self)(self._item_class) @@ -105,85 +123,92 @@ def _create_new_from(self, items): new.extend(items) return new + @overload + def __setitem__(self, index: int, item: Union[T, dict]): + ... + + @overload + def __setitem__(self, index: slice, item: Iterable[Union[T, dict]]): + ... + def __setitem__(self, index, item): if isinstance(index, slice): - item = [self._check_type_and_set_attrs(i) for i in item] + self._items[index] = [self._check_type_and_set_attrs(i) for i in item] else: - item = self._check_type_and_set_attrs(item) - self._items[index] = item + self._items[index] = self._check_type_and_set_attrs(item) - def __delitem__(self, index): + def __delitem__(self, index: Union[int, slice]): del self._items[index] - def __contains__(self, item): + def __contains__(self, item: object) -> bool: return item in self._items - def __len__(self): + def __len__(self) -> int: return len(self._items) - def __str__(self): + def __str__(self) -> str: return str(list(self)) - def __repr__(self): + def __repr__(self) -> str: class_name = type(self).__name__ item_name = self._item_class.__name__ return f'{class_name}(item_class={item_name}, items={self._items})' - def count(self, item): + def count(self, item: T) -> int: return self._items.count(item) - def sort(self): - self._items.sort() + def sort(self, **config): + self._items.sort(**config) def reverse(self): self._items.reverse() - def __reversed__(self): + def __reversed__(self) -> Iterator[T]: index = 0 while index < len(self._items): yield self._items[len(self._items) - index - 1] index += 1 - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return (isinstance(other, ItemList) and self._is_compatible(other) and self._items == other._items) - def _is_compatible(self, other): + def _is_compatible(self, other) -> bool: return (self._item_class is other._item_class and self._common_attrs == other._common_attrs) - def __lt__(self, other): + def __lt__(self, other: 'ItemList[T]') -> bool: if not isinstance(other, ItemList): raise TypeError(f'Cannot order ItemList and {type_name(other)}.') if not self._is_compatible(other): raise TypeError('Cannot order incompatible ItemLists.') return self._items < other._items - def __add__(self, other): + def __add__(self: Self, other: 'ItemList[T]') -> Self: if not isinstance(other, ItemList): raise TypeError(f'Cannot add ItemList and {type_name(other)}.') if not self._is_compatible(other): raise TypeError('Cannot add incompatible ItemLists.') return self._create_new_from(self._items + other._items) - def __iadd__(self, other): + def __iadd__(self: Self, other: Iterable[T]) -> Self: if isinstance(other, ItemList) and not self._is_compatible(other): raise TypeError('Cannot add incompatible ItemLists.') self.extend(other) return self - def __mul__(self, other): - return self._create_new_from(self._items * other) + def __mul__(self: Self, count: int) -> Self: + return self._create_new_from(self._items * count) - def __imul__(self, other): - self._items *= other + def __imul__(self: Self, count: int) -> Self: + self._items *= count return self - def __rmul__(self, other): - return self * other + def __rmul__(self: Self, count: int) -> Self: + return self * count - def to_dicts(self): + def to_dicts(self) -> List[dict]: """Return list of items converted to dictionaries. Items are converted to dictionaries using the ``to_dict`` method, if @@ -193,4 +218,4 @@ def to_dicts(self): """ if not hasattr(self._item_class, 'to_dict'): return [vars(item) for item in self] - return [item.to_dict() for item in self] + return [item.to_dict() for item in self] # type: ignore diff --git a/utest/model/test_itemlist.py b/utest/model/test_itemlist.py index a4af8ae1e50..8881fd3557b 100644 --- a/utest/model/test_itemlist.py +++ b/utest/model/test_itemlist.py @@ -265,9 +265,13 @@ def test_count(self): assert_equal(objects.count('whatever'), 0) def test_sort(self): - chars = ItemList(str, items='asdfg') + chars = ItemList(str, items='asDfG') chars.sort() - assert_equal(list(chars), sorted('asdfg')) + assert_equal(list(chars), ['D', 'G', 'a', 'f', 's']) + chars.sort(key=str.lower) + assert_equal(list(chars), ['a', 'D', 'f', 'G', 's']) + chars.sort(reverse=True) + assert_equal(list(chars), ['s', 'f', 'a', 'G', 'D']) def test_sorted(self): chars = ItemList(str, items='asdfg') From fb869f0fff053847fce81d80e6fd85fed6a4b83b Mon Sep 17 00:00:00 2001 From: franzhaas <franz.dominik.haas@gmail.com> Date: Mon, 27 Mar 2023 16:55:13 +0200 Subject: [PATCH 0453/1592] Zipapp compatibility Fixes #4613. --- INSTALL.rst | 22 +++++++++++++++++ src/robot/htmldata/common/__init__.py | 14 +++++++++++ src/robot/htmldata/lib/__init__.py | 14 +++++++++++ src/robot/htmldata/libdoc/__init__.py | 14 +++++++++++ src/robot/htmldata/rebot/__init__.py | 14 +++++++++++ src/robot/htmldata/template.py | 34 ++++++++++++++++++++------ src/robot/htmldata/testdoc/__init__.py | 14 +++++++++++ src/robot/pythonpathsetter.py | 9 ++++++- utest/htmldata/test_htmltemplate.py | 4 +-- 9 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 src/robot/htmldata/common/__init__.py create mode 100644 src/robot/htmldata/lib/__init__.py create mode 100644 src/robot/htmldata/libdoc/__init__.py create mode 100644 src/robot/htmldata/rebot/__init__.py create mode 100644 src/robot/htmldata/testdoc/__init__.py diff --git a/INSTALL.rst b/INSTALL.rst index 525e625fcbb..2b324d8e126 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -322,3 +322,25 @@ __ https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtua .. _PATH: `Configuring path`_ .. _PyPI: https://pypi.org/project/robotframework .. _GitHub: https://github.com/robotframework/robotframework + +Zipapp +-------------------- + +`Zipapps <https://docs.python.org/3/library/zipapp.html>`_ are a technique to +distribute all the python code of a solution in a single file, which can +be executed using a python interpreter. The same zipapp file can be run on +multiple plattforms. An example of using (`pdm <https://pdm.fming.dev/latest/>`_) +with the packer extension to create a zipapp would be.: + +.. sourcecode:: bash + + $ pdm init + $ pdm add robotframework + $ #If the target is python 3.9 or older: pdm add importlib_resources + $ pdm pack -m robot:run_cli + +At this point you have created a pyz file. This pyz file can be uesed like this.: + +.. sourcecode:: bash + + $ python *.pyz example.robot diff --git a/src/robot/htmldata/common/__init__.py b/src/robot/htmldata/common/__init__.py new file mode 100644 index 00000000000..2442daa57b0 --- /dev/null +++ b/src/robot/htmldata/common/__init__.py @@ -0,0 +1,14 @@ +# 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. diff --git a/src/robot/htmldata/lib/__init__.py b/src/robot/htmldata/lib/__init__.py new file mode 100644 index 00000000000..2442daa57b0 --- /dev/null +++ b/src/robot/htmldata/lib/__init__.py @@ -0,0 +1,14 @@ +# 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. diff --git a/src/robot/htmldata/libdoc/__init__.py b/src/robot/htmldata/libdoc/__init__.py new file mode 100644 index 00000000000..2442daa57b0 --- /dev/null +++ b/src/robot/htmldata/libdoc/__init__.py @@ -0,0 +1,14 @@ +# 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. diff --git a/src/robot/htmldata/rebot/__init__.py b/src/robot/htmldata/rebot/__init__.py new file mode 100644 index 00000000000..2442daa57b0 --- /dev/null +++ b/src/robot/htmldata/rebot/__init__.py @@ -0,0 +1,14 @@ +# 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. diff --git a/src/robot/htmldata/template.py b/src/robot/htmldata/template.py index da4db531210..09067c472d6 100644 --- a/src/robot/htmldata/template.py +++ b/src/robot/htmldata/template.py @@ -14,16 +14,34 @@ # limitations under the License. import os -from os.path import abspath, dirname, join, normpath +import pathlib +import sys +if sys.version_info < (3, 10) and not pathlib.Path(__file__).exists(): + # Try importlib resources backport as prior to python 3.10 + # importlib.resources.files was not zipapp compatible... + try: + from importlib_resources import files + except ImportError: + err_msg = "Up to python <= 3.10 importlib-resources backport is " + err_msg += "required if __file__ does not exist (zipapps, " + err_msg += "pyodixizer etc...)" + raise ImportError(err_msg) +else: + try: + from importlib.resources import files + except ImportError: + # python 3.8 or earlier: + def files(modulepath): + base_dir = pathlib.Path(__file__).parent.parent.parent + return base_dir / modulepath.replace(".", os.sep) class HtmlTemplate: - _base_dir = join(dirname(abspath(__file__)), '..', 'htmldata') - def __init__(self, filename): - self._path = normpath(join(self._base_dir, filename.replace('/', os.sep))) - + module, self.filename = os.path.split(os.path.normpath(filename)) + self.module = 'robot.htmldata.' + module + def __iter__(self): - with open(self._path, encoding='UTF-8') as file: - for line in file: - yield line.rstrip() + with files(self.module).joinpath(self.filename).open('r', encoding="utf-8") as f: + for item in f: + yield item.rstrip() diff --git a/src/robot/htmldata/testdoc/__init__.py b/src/robot/htmldata/testdoc/__init__.py new file mode 100644 index 00000000000..2442daa57b0 --- /dev/null +++ b/src/robot/htmldata/testdoc/__init__.py @@ -0,0 +1,14 @@ +# 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. diff --git a/src/robot/pythonpathsetter.py b/src/robot/pythonpathsetter.py index 930fc7cb783..50ed6fe4fa3 100644 --- a/src/robot/pythonpathsetter.py +++ b/src/robot/pythonpathsetter.py @@ -13,7 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Module that adds directories needed by Robot to sys.path when imported.""" +"""Module that adds directories needed by Robot to sys.path when imported. + +By adapting the system configuration at runtime this module allows to use +robotframework without installing it. + +This is only relevant if robotframework installation is not handled bythe +environment. +""" import sys import fnmatch diff --git a/utest/htmldata/test_htmltemplate.py b/utest/htmldata/test_htmltemplate.py index 86f28cd6035..343bfe62312 100644 --- a/utest/htmldata/test_htmltemplate.py +++ b/utest/htmldata/test_htmltemplate.py @@ -2,7 +2,7 @@ from robot.htmldata.template import HtmlTemplate from robot.htmldata import LOG, REPORT -from robot.utils.asserts import assert_true, assert_raises, assert_equal +from robot.utils.asserts import assert_true, assert_equal, assert_raises class TestHtmlTemplate(unittest.TestCase): @@ -17,7 +17,7 @@ def test_lines_do_not_have_line_breaks(self): assert_true(not line.endswith('\n')) def test_non_existing(self): - assert_raises(IOError, list, HtmlTemplate('nonex.html')) + assert_raises((ImportError, IOError), list, HtmlTemplate('nonex.html')) if __name__ == "__main__": From 8abec456ea6b4b4abffca0b60db58e72394d8c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 23 Mar 2023 00:57:11 +0200 Subject: [PATCH 0454/1592] Fix method name in possible error message --- src/robot/model/body.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 3e8dece578a..4367ae24263 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -152,7 +152,7 @@ def create_message(self, *args, **kwargs): return self._create(self.message_class, 'create_message', args, kwargs) def create_error(self, *args, **kwargs): - return self._create(self.error_class, 'create_message', args, kwargs) + return self._create(self.error_class, 'create_error', args, kwargs) def filter(self, keywords=None, messages=None, predicate=None): """Filter body items based on type and/or custom predicate. From e3066332df67bccb9d9be4afa64b06ae6d474037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Mar 2023 19:35:02 +0300 Subject: [PATCH 0455/1592] Little cleanup related to #4613. --- src/robot/htmldata/template.py | 54 ++++++++++++++++++----------- utest/htmldata/test_htmltemplate.py | 6 +++- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/robot/htmldata/template.py b/src/robot/htmldata/template.py index 09067c472d6..78bdda46120 100644 --- a/src/robot/htmldata/template.py +++ b/src/robot/htmldata/template.py @@ -13,35 +13,47 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os -import pathlib import sys +from collections.abc import Iterable +from os.path import normpath +from pathlib import Path -if sys.version_info < (3, 10) and not pathlib.Path(__file__).exists(): - # Try importlib resources backport as prior to python 3.10 - # importlib.resources.files was not zipapp compatible... + +if sys.version_info < (3, 10) and not Path(__file__).exists(): + # `importlib.resources.files` is new in Python 3.9, but that version does + # not seem to be compatible with zipapp. try: from importlib_resources import files except ImportError: - err_msg = "Up to python <= 3.10 importlib-resources backport is " - err_msg += "required if __file__ does not exist (zipapps, " - err_msg += "pyodixizer etc...)" - raise ImportError(err_msg) + raise ImportError( + "'importlib_resources' backport module needs to be installed with " + "Python 3.9 and older when Robot Framework is distributed as a zip " + "package or '__file__' does not exist for other reasons." + ) else: try: from importlib.resources import files - except ImportError: - # python 3.8 or earlier: - def files(modulepath): - base_dir = pathlib.Path(__file__).parent.parent.parent - return base_dir / modulepath.replace(".", os.sep) - -class HtmlTemplate: - def __init__(self, filename): - module, self.filename = os.path.split(os.path.normpath(filename)) + except ImportError: # Python 3.8 or older + BASE_DIR = Path(__file__).absolute().parent.parent.parent + + def files(module): + return BASE_DIR / module.replace('.', '/') + + +class HtmlTemplate(Iterable): + + def __init__(self, path: 'Path|str'): + # Need to use `os.path.normpath` because `Path` does not support + # normalizing only `..` components. + path = Path(normpath(path)) + try: + module, self.name = path.parts + except ValueError: + raise ValueError(f"HTML template path must contain only directory and " + f"file names like 'rebot/log.html', got '{path}'.") self.module = 'robot.htmldata.' + module - + def __iter__(self): - with files(self.module).joinpath(self.filename).open('r', encoding="utf-8") as f: - for item in f: + with files(self.module).joinpath(self.name).open(encoding='UTF-8') as file: + for item in file: yield item.rstrip() diff --git a/utest/htmldata/test_htmltemplate.py b/utest/htmldata/test_htmltemplate.py index 343bfe62312..774c9eff8ac 100644 --- a/utest/htmldata/test_htmltemplate.py +++ b/utest/htmldata/test_htmltemplate.py @@ -16,8 +16,12 @@ def test_lines_do_not_have_line_breaks(self): for line in HtmlTemplate(REPORT): assert_true(not line.endswith('\n')) + def test_bad_path(self): + assert_raises(ValueError, HtmlTemplate, 'one_part.html') + assert_raises(ValueError, HtmlTemplate, 'more_than/two/parts.html') + def test_non_existing(self): - assert_raises((ImportError, IOError), list, HtmlTemplate('nonex.html')) + assert_raises((ImportError, IOError), list, HtmlTemplate('non/ex.html')) if __name__ == "__main__": From 01095165b53c7759c3ebfb15400f3330f1410d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Mar 2023 19:35:54 +0300 Subject: [PATCH 0456/1592] Cleanup: type hints, explicit ABCs --- src/robot/htmldata/__init__.py | 3 +- src/robot/htmldata/htmlfilewriter.py | 115 ++++++++++++++------------- 2 files changed, 62 insertions(+), 56 deletions(-) diff --git a/src/robot/htmldata/__init__.py b/src/robot/htmldata/__init__.py index e0b6864882c..38b64c93fc2 100644 --- a/src/robot/htmldata/__init__.py +++ b/src/robot/htmldata/__init__.py @@ -15,12 +15,13 @@ """Package for writing output files in HTML format. -This package is considered stable but it is not part of the public API. +This package is considered stable, but it is not part of the public API. """ from .htmlfilewriter import HtmlFileWriter, ModelWriter from .jsonwriter import JsonWriter + LOG = 'rebot/log.html' REPORT = 'rebot/report.html' LIBDOC = 'libdoc/libdoc.html' diff --git a/src/robot/htmldata/htmlfilewriter.py b/src/robot/htmldata/htmlfilewriter.py index bc18a6a2105..acab48983de 100644 --- a/src/robot/htmldata/htmlfilewriter.py +++ b/src/robot/htmldata/htmlfilewriter.py @@ -13,8 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os.path import re +from abc import ABC, abstractmethod +from io import TextIOBase +from pathlib import Path from robot.utils import HtmlWriter from robot.version import get_full_version @@ -24,92 +26,95 @@ class HtmlFileWriter: - def __init__(self, output, model_writer): - self._output = output - self._model_writer = model_writer + def __init__(self, output: TextIOBase, model_writer: 'ModelWriter'): + self.output = output + self.model_writer = model_writer - def write(self, template): - writers = self._get_writers(os.path.dirname(template)) + def write(self, template: 'Path|str'): + if not isinstance(template, Path): + template = Path(template) + writers = self._get_writers(template.parent) for line in HtmlTemplate(template): for writer in writers: if writer.handles(line): writer.write(line) break - def _get_writers(self, base_dir): - html_writer = HtmlWriter(self._output) - return (self._model_writer, - JsFileWriter(html_writer, base_dir), - CssFileWriter(html_writer, base_dir), - GeneratorWriter(html_writer), - LineWriter(self._output)) + def _get_writers(self, base_dir: Path): + writer = HtmlWriter(self.output) + return (self.model_writer, + JsFileWriter(writer, base_dir), + CssFileWriter(writer, base_dir), + GeneratorWriter(writer), + LineWriter(self.output)) -class _Writer: - _handles_line = None +class Writer(ABC): + handles_line = None - def handles(self, line): - return line.startswith(self._handles_line) + def handles(self, line: str): + return line.startswith(self.handles_line) - def write(self, line): + @abstractmethod + def write(self, line: str): raise NotImplementedError -class ModelWriter(_Writer): - _handles_line = '<!-- JS MODEL -->' +class ModelWriter(Writer, ABC): + handles_line = '<!-- JS MODEL -->' -class LineWriter(_Writer): +class LineWriter(Writer): - def __init__(self, output): - self._output = output + def __init__(self, output: TextIOBase): + self.output = output - def handles(self, line): + def handles(self, line: str): return True - def write(self, line): - self._output.write(line + '\n') + def write(self, line: str): + self.output.write(line + '\n') -class GeneratorWriter(_Writer): - _handles_line = '<meta name="Generator" content=' +class GeneratorWriter(Writer): + handles_line = '<meta name="Generator" content=' - def __init__(self, html_writer): - self._html_writer = html_writer + def __init__(self, writer: HtmlWriter): + self.writer = writer - def write(self, line): + def write(self, line: str): version = get_full_version('Robot Framework') - self._html_writer.start('meta', {'name': 'Generator', 'content': version}) + self.writer.start('meta', {'name': 'Generator', 'content': version}) -class _InliningWriter(_Writer): +class InliningWriter(Writer, ABC): - def __init__(self, html_writer, base_dir): - self._html_writer = html_writer - self._base_dir = base_dir + def __init__(self, writer: HtmlWriter, base_dir: Path): + self.writer = writer + self.base_dir = base_dir - def _inline_file(self, filename, tag, attrs): - self._html_writer.start(tag, attrs) - for line in HtmlTemplate(os.path.join(self._base_dir, filename)): - self._html_writer.content(line, escape=False, newline=True) - self._html_writer.end(tag) + def inline_file(self, path: 'Path|str', tag: str, attrs: dict): + self.writer.start(tag, attrs) + for line in HtmlTemplate(self.base_dir / path): + self.writer.content(line, escape=False, newline=True) + self.writer.end(tag) -class JsFileWriter(_InliningWriter): - _handles_line = '<script type="text/javascript" src=' - _source_file = re.compile('src=\"([^\"]+)\"') +class JsFileWriter(InliningWriter): + handles_line = '<script type="text/javascript" src=' + source_file = re.compile('src=\"([^\"]+)\"') - def write(self, line): - name = self._source_file.search(line).group(1) - self._inline_file(name, 'script', {'type': 'text/javascript'}) + def write(self, line: str): + name = self.source_file.search(line).group(1) + self.inline_file(name, 'script', {'type': 'text/javascript'}) -class CssFileWriter(_InliningWriter): - _handles_line = '<link rel="stylesheet"' - _source_file = re.compile('href=\"([^\"]+)\"') - _media_type = re.compile('media=\"([^\"]+)\"') +class CssFileWriter(InliningWriter): + handles_line = '<link rel="stylesheet"' + source_file = re.compile('href=\"([^\"]+)\"') + media_type = re.compile('media=\"([^\"]+)\"') - def write(self, line): - name = self._source_file.search(line).group(1) - media = self._media_type.search(line).group(1) - self._inline_file(name, 'style', {'type': 'text/css', 'media': media}) + def write(self, line: str): + name = self.source_file.search(line).group(1) + media = self.media_type.search(line).group(1) + self.inline_file(name, 'style', {'type': 'text/css', 'media': media}) From eda9b49eb08c6dcb5de7c404fef327d76266d44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Mar 2023 20:54:09 +0300 Subject: [PATCH 0457/1592] Avoid import time re.compile. Benefits of pre-compilation aren't big enough compared to time used at import time in these cases. Also enhance grammar in documentation. --- src/robot/htmldata/htmlfilewriter.py | 12 ++++-------- src/robot/libraries/XML.py | 15 +++++++-------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/robot/htmldata/htmlfilewriter.py b/src/robot/htmldata/htmlfilewriter.py index acab48983de..e2ef8ce8a4e 100644 --- a/src/robot/htmldata/htmlfilewriter.py +++ b/src/robot/htmldata/htmlfilewriter.py @@ -102,19 +102,15 @@ def inline_file(self, path: 'Path|str', tag: str, attrs: dict): class JsFileWriter(InliningWriter): handles_line = '<script type="text/javascript" src=' - source_file = re.compile('src=\"([^\"]+)\"') def write(self, line: str): - name = self.source_file.search(line).group(1) - self.inline_file(name, 'script', {'type': 'text/javascript'}) + src = re.search('src="([^"]+)"', line).group(1) + self.inline_file(src, 'script', {'type': 'text/javascript'}) class CssFileWriter(InliningWriter): handles_line = '<link rel="stylesheet"' - source_file = re.compile('href=\"([^\"]+)\"') - media_type = re.compile('media=\"([^\"]+)\"') def write(self, line: str): - name = self.source_file.search(line).group(1) - media = self.media_type.search(line).group(1) - self.inline_file(name, 'style', {'type': 'text/css', 'media': media}) + href, media = re.search('href="([^"]+)" media="([^"]+)"', line).groups() + self.inline_file(href, 'style', {'type': 'text/css', 'media': media}) diff --git a/src/robot/libraries/XML.py b/src/robot/libraries/XML.py index 8d3205f8990..e8da5c6dd0e 100644 --- a/src/robot/libraries/XML.py +++ b/src/robot/libraries/XML.py @@ -456,20 +456,19 @@ class XML: """ ROBOT_LIBRARY_SCOPE = 'GLOBAL' ROBOT_LIBRARY_VERSION = get_version() - _xml_declaration = re.compile('^<\?xml .*\?>') def __init__(self, use_lxml=False): """Import library with optionally lxml mode enabled. - By default this library uses Python's standard + This library uses Python's standard [http://docs.python.org/library/xml.etree.elementtree.html|ElementTree] - module for parsing XML. If ``use_lxml`` argument is given a true value - (see `Boolean arguments`), the library will use [http://lxml.de|lxml] - module instead. See `Using lxml` section for benefits provided by lxml. + module for parsing XML by default. If ``use_lxml`` argument is given + a true value (see `Boolean arguments`), the [http://lxml.de|lxml] module + is used instead. See the `Using lxml` section for benefits provided by lxml. Using lxml requires that the lxml module is installed on the system. If lxml mode is enabled but the module is not installed, this library - will emit a warning and revert back to using the standard ElementTree. + emits a warning and reverts back to using the standard ElementTree. """ use_lxml = is_truthy(use_lxml) if use_lxml and lxml_etree: @@ -1302,7 +1301,7 @@ def element_to_string(self, source, xpath='.', encoding=None): ``xpath``. They have exactly the same semantics as with `Get Element` keyword. - By default the string is returned as Unicode. If ``encoding`` argument + The string is returned as Unicode by default. If ``encoding`` argument is given any value, the string is returned as bytes in the specified encoding. The resulting string never contains the XML declaration. @@ -1310,7 +1309,7 @@ def element_to_string(self, source, xpath='.', encoding=None): """ source = self.get_element(source, xpath) string = self.etree.tostring(source, encoding='UTF-8').decode('UTF-8') - string = self._xml_declaration.sub('', string).strip() + string = re.sub(r'^<\?xml .*\?>', '', string).strip() if encoding: string = string.encode(encoding) return string From ed65b191ae221450813ebd0c529efc06709f722c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Mar 2023 22:33:07 +0300 Subject: [PATCH 0458/1592] Some more pathlib.Path usage. Also enhance `console_encode` to work with non-strings. --- src/robot/libdoc.py | 6 +++--- src/robot/testdoc.py | 4 ++-- src/robot/utils/encoding.py | 8 +++++--- utest/utils/test_encoding.py | 28 ++++++++++++++++++++++------ 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/robot/libdoc.py b/src/robot/libdoc.py index 74045608e69..c9f33cb08fa 100755 --- a/src/robot/libdoc.py +++ b/src/robot/libdoc.py @@ -34,7 +34,7 @@ """ import sys -import os +from pathlib import Path # Allows running as a script. __name__ check needed with multiprocessing: # https://github.com/robotframework/robotframework/issues/1137 @@ -198,13 +198,13 @@ def main(self, args, name='', version='', format=None, docformat=None, libdoc.convert_docs_to_html() libdoc.save(output, format, self._validate_theme(theme, format)) if not quiet: - self.console(os.path.abspath(output)) + self.console(Path(output).absolute()) def _get_docformat(self, docformat): return self._validate('Doc format', docformat, 'ROBOT', 'TEXT', 'HTML', 'REST') def _get_format_and_specdocformat(self, format, specdocformat, output): - extension = os.path.splitext(output)[1][1:] + extension = Path(output).suffix[1:] format = self._validate('Format', format or extension, 'HTML', 'XML', 'JSON', 'LIBSPEC') specdocformat = self._validate('Spec doc format', specdocformat, 'RAW', 'HTML') diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index 8d0a52e355b..83b926b7955 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -29,9 +29,9 @@ that can be used programmatically. Other code is for internal usage. """ -import os.path import sys import time +from pathlib import Path # Allows running as a script. __name__ check needed with multiprocessing: # https://github.com/robotframework/robotframework/issues/1137 @@ -186,7 +186,7 @@ def _convert_suite(self, suite): def _get_relative_source(self, source): if not source or not self._output_path: return '' - return get_link_path(source, os.path.dirname(self._output_path)) + return get_link_path(source, Path(self._output_path).parent) def _escape(self, item): return html_escape(item) diff --git a/src/robot/utils/encoding.py b/src/robot/utils/encoding.py index 3534a6ad0f5..cdc14588d4a 100644 --- a/src/robot/utils/encoding.py +++ b/src/robot/utils/encoding.py @@ -30,7 +30,7 @@ def console_decode(string, encoding=CONSOLE_ENCODING): """Decodes bytes from console encoding to Unicode. - By default uses the system console encoding, but that can be configured + Uses the system console encoding by default, but that can be configured using the `encoding` argument. In addition to the normal encodings, it is possible to use case-insensitive values `CONSOLE` and `SYSTEM` to use the system console and system encoding, respectively. @@ -56,15 +56,17 @@ def console_encode(string, encoding=None, errors='replace', stream=sys.__stdout_ case-insensitive values `CONSOLE` and `SYSTEM` to use the system console and system encoding, respectively. - By default decodes bytes back to Unicode because Python 3 APIs in general + Decodes bytes back to Unicode by default, because Python 3 APIs in general work with strings. Use `force=True` if that is not desired. """ + if not is_string(string): + string = safe_str(string) if encoding: encoding = {'CONSOLE': CONSOLE_ENCODING, 'SYSTEM': SYSTEM_ENCODING}.get(encoding.upper(), encoding) else: encoding = _get_console_encoding(stream) - if encoding != 'UTF-8': + if encoding.upper() != 'UTF-8': encoded = string.encode(encoding, errors) return encoded if force else encoded.decode(encoding) return string.encode(encoding, errors) if force else string diff --git a/utest/utils/test_encoding.py b/utest/utils/test_encoding.py index 0fbfbae8080..c3ea20f63a9 100644 --- a/utest/utils/test_encoding.py +++ b/utest/utils/test_encoding.py @@ -1,21 +1,37 @@ import unittest from robot.utils.asserts import assert_equal -from robot.utils.encoding import console_decode, CONSOLE_ENCODING +from robot.utils.encoding import console_decode, console_encode, CONSOLE_ENCODING -UNICODE = u'hyv\xe4' +UNICODE = 'hyvä' ENCODED = UNICODE.encode(CONSOLE_ENCODING) -class TestDecodeOutput(unittest.TestCase): - - def test_return_unicode_as_is_by_default(self): - assert_equal(console_decode(UNICODE), UNICODE) +class TestConsoleDecode(unittest.TestCase): def test_decode(self): assert_equal(console_decode(ENCODED), UNICODE) + def test_unicode_is_returned_as_is(self): + assert_equal(console_decode(UNICODE), UNICODE) + + +class TestConsoleEncode(unittest.TestCase): + + def test_unicode_is_returned_as_is_by_default(self): + assert_equal(console_encode(UNICODE), UNICODE) + + def test_force_encoding(self): + assert_equal(console_encode(UNICODE, force=True), ENCODED) + + def test_encoding_error(self): + assert_equal(console_encode(UNICODE, 'ASCII'), 'hyv?') + assert_equal(console_encode(UNICODE, 'ASCII', force=True), b'hyv?') + + def test_non_string(self): + assert_equal(console_encode(42), '42') + if __name__ == '__main__': unittest.main() From 9a87af3b7414a48a8d618109851d08dc04310d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Mar 2023 22:51:03 +0300 Subject: [PATCH 0459/1592] Simpler `pythonpathsetter`. Includes better documentation. --- src/robot/__main__.py | 10 +++------ src/robot/libdoc.py | 5 ++--- src/robot/pythonpathsetter.py | 38 ++++++++++------------------------- src/robot/rebot.py | 5 ++--- src/robot/run.py | 5 ++--- src/robot/testdoc.py | 5 ++--- 6 files changed, 22 insertions(+), 46 deletions(-) diff --git a/src/robot/__main__.py b/src/robot/__main__.py index c4727ca9e89..82e33df3654 100755 --- a/src/robot/__main__.py +++ b/src/robot/__main__.py @@ -15,14 +15,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys - -# Allows running as a script. __name__ check needed with multiprocessing: -# https://github.com/robotframework/robotframework/issues/1137 -if 'robot' not in sys.modules and __name__ == '__main__': +# Allows running as a script. +if __name__ == '__main__': import pythonpathsetter from robot import run_cli - -run_cli(sys.argv[1:]) +run_cli() diff --git a/src/robot/libdoc.py b/src/robot/libdoc.py index c9f33cb08fa..59d0f3d0e74 100755 --- a/src/robot/libdoc.py +++ b/src/robot/libdoc.py @@ -36,9 +36,8 @@ import sys from pathlib import Path -# Allows running as a script. __name__ check needed with multiprocessing: -# https://github.com/robotframework/robotframework/issues/1137 -if 'robot' not in sys.modules and __name__ == '__main__': +# Allows running as a script. +if __name__ == '__main__': import pythonpathsetter from robot.utils import Application, seq2str diff --git a/src/robot/pythonpathsetter.py b/src/robot/pythonpathsetter.py index 50ed6fe4fa3..b3ed1973c2f 100644 --- a/src/robot/pythonpathsetter.py +++ b/src/robot/pythonpathsetter.py @@ -13,36 +13,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Module that adds directories needed by Robot to sys.path when imported. +"""Modifies `sys.path` if Robot Framework's entry points are run as scripts. -By adapting the system configuration at runtime this module allows to use -robotframework without installing it. +When, for example, `robot/run.py` or `robot/libdoc.py` is executed as a script, +the `robot` directory is in `sys.path` but its parent directory is not. +Importing this module adds the parent directory to `sys.path` to make it +possible to import the `robot` module. The `robot` directory itself is removed +to prevent importing internal modules directly. -This is only relevant if robotframework installation is not handled bythe -environment. +Does nothing if the `robot` module is already imported. """ import sys -import fnmatch -from os.path import abspath, dirname +from pathlib import Path -ROBOTDIR = dirname(abspath(__file__)) - -def add_path(path, end=False): - if not end: - remove_path(path) - sys.path.insert(0, path) - elif not any(fnmatch.fnmatch(p, path) for p in sys.path): - sys.path.append(path) - -def remove_path(path): - sys.path = [p for p in sys.path if not fnmatch.fnmatch(p, path)] - - -# When, for example, robot/run.py is executed as a script, the directory -# containing the robot module is not added to sys.path automatically but -# the robot directory itself is. Former is added to allow importing -# the module and the latter removed to prevent accidentally importing -# internal modules directly. -add_path(dirname(ROBOTDIR)) -remove_path(ROBOTDIR) +if 'robot' not in sys.modules: + robot_dir = Path(__file__).absolute().parent + sys.path = [str(robot_dir.parent)] + [p for p in sys.path if Path(p) != robot_dir] diff --git a/src/robot/rebot.py b/src/robot/rebot.py index 6e9139a9bc7..3964ea22760 100755 --- a/src/robot/rebot.py +++ b/src/robot/rebot.py @@ -32,9 +32,8 @@ import sys -# Allows running as a script. __name__ check needed with multiprocessing: -# https://github.com/robotframework/robotframework/issues/1137 -if 'robot' not in sys.modules and __name__ == '__main__': +# Allows running as a script. +if __name__ == '__main__': import pythonpathsetter from robot.conf import RebotSettings diff --git a/src/robot/run.py b/src/robot/run.py index b3372bf1cd3..1d881cf2f13 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -32,9 +32,8 @@ import sys -# Allows running as a script. __name__ check needed with multiprocessing: -# https://github.com/robotframework/robotframework/issues/1137 -if 'robot' not in sys.modules and __name__ == '__main__': +# Allows running as a script. +if __name__ == '__main__': import pythonpathsetter from robot.conf import RobotSettings diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index 83b926b7955..99c9ca050c6 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -33,9 +33,8 @@ import time from pathlib import Path -# Allows running as a script. __name__ check needed with multiprocessing: -# https://github.com/robotframework/robotframework/issues/1137 -if 'robot' not in sys.modules and __name__ == '__main__': +# Allows running as a script. +if __name__ == '__main__': import pythonpathsetter from robot.conf import RobotSettings From 417b2041ee044d1fe34abeff2ab52d570ad53a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 27 Mar 2023 22:54:52 +0300 Subject: [PATCH 0460/1592] Remove zipapp section added as part of #4613. Creating zipapps is more about distribution than installation. It could be documented somewhere in the User Guide, but I'm not sure is that really needed either. There's nothing special about that with Robot other than the need to use `importlib_resources` with Python 3.9 and older, but we have an explicit error in the code related to that. --- INSTALL.rst | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/INSTALL.rst b/INSTALL.rst index 2b324d8e126..525e625fcbb 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -322,25 +322,3 @@ __ https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtua .. _PATH: `Configuring path`_ .. _PyPI: https://pypi.org/project/robotframework .. _GitHub: https://github.com/robotframework/robotframework - -Zipapp --------------------- - -`Zipapps <https://docs.python.org/3/library/zipapp.html>`_ are a technique to -distribute all the python code of a solution in a single file, which can -be executed using a python interpreter. The same zipapp file can be run on -multiple plattforms. An example of using (`pdm <https://pdm.fming.dev/latest/>`_) -with the packer extension to create a zipapp would be.: - -.. sourcecode:: bash - - $ pdm init - $ pdm add robotframework - $ #If the target is python 3.9 or older: pdm add importlib_resources - $ pdm pack -m robot:run_cli - -At this point you have created a pyz file. This pyz file can be uesed like this.: - -.. sourcecode:: bash - - $ python *.pyz example.robot From 89e0ba147fbee5226565daf2654c2824504fa439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sat, 25 Mar 2023 17:17:03 +0200 Subject: [PATCH 0461/1592] While: make condition optional Add/modify tests and udpate documentation as well. Fixes #4576 --- atest/robot/running/while/invalid_while.robot | 4 ---- atest/robot/running/while/while.robot | 3 +++ atest/robot/running/while/while_limit.robot | 3 +++ atest/testdata/running/while/invalid_while.robot | 6 ------ atest/testdata/running/while/while.robot | 8 ++++++++ atest/testdata/running/while/while_limit.robot | 6 ++++++ doc/userguide/src/CreatingTestData/ControlStructures.rst | 6 +++++- src/robot/parsing/model/statements.py | 2 -- src/robot/running/bodyrunner.py | 2 ++ 9 files changed, 27 insertions(+), 13 deletions(-) diff --git a/atest/robot/running/while/invalid_while.robot b/atest/robot/running/while/invalid_while.robot index d591dd5ce9a..6a96f911368 100644 --- a/atest/robot/running/while/invalid_while.robot +++ b/atest/robot/running/while/invalid_while.robot @@ -3,10 +3,6 @@ Resource while.resource Suite Setup Run Tests --log test_result_model_as_well running/while/invalid_while.robot *** Test Cases *** -No condition - ${tc} = Check Invalid WHILE Test Case - Should Be Equal ${tc.body[0].condition} ${NONE} - Multiple conditions ${tc} = Check Invalid WHILE Test Case Should Be Equal ${tc.body[0].condition} Too, many, ! diff --git a/atest/robot/running/while/while.robot b/atest/robot/running/while/while.robot index b7c3bd82747..45b028f951f 100644 --- a/atest/robot/running/while/while.robot +++ b/atest/robot/running/while/while.robot @@ -17,6 +17,9 @@ Loop not executed Should Be Equal ${item.status} NOT RUN END +No Condition + Check While Loop PASS 5 + Execution fails on the first loop Check While Loop FAIL 1 diff --git a/atest/robot/running/while/while_limit.robot b/atest/robot/running/while/while_limit.robot index e1d29da9799..b00b40c1c4c 100644 --- a/atest/robot/running/while/while_limit.robot +++ b/atest/robot/running/while/while_limit.robot @@ -29,6 +29,9 @@ Part of limit from variable Limit can be disabled Check Test Case ${TESTNAME} +No Condition With Limit + Check Test Case ${TESTNAME} + Invalid limit invalid suffix Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/while/invalid_while.robot b/atest/testdata/running/while/invalid_while.robot index 77ac370eb11..4be886f5cf8 100644 --- a/atest/testdata/running/while/invalid_while.robot +++ b/atest/testdata/running/while/invalid_while.robot @@ -1,10 +1,4 @@ *** Test Cases *** -No condition - [Documentation] FAIL WHILE must have a condition. - WHILE - Fail Not executed! - END - Multiple conditions [Documentation] FAIL WHILE cannot have more than one condition. WHILE Too many ! diff --git a/atest/testdata/running/while/while.robot b/atest/testdata/running/while/while.robot index 55f1be5f3ad..f131b5f2256 100644 --- a/atest/testdata/running/while/while.robot +++ b/atest/testdata/running/while/while.robot @@ -20,6 +20,14 @@ Loop not executed Not executed either END +No condition + WHILE + ${variable}= Evaluate $variable + 1 + IF ${variable} > 5 + BREAK + END + END + Execution fails on the first loop [Documentation] FAIL Oh no WHILE $variable < 2 diff --git a/atest/testdata/running/while/while_limit.robot b/atest/testdata/running/while/while_limit.robot index 688eb3cd41f..3c8b7ecccc2 100644 --- a/atest/testdata/running/while/while_limit.robot +++ b/atest/testdata/running/while/while_limit.robot @@ -52,6 +52,12 @@ Limit can be disabled ${variable}= Evaluate $variable + 1 END +No condition with limit + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 2 iterations. Use the 'limit' argument to increase or remove the limit if needed. + WHILE limit=2 + Log Hello + END + Invalid limit invalid suffix [Documentation] FAIL Invalid WHILE loop limit: Invalid time string '1 times'. WHILE $variable < 2 limit=1 times diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index 021c153ecd1..5efc24f04f8 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -596,7 +596,11 @@ The latter approach is handy when the string representation of the variable cann used in the condition directly. For example, strings require quoting and multiline strings and string themselves containing quotes cause additional problems. See the `Evaluating expressions`_ appendix for more information and examples related to -the evaluation syntax +the evaluation syntax. + +Starting from Robot Framework 6.1, the condition in a `WHILE` statement can be omitted. +This is interpreted as the condition always being true, which may be useful with the +`limit` option described below. Limiting `WHILE` loop iterations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 400e0bc987b..9d34c2ef8df 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -1035,8 +1035,6 @@ def limit(self): def validate(self, ctx: 'ValidationContext'): values = self.get_values(Token.ARGUMENT) - if len(values) == 0: - self.errors += ('WHILE must have a condition.',) if len(values) == 2: self.errors += (f"Second WHILE loop argument must be 'limit', " f"got '{values[1]}'.",) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 02bdfd55ec2..67abbf20625 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -421,6 +421,8 @@ def _run_iteration(self, data, result, run=True): runner.run(data.body) def _should_run(self, condition, variables): + if not condition: + return True try: return evaluate_expression(condition, variables.current, resolve_variables=True) From 72cde3b519ca3547dd24141f33a10e5e87a88cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Mar 2023 14:36:01 +0300 Subject: [PATCH 0462/1592] Fix nested arg conversion when value is object, not string. Now arguments with nested types are always converted. An alternative would have been checking do nested values have correct types. That could have been better, but it would have also been lot more work. Fixes #4705. --- .../type_conversion/annotations.robot | 4 +++ .../annotations_with_typing.robot | 28 +++++++++++++++---- .../type_conversion/custom_converters.robot | 5 ++++ src/robot/running/arguments/typeconverters.py | 23 +++++++++++---- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/atest/testdata/keywords/type_conversion/annotations.robot b/atest/testdata/keywords/type_conversion/annotations.robot index 45a6700dc5c..87a5668088b 100644 --- a/atest/testdata/keywords/type_conversion/annotations.robot +++ b/atest/testdata/keywords/type_conversion/annotations.robot @@ -10,6 +10,7 @@ ${FRACTION 1/2} ${{fractions.Fraction(1,2)}} ${DECIMAL 1/2} ${{decimal.Decimal('0.5')}} ${DEQUE} ${{collections.deque([1, 2, 3])}} ${MAPPING} ${{type('M', (collections.abc.Mapping,), {'__getitem__': lambda s, k: {'a': 1}[k], '__iter__': lambda s: iter({'a': 1}), '__len__': lambda s: 1})()}} +${SEQUENCE} ${{type('S', (collections.abc.Sequence,), {'__getitem__': lambda s, i: ['x'][i], '__len__': lambda s: 1})()}} ${PATH} ${{pathlib.Path('x/y')}} ${PUREPATH} ${{pathlib.PurePath('x/y')}} @@ -354,6 +355,7 @@ List List ${{[1, 2]}} [1, 2] List ${{(1, 2)}} [1, 2] List ${DEQUE} [1, 2, 3] + List ${SEQUENCE} ['x'] Invalid list [Template] Conversion Should Fail @@ -370,9 +372,11 @@ Sequence (abc) Sequence [] [] Sequence ['foo', 'bar'] ${LIST} Sequence ${DEQUE} collections.deque([1, 2, 3]) + Sequence ${SEQUENCE} ${SEQUENCE} Mutable sequence [1, 2, 3.14, -42] [1, 2, 3.14, -42] Mutable sequence ['\\x00', '\\x52'] ['\\x00', 'R'] Mutable sequence ${DEQUE} collections.deque([1, 2, 3]) + Mutable sequence ${SEQUENCE} ['x'] Invalid sequence (abc) [Template] Conversion Should Fail diff --git a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot index 7c38950424f..66935233eb7 100644 --- a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot @@ -14,11 +14,14 @@ List with types List with types [] [] List with types [1, 2, 3, -42] [1, 2, 3, -42] List with types [1, '2', 3.0] [1, 2, 3] + List with types ${{[1, '2', 3.0]}} [1, 2, 3] List with incompatible types [Template] Conversion Should Fail List with types ['foo', 'bar'] type=List[int] error=Item '0' got value 'foo' that cannot be converted to integer. List with types [0, 1, 2, 3, 4, 5, 6.1] type=List[int] error=Item '6' got value '6.1' (float) that cannot be converted to integer: Conversion would lose precision. + List with types ${{[0.0, 1.1]}} type=List[int] error=Item '1' got value '1.1' (float) that cannot be converted to integer: Conversion would lose precision. + ... arg_type=list Invalid list [Template] Conversion Should Fail @@ -34,17 +37,22 @@ Tuple Tuple with types Tuple with types ('true', 1) (True, 1) Tuple with types ('ei', '2') (False, 2) # 'ei' -> False is due to language config + Tuple with types ${{('no', '3')}} (False, 3) Tuple with homogenous types Homogenous tuple () () Homogenous tuple (1,) (1,) - Homogenous tuple (1, 2) (1, 2) - Homogenous tuple (1, 2, 3, 4, 5, 6, 7) (1, 2, 3, 4, 5, 6, 7) + Homogenous tuple ('1',) (1,) + Homogenous tuple (1, '2') (1, 2) + Homogenous tuple (1, '2', 3.0, 4, 5) (1, 2, 3, 4, 5) + Homogenous tuple ${{(1, '2', 3.0)}} (1, 2, 3) Tuple with incompatible types [Template] Conversion Should Fail Tuple with types ('bad', 'values') type=Tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. Homogenous tuple ('bad', 'values') type=Tuple[int, ...] error=Item '0' got value 'bad' that cannot be converted to integer. + Tuple with types ${{('bad', 'values')}} type=Tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. + ... arg_type=tuple Tuple with wrong number of values [Template] Conversion Should Fail @@ -67,6 +75,7 @@ Sequence with types Sequence with types [1, 2.3, '4', '5.6'] [1, 2.3, 4, 5.6] Mutable sequence with types ... [1, 2, 3.0, '4'] [1, 2, 3, 4] + Sequence with types ${{[1, 2.3, '4', '5.6']}} [1, 2.3, 4, 5.6] Sequence with incompatible types [Template] Conversion Should Fail @@ -88,6 +97,7 @@ Dict with types Dict with types {} {} Dict with types {1: 1.1, 2: 2.2} {1: 1.1, 2: 2.2} Dict with types {'1': '2', 3.0: 4} {1: 2, 3: 4} + Dict with types ${{{'1': '2', 3.0: 4}}} {1: 2, 3: 4} Dict with incompatible types [Template] Conversion Should Fail @@ -110,7 +120,11 @@ Mapping Mapping with types Mapping with types {} {} Mapping with types {1: 2, '3': 4.0} {1: 2, 3: 4} - Mutable mapping with types {1: 2, '3': 4.0} {1: 2, 3: 4} + Mapping with types ${{{1: 2, '3': 4.0}}} {1: 2, 3: 4} + Mutable mapping with types + ... {1: 2, '3': 4.0} {1: 2, 3: 4} + Mutable mapping with types + ... ${{{1: 2, '3': 4.0}}} {1: 2, 3: 4} Mapping with incompatible types [Template] Conversion Should Fail @@ -124,12 +138,14 @@ Invalid mapping Mapping with types ooops type=Mapping[int, float] error=Invalid expression. TypedDict - TypedDict {'x': 1, 'y': 2} {'x': 1, 'y': 2} + TypedDict {'x': 1, 'y': 2.0} {'x': 1, 'y': 2} TypedDict {'x': -10_000, 'y': '2'} {'x': -10000, 'y': 2} + TypedDict ${{{'x': 1, 'y': '2'}}} {'x': 1, 'y': 2} TypedDict with optional {'x': 1, 'y': 2, 'z': 3} {'x': 1, 'y': 2, 'z': 3} Optional TypedDict keys can be omitted - TypedDict with optional {'x': 0, 'y': 0} {'x': 0, 'y': 0} + TypedDict with optional {'x': 0, 'y': '0'} {'x': 0, 'y': 0} + TypedDict with optional ${{{'x': 0, 'y': '0'}}} {'x': 0, 'y': 0} Required TypedDict keys cannot be omitted [Documentation] This test would fail if using Python 3.8 without typing_extensions! @@ -160,7 +176,9 @@ Set Set with types Set with types set() set() Set with types {1, 2.0, '3'} {1, 2, 3} + Set with types ${{{1, 2.0, '3'}}} {1, 2, 3} Mutable set with types {1, 2, 3.14, -42} {1, 2, 3.14, -42} + Mutable set with types ${{{1, 2, 3.14, -42}}} {1, 2, 3.14, -42} Set with incompatible types [Template] Conversion Should Fail diff --git a/atest/testdata/keywords/type_conversion/custom_converters.robot b/atest/testdata/keywords/type_conversion/custom_converters.robot index fc13bbf7358..3ed529f74bb 100644 --- a/atest/testdata/keywords/type_conversion/custom_converters.robot +++ b/atest/testdata/keywords/type_conversion/custom_converters.robot @@ -50,6 +50,11 @@ With generics ... ('28.9.2022', '9/28/2022') ... {'one': '28.9.2022'} ... {'one', 'two', 'three'} + With generics + ... ${{['one', 'two', 'three']}} + ... ${{('28.9.2022', '9/28/2022')}} + ... ${{{'one': '28.9.2022'}}} + ... ${{{'one', 'two', 'three'}}} With TypedDict TypedDict {'fi': '29.9.2022', 'us': '9/29/2022'} diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 8182ca4edf1..d310c1348be 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -100,11 +100,11 @@ def convert(self, name, value, explicit_type=True, strict=True, kind='Argument') return self._handle_error(name, value, kind, error, strict) def no_conversion_needed(self, value): + used_type = getattr(self.used_type, '__origin__', self.used_type) try: - return isinstance(value, self.used_type) + return isinstance(value, used_type) except TypeError: - # If the used type doesn't like `isinstance` (e.g. TypedDict), - # compare the value to the generic type instead. + # Used type wasn't a class. Compare to generic type instead. if self.type and self.type is not self.used_type: return isinstance(value, self.type) raise @@ -460,7 +460,7 @@ def handles(cls, type_): return super().handles(type_) and type_ is not Tuple def no_conversion_needed(self, value): - if isinstance(value, str): + if isinstance(value, str) or self.converter: return False return super().no_conversion_needed(value) @@ -500,6 +500,9 @@ def __init__(self, used_type, custom_converters=None, languages=None): self.converters = tuple(self.converter_for(t, custom_converters, languages) for t in types) + def no_conversion_needed(self, value): + return super().no_conversion_needed(value) and not self.converters + def _non_string_convert(self, value, explicit_type=True): return self._convert_items(tuple(value), explicit_type) @@ -587,11 +590,18 @@ def __init__(self, used_type, custom_converters=None, languages=None): self.converters = tuple(self.converter_for(t, custom_converters, languages) for t in types) + def no_conversion_needed(self, value): + return super().no_conversion_needed(value) and not self.converters + def _non_string_convert(self, value, explicit_type=True): - if issubclass(self.used_type, dict) and not isinstance(value, dict): + if self._used_type_is_dict() and not isinstance(value, dict): value = dict(value) return self._convert_items(value, explicit_type) + def _used_type_is_dict(self): + used_type = getattr(self.used_type, '__origin__', self.used_type) + return issubclass(used_type, dict) + def _convert(self, value, explicit_type=True): return self._convert_items(self._literal_eval(value, dict), explicit_type) @@ -625,6 +635,9 @@ def __init__(self, used_type, custom_converters=None, languages=None): self.type_name = type_repr(used_type) self.converter = self.converter_for(types[0], custom_converters, languages) + def no_conversion_needed(self, value): + return super().no_conversion_needed(value) and not self.converter + def _non_string_convert(self, value, explicit_type=True): return self._convert_items(set(value), explicit_type) From 156b8075abc6fe92125e29dc6e5e98634d39dac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Mar 2023 14:49:18 +0300 Subject: [PATCH 0463/1592] Windows unit test fix --- utest/utils/test_encoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utest/utils/test_encoding.py b/utest/utils/test_encoding.py index c3ea20f63a9..70274add569 100644 --- a/utest/utils/test_encoding.py +++ b/utest/utils/test_encoding.py @@ -23,7 +23,7 @@ def test_unicode_is_returned_as_is_by_default(self): assert_equal(console_encode(UNICODE), UNICODE) def test_force_encoding(self): - assert_equal(console_encode(UNICODE, force=True), ENCODED) + assert_equal(console_encode(UNICODE, 'UTF-8', force=True), b'hyv\xc3\xa4') def test_encoding_error(self): assert_equal(console_encode(UNICODE, 'ASCII'), 'hyv?') From 6618b6b6e8fe83e2a9a2e72a9eb5b3f7ba653896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Mar 2023 16:22:30 +0300 Subject: [PATCH 0464/1592] Remove unused imports --- src/robot/running/statusreporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index 1e4f573eb8d..c80121952ea 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -14,7 +14,7 @@ # limitations under the License. from robot.errors import (ExecutionFailed, ExecutionStatus, DataError, - HandlerExecutionFailed, KeywordError, VariableError) + HandlerExecutionFailed) from robot.utils import ErrorDetails, get_timestamp from .modelcombiner import ModelCombiner From c3bead5eede8becfa20645f4af7250700ff3c265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 28 Mar 2023 16:49:28 +0300 Subject: [PATCH 0465/1592] Fix model object `id` if its parent doesn't know about it Avoids failure reported in #4695. --- src/robot/model/body.py | 5 +++-- src/robot/model/control.py | 4 ++-- src/robot/model/message.py | 4 +++- src/robot/model/testcase.py | 6 ++++-- src/robot/model/testsuite.py | 3 ++- utest/model/test_body.py | 10 +++++++++- utest/model/test_control.py | 6 ++++++ utest/model/test_message.py | 7 +++++++ 8 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 4367ae24263..fe84e586626 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -66,8 +66,9 @@ def _get_id(self, parent): if step.type != self.MESSAGE) if getattr(parent, 'has_teardown', False): steps.append(parent.teardown) - my_id = steps.index(self) + 1 - return f'{parent.id}-k{my_id}' + index = steps.index(self) if self in steps else len(steps) + parent_id = parent.id + return f'{parent_id}-k{index + 1}' if parent_id else f'k{index + 1}' def to_dict(self): raise NotImplementedError diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 3994793f9bd..717e991df08 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -154,7 +154,7 @@ def id(self): if not self.parent: return 'k1' if not self.parent.parent: - return 'k%d' % (self.parent.body.index(self) + 1) + return self._get_id(self.parent) return self._get_id(self.parent.parent) def __str__(self): @@ -231,7 +231,7 @@ def id(self): if not self.parent: return 'k1' if not self.parent.parent: - return 'k%d' % (self.parent.body.index(self) + 1) + return self._get_id(self.parent) return self._get_id(self.parent.parent) def __str__(self): diff --git a/src/robot/model/message.py b/src/robot/model/message.py index c931223e6ef..3516a81b1ff 100644 --- a/src/robot/model/message.py +++ b/src/robot/model/message.py @@ -52,7 +52,9 @@ def html_message(self): def id(self): if not self.parent: return 'm1' - return '%s-m%d' % (self.parent.id, self.parent.messages.index(self) + 1) + messages = self.parent.messages + index = messages.index(self) if self in messages else len(messages) + return f'{self.parent.id}-m{index + 1}' def visit(self, visitor): """:mod:`Visitor interface <robot.model.visitor>` entry-point.""" diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index 2dc395e6257..cb07ad4230a 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -149,14 +149,16 @@ def id(self): """ if not self.parent: return 't1' - return '%s-t%d' % (self.parent.id, self.parent.tests.index(self)+1) + tests = self.parent.tests + index = tests.index(self) if self in tests else len(tests) + return f'{self.parent.id}-t{index + 1}' @property def longname(self): """Test name prefixed with the long name of the parent suite.""" if not self.parent: return self.name - return '%s.%s' % (self.parent.longname, self.name) + return f'{self.parent.longname}.{self.name}' @property def source(self): diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 62071cf0a56..97ff5e09e0a 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -211,7 +211,8 @@ def id(self): """ if not self.parent: return 's1' - index = self.parent.suites.index(self) + suites = self.parent.suites + index = suites.index(self) if self in suites else len(suites) return f'{self.parent.id}-s{index + 1}' @property diff --git a/utest/model/test_body.py b/utest/model/test_body.py index fdb7ad60f08..388dde440b8 100644 --- a/utest/model/test_body.py +++ b/utest/model/test_body.py @@ -1,6 +1,6 @@ import unittest -from robot.model import Body, BodyItem, If, For, Keyword, Message, TestCase +from robot.model import Body, BodyItem, If, For, Keyword, TestCase, TestSuite from robot.result.model import Body as ResultBody from robot.utils.asserts import assert_equal, assert_raises_with_msg @@ -77,6 +77,14 @@ def test_id_with_parent_having_setup_and_teardown(self): assert_equal(tc.setup.id, 't1-k1') assert_equal(tc.teardown.id, 't1-k5') + def test_id_when_item_not_in_parent(self): + tc = TestCase(parent=TestSuite(parent=TestSuite())) + assert_equal(tc.id, 's1-s1-t1') + assert_equal(Keyword(parent=tc).id, 's1-s1-t1-k1') + tc.body.create_keyword() + tc.body.create_if().body.create_branch() + assert_equal(Keyword(parent=tc).id, 's1-s1-t1-k3') + def test_id_with_if(self): tc = TestCase() root = tc.body.create_if() diff --git a/utest/model/test_control.py b/utest/model/test_control.py index 9b541a1813e..0715aad18e9 100644 --- a/utest/model/test_control.py +++ b/utest/model/test_control.py @@ -80,6 +80,9 @@ def test_branch_id_with_only_root(self): assert_equal(root.body.create_branch().id, 'k1') assert_equal(root.body.create_branch().id, 'k2') + def test_branch_id_with_only_root_when_branch_not_in_root(self): + assert_equal(IfBranch(parent=If()).id, 'k1') + def test_branch_id_with_real_parent(self): root = TestCase().body.create_if() assert_equal(root.body.create_branch().id, 't1-k1') @@ -143,6 +146,9 @@ def test_branch_id_with_only_root(self): assert_equal(root.body.create_branch().id, 'k1') assert_equal(root.body.create_branch().id, 'k2') + def test_branch_id_with_only_root_when_branch_not_in_root(self): + assert_equal(TryBranch(parent=Try()).id, 'k1') + def test_branch_id_with_real_parent(self): root = TestCase().body.create_try() assert_equal(root.body.create_branch().id, 't1-k1') diff --git a/utest/model/test_message.py b/utest/model/test_message.py index 02020bfa665..be654c48131 100644 --- a/utest/model/test_message.py +++ b/utest/model/test_message.py @@ -27,6 +27,13 @@ def test_id_with_errors_parent(self): assert_equal(errors.messages.create().id, 'errors-m1') assert_equal(errors.messages.create().id, 'errors-m2') + def test_id_when_item_not_in_parent(self): + kw = Keyword() + assert_equal(Message(parent=kw).id, 'k1-m1') + assert_equal(kw.body.create_message().id, 'k1-m1') + assert_equal(kw.body.create_message().id, 'k1-m2') + assert_equal(Message(parent=kw).id, 'k1-m3') + class TestHtmlMessage(unittest.TestCase): From 70f20be21a84e0937baded74a8670cf59a803064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Mar 2023 03:04:50 +0300 Subject: [PATCH 0466/1592] Workaround for importlib.resources bug. Bug appears with Python 3.9 on Windows when code is packaged to a zip. For more info see: https://github.com/python/importlib_resources/issues/281 Related to #4613. --- src/robot/htmldata/template.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/robot/htmldata/template.py b/src/robot/htmldata/template.py index 78bdda46120..ae112330961 100644 --- a/src/robot/htmldata/template.py +++ b/src/robot/htmldata/template.py @@ -19,7 +19,7 @@ from pathlib import Path -if sys.version_info < (3, 10) and not Path(__file__).exists(): +if sys.version_info < (3, 9) and not Path(__file__).exists(): # `importlib.resources.files` is new in Python 3.9, but that version does # not seem to be compatible with zipapp. try: @@ -27,7 +27,7 @@ except ImportError: raise ImportError( "'importlib_resources' backport module needs to be installed with " - "Python 3.9 and older when Robot Framework is distributed as a zip " + "Python 3.8 and older when Robot Framework is distributed as a zip " "package or '__file__' does not exist for other reasons." ) else: @@ -54,6 +54,11 @@ def __init__(self, path: 'Path|str'): self.module = 'robot.htmldata.' + module def __iter__(self): - with files(self.module).joinpath(self.name).open(encoding='UTF-8') as file: + path = files(self.module).joinpath(self.name) + # Workaround for a bug on Windows with Python 3.9 when packaged to a zip: + # https://github.com/python/importlib_resources/issues/281 + if hasattr(path, 'at') and '\\' in path.at: + path.at = path.at.replace('\\', '/') + with path.open(encoding='UTF-8') as file: for item in file: yield item.rstrip() From fa20cd480b662b0818c78e6d9228190b32d662be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 30 Mar 2023 20:45:21 +0300 Subject: [PATCH 0467/1592] Add NormalizedDict.__repr__. Fixes #4709. --- src/robot/utils/normalizing.py | 8 +++++++- utest/utils/test_normalizing.py | 10 ++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/robot/utils/normalizing.py b/src/robot/utils/normalizing.py index 0a2fc3f2437..c1b36109c26 100644 --- a/src/robot/utils/normalizing.py +++ b/src/robot/utils/normalizing.py @@ -89,7 +89,13 @@ def __len__(self): return len(self._data) def __str__(self): - return '{%s}' % ', '.join('%r: %r' % (key, self[key]) for key in self) + items = ', '.join(f'{key!r}: {self[key]!r}' for key in self) + return f'{{{items}}}' + + def __repr__(self): + name = type(self).__name__ + params = str(self) if self else '' + return f'{name}({params})' def __eq__(self, other): if not is_dict_like(other): diff --git a/utest/utils/test_normalizing.py b/utest/utils/test_normalizing.py index 74eb0788b45..15502b66eb5 100644 --- a/utest/utils/test_normalizing.py +++ b/utest/utils/test_normalizing.py @@ -184,10 +184,16 @@ def test_copy(self): assert_equal(cd._data, {'a': 1, 'b': 2}) def test_str(self): - nd = NormalizedDict({'a': 1, 'B': 1, 'c': 3, 'd': 4, 'E': 5, 'F': 6}) - expected = "{'a': 1, 'B': 1, 'c': 3, 'd': 4, 'E': 5, 'F': 6}" + nd = NormalizedDict({'a': 1, 'B': 2, 'c': '3', 'd': '"', 'E': 5, 'F': 6}) + expected = "{'a': 1, 'B': 2, 'c': '3', 'd': '\"', 'E': 5, 'F': 6}" assert_equal(str(nd), expected) + def test_repr(self): + assert_equal(repr(NormalizedDict()), 'NormalizedDict()') + assert_equal(repr(NormalizedDict({'a': None, 'b': '"', 'A': 1})), + "NormalizedDict({'a': 1, 'b': '\"'})") + assert_equal(repr(type('Extend', (NormalizedDict,), {})()), 'Extend()') + def test_unicode(self): nd = NormalizedDict({'a': '\xe4', '\xe4': 'a'}) assert_equal(str(nd), "{'a': '\xe4', '\xe4': 'a'}") From 72ef3c74414c4d3faddb68648e43067e4860c2ac Mon Sep 17 00:00:00 2001 From: Tatu Aalto <2665023+aaltat@users.noreply.github.com> Date: Mon, 3 Apr 2023 14:14:15 +0300 Subject: [PATCH 0468/1592] Support for postional only args in dynamic library api (#4701) Fixes #4660. --- .../dynamic_positional_only_args.robot | 29 ++++++++ .../keywords/DynamicPositionalOnly.py | 22 +++++++ .../dynamic_positional_only_args.robot | 66 +++++++++++++++++++ .../CreatingTestLibraries.rst | 38 ++++++++++- src/robot/running/arguments/argumentparser.py | 17 +++++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 atest/robot/keywords/dynamic_positional_only_args.robot create mode 100644 atest/testdata/keywords/DynamicPositionalOnly.py create mode 100644 atest/testdata/keywords/dynamic_positional_only_args.robot diff --git a/atest/robot/keywords/dynamic_positional_only_args.robot b/atest/robot/keywords/dynamic_positional_only_args.robot new file mode 100644 index 00000000000..160180cdd59 --- /dev/null +++ b/atest/robot/keywords/dynamic_positional_only_args.robot @@ -0,0 +1,29 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/dynamic_positional_only_args.robot +Force Tags require-py3.8 +Resource atest_resource.robot + +*** Test Cases *** +One Argument + Check Test Case ${TESTNAME} + +Three arguments + Check Test Case ${TESTNAME} + +Pos and named + Check Test Case ${TESTNAME} + +Pos and names too few arguments + Check Test Case ${TESTNAME} + +Three arguments too many arguments + Check Test Case ${TESTNAME} + +Pos with default + Check Test Case ${TESTNAME} + +All args + Check Test Case ${TESTNAME} + +Arg with too may / separators + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/DynamicPositionalOnly.py b/atest/testdata/keywords/DynamicPositionalOnly.py new file mode 100644 index 00000000000..3b942203a54 --- /dev/null +++ b/atest/testdata/keywords/DynamicPositionalOnly.py @@ -0,0 +1,22 @@ +class DynamicPositionalOnly: + kws = { + "one argument": ["one", "/"], + "three arguments": ["a", "b", "c", "/"], + "with normal": ["posonly", "/", "normal"], + "default str": ["required", "optional=default", "/"], + "default tuple": ["required", ("optional", "default"), "/"], + "all args kw": [("one", "value"), "/", ("named", "other"), "*varargs", "**kwargs"], + "arg with separator": ["/one"], + "Arg with too many / separators": ["one", "/", "two", "/"] + } + + def get_keyword_names(self): + return [key for key in self.kws] + + def run_keyword(self, name, args, kwargs=None): + if kwargs: + return f"{name}-{args}-{kwargs}" + return f"{name}-{args}" + + def get_keyword_arguments(self, name): + return self.kws[name] diff --git a/atest/testdata/keywords/dynamic_positional_only_args.robot b/atest/testdata/keywords/dynamic_positional_only_args.robot new file mode 100644 index 00000000000..e4bdeae3f55 --- /dev/null +++ b/atest/testdata/keywords/dynamic_positional_only_args.robot @@ -0,0 +1,66 @@ +*** Settings *** +Library DynamicPositionalOnly.py +Force Tags require-py3.8 + +*** Test Cases *** +One Argument + ${result} = One Argument value + Should be equal ${result} one argument-('value',) + ${result} = One Argument one=value + Should be equal ${result} one argument-('one=value',) + ${result} = One Argument foo=value + Should be equal ${result} one argument-('foo=value',) + +Three arguments + ${result} = Three Arguments a b c + Should be equal ${result} three arguments-('a', 'b', 'c') + ${result} = Three Arguments x=a y=b z=c + Should be equal ${result} three arguments-('x=a', 'y=b', 'z=c') + ${result} = Three Arguments a=a b=b c=c + Should be equal ${result} three arguments-('a=a', 'b=b', 'c=c') + +Pos and named + ${result} = with normal a b + Should be equal ${result} with normal-('a', 'b') + ${result} = with normal posonly=posonly normal=111 + Should be equal ${result} with normal-('posonly=posonly',)-{'normal': '111'} + ${result} = with normal aaa normal=111 + Should be equal ${result} with normal-('aaa',)-{'normal': '111'} + +Pos and names too few arguments + [Documentation] FAIL Keyword 'DynamicPositionalOnly.With Normal' expected 2 arguments, got 1. + with normal normal=aaa + +Three arguments too many arguments + [Documentation] FAIL Keyword 'DynamicPositionalOnly.Three Arguments' expected 3 arguments, got 4. + Three Arguments a b c / + +Pos with default + ${result} = default str a + Should be equal ${result} default str-('a',) + ${result} = default str a optional=b + Should be equal ${result} default str-('a', 'optional=b') + ${result} = default str optional=b + Should be equal ${result} default str-('optional=b',) + ${result} = default tuple a + Should be equal ${result} default tuple-('a',) + ${result} = default tuple a optional=b + Should be equal ${result} default tuple-('a', 'optional=b') + ${result} = default tuple optional=b + Should be equal ${result} default tuple-('optional=b',) + ${result} = default tuple optional=b optional=c + Should be equal ${result} default tuple-('optional=b', 'optional=c') + Arg with separator /one= + Should be equal ${result} default tuple-('optional=b', 'optional=c') + +All args + ${result} = all args kw other value 1 2 kw1=1 kw2=2 + Should be equal ${result} all args kw-('other', 'value', '1', '2')-{'kw1': '1', 'kw2': '2'} + ${result} = all args kw other + Should be equal ${result} all args kw-('other',) + ${result} = all args kw + Should be equal ${result} all args kw-() + +Arg with too may / separators + [Documentation] FAIL No keyword with name 'Arg with too many / separators' found. + Arg with too many / separators one two diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 22cd332b81f..eba46ac3ead 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -2901,6 +2901,11 @@ they are specified in Python and explained in the following table. | | separate items. New in | | | | Robot Framework 3.2. | | +--------------------+----------------------------+----------------------------+ + | `Positional-only | Arguments before `/` | `['pos', '/']`, | + | arguments`_ | marker are considered as | `['pos', '/', 'named']` | + | | positional only. New in | | + | | Robot Framework 6.1. | | + +--------------------+----------------------------+----------------------------+ | `Variable number | Argument after possible | `['*varargs']`, | | of arguments`_ | positional arguments and | `['argument', '*rest']`, | | (varargs) | their defaults has `*` | `['a', 'b=42', '*c']` | @@ -2912,7 +2917,7 @@ they are specified in Python and explained in the following table. | | free named arguments`__. | | +--------------------+----------------------------+----------------------------+ | `Named-only | Arguments after varargs or | `['*varargs', 'named']`, | - | arguments`_ | a lone `*` if there are no | `['*', 'named'], | + | arguments`_ | a lone `*` if there are no | `['*', 'named']`, | | | varargs. With or without | `['*', 'x', 'y=default']`, | | | defaults. Requires | `['a', '*b', 'c', '**d']` | | | `run_keyword` to `support | | @@ -2948,7 +2953,8 @@ accepting all arguments. This automatic argument spec is either `run_keyword` `support free named arguments`__ or not. .. note:: Support to specify arguments as tuples like `('name', 'default')` - is new in Robot Framework 3.2. + is new in Robot Framework 3.2. Support for positional only arguments + in dynamic library API is new in Robot Framework 6.1. __ `Free named arguments with dynamic libraries`_ __ `Named-only arguments with dynamic libraries`_ @@ -3064,6 +3070,34 @@ source path defined. .. note:: Returning source information for keywords is a new feature in Robot Framework 3.2. +Positional only argument syntax with dynamic libraries +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The dynamic library API supports the +`positional-only arguments`_. Python 3.8 introduced positional-only arguments +that make it possible to specify that an argument can only be given as a +positional argument, not as a named argument like name=value. Positional-only +arguments are specified before normal arguments and a special / marker must +be used after them: + +.. sourcecode:: python + + def keyword(posonly, /, normal=None): + print(f"Got positional-only argument {posonly} and normal argument {normal}.") + +The above keyword could be used like this: + +.. sourcecode:: robotframework + + *** Test Cases *** + Positional-only argument #args + Keyword x # posonly gets value "x" and normal uses default value. + Keyword normal=x # posonly gets value "normal=x" and normal uses default value. + Keyword normal=x y # posonly gets value "normal=x" and normal gets value "y. + + +.. note:: Positional-only argument support in dynamic libary API is a new + feature in Robot Framework 6.1. + Named argument syntax with dynamic libraries ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/running/arguments/argumentparser.py b/src/robot/running/arguments/argumentparser.py index b4d9d49db0f..f736a797591 100644 --- a/src/robot/running/arguments/argumentparser.py +++ b/src/robot/running/arguments/argumentparser.py @@ -99,9 +99,16 @@ class ArgumentSpecParser(ArgumentParser): def parse(self, argspec, name=None): spec = ArgumentSpec(name, self._type) + positional_only_separator_seen = False named_only = False for arg in argspec: arg = self._validate_arg(arg) + if self._is_positional_only_separator(arg): + if positional_only_separator_seen: + self._report_error('Too many positional only separators.') + positional_only_separator_seen = True + spec.positional_only, spec.positional_or_named = spec.positional_or_named, [] + continue if spec.var_named: self._report_error('Only last argument can be kwargs.') elif isinstance(arg, tuple): @@ -146,6 +153,10 @@ def _is_var_positional(self, arg): def _format_var_positional(self, varargs): raise NotImplementedError + @abstractmethod + def _is_positional_only_separator(self, arg): + raise NotImplementedError + def _format_arg(self, arg): return arg @@ -189,6 +200,9 @@ def _is_named_only_separator(self, arg): def _format_var_positional(self, varargs): return varargs[1:] + def _is_positional_only_separator(self, arg): + return arg == "/" + class UserKeywordArgumentParser(ArgumentSpecParser): @@ -221,3 +235,6 @@ def _format_var_positional(self, varargs): def _format_arg(self, arg): return arg[2:-1] + + def _is_positional_only_separator(self, arg): + return False From e5082cf5da9ce0187242ff6f124a69bf66fdaf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 31 Mar 2023 19:37:34 +0300 Subject: [PATCH 0469/1592] Add unit test to detect zip-unsafe __file__ usages. Related to #4613. --- src/robot/htmldata/template.py | 4 ++-- src/robot/pythonpathsetter.py | 2 +- utest/api/test_zipsafe.py | 25 +++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 utest/api/test_zipsafe.py diff --git a/src/robot/htmldata/template.py b/src/robot/htmldata/template.py index ae112330961..39a8452f5b4 100644 --- a/src/robot/htmldata/template.py +++ b/src/robot/htmldata/template.py @@ -19,7 +19,7 @@ from pathlib import Path -if sys.version_info < (3, 9) and not Path(__file__).exists(): +if sys.version_info < (3, 9) and not Path(__file__).exists(): # zipsafe # `importlib.resources.files` is new in Python 3.9, but that version does # not seem to be compatible with zipapp. try: @@ -34,7 +34,7 @@ try: from importlib.resources import files except ImportError: # Python 3.8 or older - BASE_DIR = Path(__file__).absolute().parent.parent.parent + BASE_DIR = Path(__file__).absolute().parent.parent.parent # zipsafe def files(module): return BASE_DIR / module.replace('.', '/') diff --git a/src/robot/pythonpathsetter.py b/src/robot/pythonpathsetter.py index b3ed1973c2f..06323936187 100644 --- a/src/robot/pythonpathsetter.py +++ b/src/robot/pythonpathsetter.py @@ -28,5 +28,5 @@ from pathlib import Path if 'robot' not in sys.modules: - robot_dir = Path(__file__).absolute().parent + robot_dir = Path(__file__).absolute().parent # zipsafe sys.path = [str(robot_dir.parent)] + [p for p in sys.path if Path(p) != robot_dir] diff --git a/utest/api/test_zipsafe.py b/utest/api/test_zipsafe.py new file mode 100644 index 00000000000..2e39cafbca4 --- /dev/null +++ b/utest/api/test_zipsafe.py @@ -0,0 +1,25 @@ +import unittest +from pathlib import Path + + +class TestZipSafe(unittest.TestCase): + + def test_no_unsafe__file__usages(self): + root = Path(__file__).absolute().parent.parent.parent / 'src/robot' + + def unsafe__file__usage(line, path): + if ('__file__' not in line or '# zipsafe' in line + or path.parent == root / 'htmldata/testdata'): + return False + return '__file__' in line.replace("'__file__'", '').replace('"__file__"', '') + + for path in root.rglob('*.py'): + with path.open(encoding='UTF-8') as file: + for lineno, line in enumerate(file, start=1): + if unsafe__file__usage(line, path): + raise AssertionError(f'Unsafe __file__ usage in {path} ' + f'on line {lineno}.') + + +if __name__ == '__main__': + unittest.main() From 5f2447fdceef2eb7f8045d1867dc40287a54d69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 3 Apr 2023 16:03:26 +0300 Subject: [PATCH 0470/1592] Stricter dynamic API positional-only validation Make, for example, `['*', 'kwo', '/', '???']` and `['**kws', '/']` explicitly invalid. Part of #4660. --- .../dynamic_positional_only_args.robot | 30 +++++++++++++-- .../keywords/DynamicPositionalOnly.py | 5 ++- .../dynamic_positional_only_args.robot | 5 --- src/robot/running/arguments/argumentparser.py | 38 ++++++++++--------- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/atest/robot/keywords/dynamic_positional_only_args.robot b/atest/robot/keywords/dynamic_positional_only_args.robot index 160180cdd59..98ead1c7c55 100644 --- a/atest/robot/keywords/dynamic_positional_only_args.robot +++ b/atest/robot/keywords/dynamic_positional_only_args.robot @@ -1,6 +1,5 @@ *** Settings *** Suite Setup Run Tests ${EMPTY} keywords/dynamic_positional_only_args.robot -Force Tags require-py3.8 Resource atest_resource.robot *** Test Cases *** @@ -25,5 +24,30 @@ Pos with default All args Check Test Case ${TESTNAME} -Arg with too may / separators - Check Test Case ${TESTNAME} +Too many markers + Validate invalid arg spec error 0 + ... Too many markers + ... Too many positional-only separators. + +After varargs + Validate invalid arg spec error 1 + ... After varargs + ... Positional-only separator must be before named-only arguments. + +After named-only marker + Validate invalid arg spec error 2 + ... After named-only marker + ... Positional-only separator must be before named-only arguments. + +After kwargs + Validate invalid arg spec error 3 + ... After kwargs + ... Only last argument can be kwargs. + +*** Keywords *** +Validate invalid arg spec error + [Arguments] ${index} ${name} ${error} + Error in library + ... DynamicPositionalOnly + ... Adding keyword '${name}' failed: Invalid argument specification: ${error} + ... index=${index} diff --git a/atest/testdata/keywords/DynamicPositionalOnly.py b/atest/testdata/keywords/DynamicPositionalOnly.py index 3b942203a54..25871bf70fa 100644 --- a/atest/testdata/keywords/DynamicPositionalOnly.py +++ b/atest/testdata/keywords/DynamicPositionalOnly.py @@ -7,7 +7,10 @@ class DynamicPositionalOnly: "default tuple": ["required", ("optional", "default"), "/"], "all args kw": [("one", "value"), "/", ("named", "other"), "*varargs", "**kwargs"], "arg with separator": ["/one"], - "Arg with too many / separators": ["one", "/", "two", "/"] + "Too many markers": ["one", "/", "two", "/"], + "After varargs": ["*varargs", "/", "arg"], + "After named-only marker": ["*", "/", "arg"], + "After kwargs": ["**kws", "/"], } def get_keyword_names(self): diff --git a/atest/testdata/keywords/dynamic_positional_only_args.robot b/atest/testdata/keywords/dynamic_positional_only_args.robot index e4bdeae3f55..1ea8c645a63 100644 --- a/atest/testdata/keywords/dynamic_positional_only_args.robot +++ b/atest/testdata/keywords/dynamic_positional_only_args.robot @@ -1,6 +1,5 @@ *** Settings *** Library DynamicPositionalOnly.py -Force Tags require-py3.8 *** Test Cases *** One Argument @@ -60,7 +59,3 @@ All args Should be equal ${result} all args kw-('other',) ${result} = all args kw Should be equal ${result} all args kw-() - -Arg with too may / separators - [Documentation] FAIL No keyword with name 'Arg with too many / separators' found. - Arg with too many / separators one two diff --git a/src/robot/running/arguments/argumentparser.py b/src/robot/running/arguments/argumentparser.py index f736a797591..9d7a0ae8245 100644 --- a/src/robot/running/arguments/argumentparser.py +++ b/src/robot/running/arguments/argumentparser.py @@ -99,18 +99,20 @@ class ArgumentSpecParser(ArgumentParser): def parse(self, argspec, name=None): spec = ArgumentSpec(name, self._type) - positional_only_separator_seen = False - named_only = False + named_only = positional_only_separator_seen = False for arg in argspec: arg = self._validate_arg(arg) - if self._is_positional_only_separator(arg): - if positional_only_separator_seen: - self._report_error('Too many positional only separators.') - positional_only_separator_seen = True - spec.positional_only, spec.positional_or_named = spec.positional_or_named, [] - continue if spec.var_named: self._report_error('Only last argument can be kwargs.') + elif self._is_positional_only_separator(arg): + if named_only: + self._report_error('Positional-only separator must be before ' + 'named-only arguments.') + if positional_only_separator_seen: + self._report_error('Too many positional-only separators.') + spec.positional_only = spec.positional_or_named + spec.positional_or_named = [] + positional_only_separator_seen = True elif isinstance(arg, tuple): arg, default = arg arg = self._add_arg(spec, arg, named_only) @@ -142,19 +144,19 @@ def _format_var_named(self, kwargs): raise NotImplementedError @abstractmethod - def _is_named_only_separator(self, arg): + def _is_positional_only_separator(self, arg): raise NotImplementedError @abstractmethod - def _is_var_positional(self, arg): + def _is_named_only_separator(self, arg): raise NotImplementedError @abstractmethod - def _format_var_positional(self, varargs): + def _is_var_positional(self, arg): raise NotImplementedError @abstractmethod - def _is_positional_only_separator(self, arg): + def _format_var_positional(self, varargs): raise NotImplementedError def _format_arg(self, arg): @@ -194,15 +196,15 @@ def _format_var_named(self, kwargs): def _is_var_positional(self, arg): return arg and arg[0] == '*' + def _is_positional_only_separator(self, arg): + return arg == '/' + def _is_named_only_separator(self, arg): return arg == '*' def _format_var_positional(self, varargs): return varargs[1:] - def _is_positional_only_separator(self, arg): - return arg == "/" - class UserKeywordArgumentParser(ArgumentSpecParser): @@ -227,6 +229,9 @@ def _format_var_named(self, kwargs): def _is_var_positional(self, arg): return arg and arg[0] == '@' + def _is_positional_only_separator(self, arg): + return False + def _is_named_only_separator(self, arg): return arg == '@{}' @@ -235,6 +240,3 @@ def _format_var_positional(self, varargs): def _format_arg(self, arg): return arg[2:-1] - - def _is_positional_only_separator(self, arg): - return False From 055edb1f7e84761a6cc241d33055e20adbf12937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 3 Apr 2023 16:06:32 +0300 Subject: [PATCH 0471/1592] Fine-tune docs related to dynamic API arguments 1. Remove new "Positional only argument syntax with dynamic libraries" section added as part of #4660. It mostly explains how positional arguments work in general, but that's alrady explained elsewhere. We can consider adding adding it back so that it explains how they work with the dynamic API, but I'm not sure it's needed. We don't have such a section with `*varargs` either and believe the remaining documentation and examples explain this topic well enough. 2. Enhance formatting of examples related to argument specs returned by `get_keyword_arguments`. 3. Make names of tests in examples more clear. Most importantly, avoid "Positional only" term when not actually talking about positional-only arguments. --- .../CreatingTestLibraries.rst | 128 +++++++----------- 1 file changed, 49 insertions(+), 79 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index eba46ac3ead..1a182ec73e7 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -2880,50 +2880,48 @@ they are specified in Python and explained in the following table. .. table:: Representing different arguments with `get_keyword_arguments` :class: tabular - +--------------------+----------------------------+----------------------------+ - | Argument type | How to represent | Examples | - +====================+============================+============================+ - | No arguments | Empty list. | `[]` | - +--------------------+----------------------------+----------------------------+ - | One or more | List of strings containing | `['argument']`, | - | `positional | argument names. | `['arg1', 'arg2', 'arg3']` | - | argument`_ | | | - +--------------------+----------------------------+----------------------------+ - | `Default values`_ | Two ways how to represent | `['name=default']`, | - | | the argument name and the | `['a', 'b=1', 'c=2']` | - | | default value: | | - | | | `[('name', 'default')]`, | - | | - As a string where the | `['a', ('b', 1), ('c', 2)]`| - | | name and the default are | | - | | separated with `=`. | | - | | - As a tuple with the name | | - | | and the default as | | - | | separate items. New in | | - | | Robot Framework 3.2. | | - +--------------------+----------------------------+----------------------------+ - | `Positional-only | Arguments before `/` | `['pos', '/']`, | - | arguments`_ | marker are considered as | `['pos', '/', 'named']` | - | | positional only. New in | | - | | Robot Framework 6.1. | | - +--------------------+----------------------------+----------------------------+ - | `Variable number | Argument after possible | `['*varargs']`, | - | of arguments`_ | positional arguments and | `['argument', '*rest']`, | - | (varargs) | their defaults has `*` | `['a', 'b=42', '*c']` | - | | prefix. | | - +--------------------+----------------------------+----------------------------+ - | `Free named | Last arguments has `**` | `['**named']`, | - | arguments`_ | prefix. Requires | `['a', 'b=42', '**c']`, | - | (kwargs) | `run_keyword` to `support | `['*varargs', '**kwargs']` | - | | free named arguments`__. | | - +--------------------+----------------------------+----------------------------+ - | `Named-only | Arguments after varargs or | `['*varargs', 'named']`, | - | arguments`_ | a lone `*` if there are no | `['*', 'named']`, | - | | varargs. With or without | `['*', 'x', 'y=default']`, | - | | defaults. Requires | `['a', '*b', 'c', '**d']` | - | | `run_keyword` to `support | | - | | named-only arguments`__. | | - | | New in Robot Framework 3.1.| | - +--------------------+----------------------------+----------------------------+ + +--------------------+----------------------------+------------------------------+ + | Argument type | How to represent | Examples | + +====================+============================+==============================+ + | No arguments | Empty list. | | `[]` | + +--------------------+----------------------------+------------------------------+ + | One or more | List of strings containing | | `['argument']` | + | `positional | argument names. | | `['arg1', 'arg2', 'arg3']` | + | argument`_ | | | + +--------------------+----------------------------+------------------------------+ + | `Default values`_ | Two ways how to represent | String with `=` separator: | + | | the argument name and the | | + | | default value: | | `['name=default']` | + | | | | `['a', 'b=1', 'c=2']` | + | | - As a string where the | | + | | name and the default are | Tuple: | + | | separated with `=`. | | + | | - As a tuple with the name | | `[('name', 'default')]` | + | | and the default as | | `['a', ('b', 1), ('c', 2)]`| + | | separate items. New in | | + | | Robot Framework 3.2. | | + +--------------------+----------------------------+------------------------------+ + | `Positional-only | Arguments before the `/` | | `['posonly', '/']` | + | arguments`_ | marker. New in Robot | | `['p', 'q', '/', 'normal']`| + | | Framework 6.1. | | + +--------------------+----------------------------+------------------------------+ + | `Variable number | Argument after possible | | `['*varargs']` | + | of arguments`_ | positional arguments has | | `['argument', '*rest']` | + | (varargs) | a `*` prefix | | `['a', 'b=42', '*c']` | + +--------------------+----------------------------+------------------------------+ + | `Named-only | Arguments after varargs or | | `['*varargs', 'named']` | + | arguments`_ | a lone `*` if there are no | | `['*', 'named']` | + | | varargs. With or without | | `['*', 'x', 'y=default']` | + | | defaults. Requires | | `['a', '*b', ('c', 42)]` | + | | `run_keyword` to `support | | + | | named-only arguments`__. | | + | | New in Robot Framework 3.1.| | + +--------------------+----------------------------+------------------------------+ + | `Free named | Last arguments has `**` | | `['**named']` | + | arguments`_ | prefix. Requires | | `['a', ('b', 42), '**c']` | + | (kwargs) | `run_keyword` to `support | | `['*varargs', '**kwargs']` | + | | free named arguments`__. | | `['*', 'kwo', '**kws']` | + +--------------------+----------------------------+------------------------------+ When the `get_keyword_arguments` is used, Robot Framework automatically calculates how many positional arguments the keyword requires and does it @@ -2953,7 +2951,7 @@ accepting all arguments. This automatic argument spec is either `run_keyword` `support free named arguments`__ or not. .. note:: Support to specify arguments as tuples like `('name', 'default')` - is new in Robot Framework 3.2. Support for positional only arguments + is new in Robot Framework 3.2. Support for positional-only arguments in dynamic library API is new in Robot Framework 6.1. __ `Free named arguments with dynamic libraries`_ @@ -3070,34 +3068,6 @@ source path defined. .. note:: Returning source information for keywords is a new feature in Robot Framework 3.2. -Positional only argument syntax with dynamic libraries -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The dynamic library API supports the -`positional-only arguments`_. Python 3.8 introduced positional-only arguments -that make it possible to specify that an argument can only be given as a -positional argument, not as a named argument like name=value. Positional-only -arguments are specified before normal arguments and a special / marker must -be used after them: - -.. sourcecode:: python - - def keyword(posonly, /, normal=None): - print(f"Got positional-only argument {posonly} and normal argument {normal}.") - -The above keyword could be used like this: - -.. sourcecode:: robotframework - - *** Test Cases *** - Positional-only argument #args - Keyword x # posonly gets value "x" and normal uses default value. - Keyword normal=x # posonly gets value "normal=x" and normal uses default value. - Keyword normal=x y # posonly gets value "normal=x" and normal gets value "y. - - -.. note:: Positional-only argument support in dynamic libary API is a new - feature in Robot Framework 6.1. - Named argument syntax with dynamic libraries ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -3175,19 +3145,19 @@ the arguments that the `run_keyword` method is actually called with. No arguments Dynamic # [], {} - Positional only + Only positional Dynamic x # [x], {} Dynamic x y # [x, y], {} - Free named only + Only free named Dynamic x=1 # [], {x: 1} Dynamic x=1 y=2 z=3 # [], {x: 1, y: 2, z: 3} - Free named with positional + Positional and free named Dynamic x y=2 # [x], {y: 2} Dynamic x y=2 z=3 # [x], {y: 2, z: 3} - Free named with normal named + Positional as named and free named Dynamic a=1 x=1 # [], {a: 1, x: 1} Dynamic b=2 x=1 a=1 # [], {a: 1, b: 2, x: 1} @@ -3221,7 +3191,7 @@ shows the arguments that the `run_keyword` method is actually called with. .. sourcecode:: robotframework *** Test Cases *** # args, kwargs - Named-only only + Only named-only Dynamic named=value # [], {named: value} Dynamic named=value named2=2 # [], {named: value, named2: 2} @@ -3229,7 +3199,7 @@ shows the arguments that the `run_keyword` method is actually called with. Dynamic argument named=xxx # [argument], {named: xxx} Dynamic a1 a2 named=3 # [a1, a2], {named: 3} - Named-only with normal named + Named-only with positional as named Dynamic named=foo positional=bar # [], {positional: bar, named: foo} Named-only with free named From 7fae698e670077370927c1128f7c886804dc372e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 3 Apr 2023 21:08:28 +0300 Subject: [PATCH 0472/1592] UG: Terminology changes and other cleanup. Following term changes done to avoid the "test" term: - test suite file -> suite file - test suite directory -> suite directory - free test suite metadata -> free suite metadata Changes done mainly on header level, changing these terms everywhere in text would have been too big task. Can be done in the future as part of bigger User Guide rewrite. Also some other changes done here and there. --- .../src/Appendices/AvailableSettings.rst | 4 +- .../Appendices/DocumentationFormatting.rst | 4 +- .../src/CreatingTestData/AdvancedFeatures.rst | 2 +- .../CreatingTestData/CreatingTestCases.rst | 2 +- .../CreatingTestData/CreatingTestSuites.rst | 42 ++++++++++--------- .../CreatingTestData/CreatingUserKeywords.rst | 2 +- .../ResourceAndVariableFiles.rst | 9 ++-- .../src/CreatingTestData/TestDataSyntax.rst | 8 ++-- .../CreatingTestData/UsingTestLibraries.rst | 2 +- .../src/CreatingTestData/Variables.rst | 4 +- .../src/ExecutingTestCases/BasicUsage.rst | 15 ++++--- .../ConfiguringExecution.rst | 15 ++++--- .../src/ExecutingTestCases/OutputFiles.rst | 4 +- .../src/ExecutingTestCases/TestExecution.rst | 4 +- .../ListenerInterface.rst | 2 +- doc/userguide/src/RobotFrameworkUserGuide.rst | 6 +-- doc/userguide/src/SupportingTools/Libdoc.rst | 4 +- 17 files changed, 64 insertions(+), 65 deletions(-) diff --git a/doc/userguide/src/Appendices/AvailableSettings.rst b/doc/userguide/src/Appendices/AvailableSettings.rst index c268305b540..0337e2b5c2d 100644 --- a/doc/userguide/src/Appendices/AvailableSettings.rst +++ b/doc/userguide/src/Appendices/AvailableSettings.rst @@ -34,7 +34,7 @@ importing libraries, resources, and variables. | Documentation | Used for specifying a `test suite`__ or | | | `resource file`__ documentation. | +-----------------+--------------------------------------------------------+ - | Metadata | Used for setting `free test suite metadata`_. | + | Metadata | Used for setting `free suite metadata`_. | +-----------------+--------------------------------------------------------+ | Suite Setup | Used for specifying the `suite setup`_. | +-----------------+--------------------------------------------------------+ @@ -64,7 +64,7 @@ importing libraries, resources, and variables. | Task Timeout | | +-----------------+--------------------------------------------------------+ -__ `Test suite documentation`_ +__ `Suite documentation`_ __ `Documenting resource files`_ __ `Deprecation of Force Tags and Default Tags`_ diff --git a/doc/userguide/src/Appendices/DocumentationFormatting.rst b/doc/userguide/src/Appendices/DocumentationFormatting.rst index 6ae0088ebf2..e51fc94a4b2 100644 --- a/doc/userguide/src/Appendices/DocumentationFormatting.rst +++ b/doc/userguide/src/Appendices/DocumentationFormatting.rst @@ -4,13 +4,13 @@ Documentation formatting ======================== It is possible to use simple HTML formatting with `test suite`__, -`test case`__ and `user keyword`__ documentation and `free test suite +`test case`__ and `user keyword`__ documentation and `free suite metadata`_ in the test data, as well as when `documenting test libraries`__. The formatting is similar to the style used in most wikis, and it is designed to be understandable both as plain text and after the HTML transformation. -__ `test suite documentation`_ +__ `suite documentation`_ __ `test case documentation`_ __ `user keyword documentation`_ __ `Documenting libraries`_ diff --git a/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst b/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst index 422cd521ed8..03072406a26 100644 --- a/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst +++ b/doc/userguide/src/CreatingTestData/AdvancedFeatures.rst @@ -24,7 +24,7 @@ that name, Robot Framework attempts to determine which keyword has the highest priority based on its scope. The keyword's scope is determined on the basis of how the keyword in question is created: -1. Created as a user keyword in the currently executed `test case file`_. +1. Created as a user keyword in the currently executed `suite file`_. These keywords have the highest priority and they are always used, even if there are other keywords with the same name elsewhere. diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index f9baf5567f2..eea0abc05e6 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -4,7 +4,7 @@ Creating test cases =================== This section describes the overall test case syntax. Organizing test -cases into `test suites`_ using `test case files`_ and `test suite +cases into `test suites`_ using `suite files`_ and `suite directories`_ is discussed in the next section. When using Robot Framework for other automation purposes than test diff --git a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst index b60fb249980..165eb907456 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst @@ -12,23 +12,23 @@ __ `Creating test cases`_ :depth: 2 :local: -Test case files ---------------- +Suite files +----------- Robot Framework test cases `are created`__ using test case sections in -test case files. Such a file automatically creates a test suite from +suite files, also known as test case files. Such a file automatically creates +a test suite from all the test cases it contains. There is no upper limit for how many test cases there can be, but it is recommended to have less than ten, unless the `data-driven approach`_ is used, where one test case consists of only one high-level keyword. -The following settings in the Setting section can be used to customize the -test suite: +The following settings in the Setting section can be used to customize the suite: `Documentation`:setting: - Used for specifying a `test suite documentation`_ + Used for specifying a `suite documentation`_. `Metadata`:setting: - Used for setting `free test suite metadata`_ as name-value pairs. + Used for setting `free suite metadata`_ as name-value pairs. `Suite Setup`:setting:, `Suite Teardown`:setting: Specify `suite setup and teardown`_. @@ -36,8 +36,8 @@ test suite: __ `Creating test cases`_ -Test suite directories ----------------------- +Suite directories +----------------- Test case files can be organized into directories, and these directories create higher-level test suites. A test suite created from @@ -84,23 +84,24 @@ variables or keywords, you can put them into `resource files`_ that can be imported both by initialization and test case files. The main usage for initialization files is specifying test suite related -settings similarly as in `test case files`_, but setting some `test case +settings similarly as in `suite files`_, but setting some `test case related settings`__ is also possible. How to use different settings in the initialization files is explained below. `Documentation`:setting:, `Metadata`:setting:, `Suite Setup`:setting:, `Suite Teardown`:setting: These test suite specific settings work the same way as in test case files. -`Force Tags`:setting: - Specified tags are unconditionally set to all test cases in all test case files - this directory contains directly or recursively. +`Test Tags`:setting: + Specified tags are unconditionally set to all tests in all suite files + this directory contains, recursively. New in Robot Framework 6.1. The + deprecated `Force Tags`:setting: needs to be used with older versions. `Test Setup`:setting:, `Test Teardown`:setting:, `Test Timeout`:setting: Set the default value for test setup/teardown or test timeout to all test cases this directory contains. Can be overridden on lower level. Notice that keywords used as setups and teardowns must be available in test case files where tests using them are. Defining keywords in the initialization file itself is not enough. -`Task Setup`:setting:, `Task Teardown`:setting:, `Task Timeout`:setting: - Aliases for `Test Setup`:setting:, `Test Teardown`:setting:, +`Task Setup`:setting:, `Task Teardown`:setting:, `Task Tags`:setting:, `Task Timeout`:setting: + Aliases for `Test Setup`:setting:, `Test Teardown`:setting:, `Test Tags`:setting: and `Test Timeout`:setting:, respectively, that can be used when `creating tasks`_, not tests. `Default Tags`:setting:, `Test Template`:setting: @@ -126,8 +127,8 @@ initialization files is explained below. __ `Specifying test data to be executed`_ __ `Test case related settings in the Setting section`_ -Test suite name and documentation ---------------------------------- +Suite name +---------- The test suite name is constructed from the file or directory name. The name is created so that the extension is ignored, possible underscores are @@ -143,6 +144,9 @@ the prefix and underscores are removed. For example files suites :name:`Some Tests` and :name:`More Tests`, respectively, and the former is executed before the latter. +Suite documentation +------------------- + The documentation for a test suite is set using the :setting:`Documentation` setting in the Setting section. It can be used in test case files or, with higher-level suites, in test suite initialization files. Test @@ -161,8 +165,8 @@ overridden in test execution. This can be done with the command line options :option:`--name` and :option:`--doc`, respectively, as explained in section `Setting metadata`_. -Free test suite metadata ------------------------- +Free suite metadata +------------------- Test suites can also have other metadata than the documentation. This metadata is defined in the Setting section using the :setting:`Metadata` setting. Metadata diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index bdd099cc111..aca23927c31 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -46,7 +46,7 @@ values`_. __ `User keyword arguments`_ -User keywords can be created in `test case files`_, `resource files`_, +User keywords can be created in `suite files`_, `resource files`_, and `suite initialization files`_. Keywords created in resource files are available for files using them, whereas other keywords are only available in the files where they are created. diff --git a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst index ff855e4da3c..78144798939 100644 --- a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst +++ b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst @@ -1,7 +1,7 @@ Resource and variable files =========================== -User keywords and variables in `test case files`_ and `suite +User keywords and variables in `suite files`_ and `suite initialization files`_ can only be used in files where they are created, but *resource files* provide a mechanism for sharing them. The high level syntax for creating resource files is exactly the same @@ -85,17 +85,16 @@ Documenting resource files Keywords created in a resource file can be documented__ using :setting:`[Documentation]` setting. The resource file itself can have -:setting:`Documentation` in the Setting section similarly as -`test suites`__. +:setting:`Documentation` in the Setting section similarly as suites__. -Both Libdoc_ and RIDE_ use these documentations, and they +Libdoc_ and various editors use these documentations, and they are naturally available for anyone opening resource files. The first logical line of the documentation of a keyword, until the first empty line, is logged when the keyword is run, but otherwise resource file documentation is ignored during the test execution. __ `User keyword name and documentation`_ -__ `Test suite name and documentation`_ +__ `Suite name`_ Example resource file ~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index a760ef0f835..7a6c1df6a38 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -15,11 +15,11 @@ Files and directories The hierarchical structure for arranging test cases is built as follows: -- Test cases are created in `test case files`_. +- Test cases are created in `suite files`_. - A test case file automatically creates a `test suite`_ containing the test cases in that file. - A directory containing test case files forms a higher-level test - suite. Such a `test suite directory`_ has suites created from test + suite. Such a `suite directory`_ has suites created from test case files as its child test suites. - A test suite directory can also contain other test suite directories, and this hierarchical structure can be as deeply nested as needed. @@ -493,10 +493,10 @@ a space by default, but that can be changed by starting the value with Splitting lines is illustrated in the following two examples containing exactly same data without and with splitting. -__ `Test suite documentation`_ +__ `Suite documentation`_ __ `Test case documentation`_ __ `User keyword documentation`_ -__ `Free test suite metadata`_ +__ `Free suite metadata`_ __ `Newlines in test data`_ .. sourcecode:: robotframework diff --git a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst index 20fddb8ed7b..763fa33046e 100644 --- a/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst +++ b/doc/userguide/src/CreatingTestData/UsingTestLibraries.rst @@ -44,7 +44,7 @@ __ `Using arguments`_ Library MyLibrary arg1 arg2 Library ${LIBRARY} -It is possible to import test libraries in `test case files`_, +It is possible to import test libraries in `suite files`_, `resource files`_ and `suite initialization files`_. In all these cases, all the keywords in the imported library are available in that file. With resource files, those keywords are also available in other diff --git a/doc/userguide/src/CreatingTestData/Variables.rst b/doc/userguide/src/CreatingTestData/Variables.rst index c107319c3af..aa2ea1ce4bf 100644 --- a/doc/userguide/src/CreatingTestData/Variables.rst +++ b/doc/userguide/src/CreatingTestData/Variables.rst @@ -460,8 +460,8 @@ Variables can spring into existence from different sources. Variable section ~~~~~~~~~~~~~~~~ -The most common source for variables are Variable sections in `test case -files`_ and `resource files`_. Variable sections are convenient, because they +The most common source for variables are Variable sections in `suite files`_ +and `resource files`_. Variable sections are convenient, because they allow creating variables in the same place as the rest of the test data, and the needed syntax is very simple. Their main disadvantages are that values are always strings and they cannot be created dynamically. diff --git a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst index 2fbd4a962bd..6baf86bfd9b 100644 --- a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst +++ b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst @@ -48,8 +48,8 @@ and they are executed by giving the path to the file or directory in question to the selected runner script. The path can be absolute or, more commonly, relative to the directory where tests are executed from. The given file or directory creates the top-level test suite, -which gets its name, unless overridden with the :option:`--name` option__, -from the `file or directory name`__. Different execution possibilities +which, by default, gets its name from the `file or directory name`__. +Different execution possibilities are illustrated in the examples below. Note that in these examples, as well as in other examples in this section, only the ``robot`` script is used, but other execution approaches could be used similarly. @@ -70,7 +70,7 @@ directories at once, separated with spaces. In this case, Robot Framework creates the top-level test suite automatically, and the specified files and directories become its child test suites. The name of the created test suite is got from child suite names by -catenating them together with an ampersand (&) and spaces. For example, +concatenating them together with an ampersand (&) and spaces. For example, the name of the top-level suite in the first example below is :name:`My Tests & Your Tests`. These automatically created names are often quite long and complicated. In most cases, it is thus better to @@ -87,11 +87,10 @@ test case files:: robot __init__.robot my_tests.robot other_tests.robot -__ `Test case files`_ -__ `Test suite directories`_ -__ `Setting the name`_ -__ `Test suite name and documentation`_ -__ `Test suite directories`_ +__ `Suite files`_ +__ `Suite directories`_ +__ `Suite name`_ +__ `Suite directories`_ __ `Suite initialization files`_ Using command line options diff --git a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst index 1169062a827..b218299725f 100644 --- a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst @@ -45,7 +45,7 @@ would mean that other files in that format are skipped. parsed by default. Starting from Robot Framework 3.2 HTML files are not supported at all. -__ `Test suite directories`_ +__ `Suite directories`_ Selecting test cases -------------------- @@ -234,8 +234,8 @@ Setting metadata Setting the name ~~~~~~~~~~~~~~~~ -When Robot Framework parses test data, `test suite names are created -from file and directory names`__. The name of the top-level test suite +When Robot Framework parses test data, `test suite names`__ are created +from file and directory names. The name of the top-level test suite can, however, be overridden with the command line option :option:`--name (-N)`. @@ -243,8 +243,7 @@ can, however, be overridden with the command line option converted to spaces. Nowadays values containing spaces need to be escaped or quoted like, for example, `--name "My example"`. -__ `Test suite name and documentation`_ - +__ `Suite name`_ Setting the documentation ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -269,12 +268,12 @@ Examples:: Prior to Robot Framework 3.1, underscores in documentation were converted to spaces same way as with the :option:`--name` option. -__ `Test suite name and documentation`_ +__ `Suite documentation`_ Setting free metadata ~~~~~~~~~~~~~~~~~~~~~ -`Free test suite metadata`_ may also be given from the command line with the +`Free suite metadata`_ may also be given from the command line with the option :option:`--metadata (-M)`. The argument must be in the format `name:value`, where `name` the name of the metadata to set and `value` is its value. The value can contain simple `HTML formatting`_ and @@ -470,7 +469,7 @@ Examples:: robot --randomize tests my_test.robot robot --randomize all:12345 path/to/tests -__ `Free test suite metadata`_ +__ `Free suite metadata`_ .. _pre-run modifier: diff --git a/doc/userguide/src/ExecutingTestCases/OutputFiles.rst b/doc/userguide/src/ExecutingTestCases/OutputFiles.rst index c2b4208037c..26ab0c7850f 100644 --- a/doc/userguide/src/ExecutingTestCases/OutputFiles.rst +++ b/doc/userguide/src/ExecutingTestCases/OutputFiles.rst @@ -140,13 +140,13 @@ the generated xUnit file, relatively to the `output directory`_, as a value. XUnit output files were changed pretty heavily in Robot Framework 5.0. They nowadays contain separate `<testsuite>` elements for each suite, -`<testsuite>` elements have `timestamp` attribute, and `test suite documentation`_ +`<testsuite>` elements have `timestamp` attribute, and `suite documentation`_ and metadata__ is stored as `<property>` elements. __ http://en.wikipedia.org/wiki/XUnit __ http://jenkins-ci.org __ https://wiki.jenkins-ci.org/display/JENKINS/Robot+Framework+Plugin -__ `Free test suite metadata`_ +__ `Free suite metadata`_ Debug file ~~~~~~~~~~ diff --git a/doc/userguide/src/ExecutingTestCases/TestExecution.rst b/doc/userguide/src/ExecutingTestCases/TestExecution.rst index 5137e5e07f1..b56f32732de 100644 --- a/doc/userguide/src/ExecutingTestCases/TestExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/TestExecution.rst @@ -17,7 +17,7 @@ Executed suites and tests ~~~~~~~~~~~~~~~~~~~~~~~~~ Test cases are always executed within a test suite. A test suite -created from a `test case file`_ has tests directly, whereas suites +created from a `suite file`_ has tests directly, whereas suites created from directories__ have child test suites which either have tests or their own child suites. By default all the tests in an executed suite are run, but it is possible to `select tests`__ using @@ -34,7 +34,7 @@ of the keywords fails, but it is also possible to possible `setups and teardowns`_ affect the execution are discussed in the following sections. -__ `Test suite directories`_ +__ `Suite directories`_ __ `Selecting test cases`_ __ `Continue on failure`_ diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 309056a97aa..49785b05d56 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -139,7 +139,7 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | child, and so on. | | | | * `longname`: Suite name including parent suites. | | | | * `doc`: Suite documentation. | - | | | * `metadata`: `Free test suite metadata`_ as a dictionary/map. | + | | | * `metadata`: `Free suite metadata`_ as a dictionary. | | | | * `source`: An absolute path of the file/directory the suite | | | | was created from. | | | | * `suites`: Names of the direct child suites this suite has | diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index 9adc98d1391..556f3bc4f0c 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -136,14 +136,12 @@ .. _keyword-driven: `Keyword-driven style`_ .. _data-driven: `Data-driven style`_ .. _data-driven approach: `Data-driven style`_ -.. _test case file: `Test case files`_ -.. _test suite directory: `Test suite directories`_ +.. _suite file: `Suite files`_ +.. _suite directory: `Suite directories`_ .. _initialization file: `Suite initialization files`_ .. _suite initialization file: `Suite initialization files`_ .. _test case name: `Test case name and documentation`_ .. _test case documentation: `Test case name and documentation`_ -.. _test suite name: `Test suite name and documentation`_ -.. _test suite documentation: `Test suite name and documentation`_ .. _test setup: `Test setup and teardown`_ .. _test teardown: `Test setup and teardown`_ .. _test teardowns: `Test teardown`_ diff --git a/doc/userguide/src/SupportingTools/Libdoc.rst b/doc/userguide/src/SupportingTools/Libdoc.rst index 59d6c371b44..3cf7c7b73a4 100644 --- a/doc/userguide/src/SupportingTools/Libdoc.rst +++ b/doc/userguide/src/SupportingTools/Libdoc.rst @@ -18,13 +18,13 @@ Documentation can be created for: - libraries implemented using the normal static library API__, - libraries using the `dynamic API`__, including remote libraries, - `resource files`_, -- `test case files`_, and +- `suite files`_, and - `suite initialization files`_. Additionally it is possible to use XML and JSON spec files created by Libdoc earlier as an input. -.. note:: Support for generating documentation for test case files and suite +.. note:: Support for generating documentation for suite files and suite initialization files is new in Robot Framework 6.0. .. note:: The support for the JSON spec files is new in Robot Framework 4.0. From 609b6cb3e6dd73f34c3690522d11a5aceefbfed0 Mon Sep 17 00:00:00 2001 From: otemek <tomek.prezes@gmail.com> Date: Tue, 4 Apr 2023 14:36:06 +0200 Subject: [PATCH 0473/1592] Add new `Name` setting for suites (#4661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes it possible to specify a custom name for suites. Implements #4583, but documentation is still missing and some tests for invalid `Name` usage should also be added. Co-authored-by: tom bsc <etombsc@gmail.com> Co-authored-by: Pekka Klärck <peke@iki.fi> --- atest/robot/core/filter_by_names.robot | 8 +++--- atest/robot/output/processing_output.robot | 4 ++- .../output/source_and_lineno_output.robot | 4 +-- atest/robot/output/statistics.robot | 4 +-- .../output/statistics_in_log_and_report.robot | 2 +- .../robot/output/statistics_with_rebot.robot | 4 +-- atest/robot/parsing/suite_names.robot | 26 +++++++++++++++++++ atest/robot/parsing/suite_settings.robot | 10 +++---- atest/robot/rebot/merge.robot | 4 +-- .../misc/suites/subsuites2/__init__.robot | 2 ++ .../misc/suites/subsuites2/subsuite3.robot | 1 + atest/testdata/parsing/suite_settings.robot | 1 + atest/testresources/listeners/listeners.py | 23 ++++++++++------ src/robot/api/parsing.py | 2 ++ src/robot/conf/languages.py | 5 ++++ src/robot/parsing/lexer/settings.py | 8 ++++-- src/robot/parsing/lexer/tokens.py | 3 ++- src/robot/parsing/model/statements.py | 14 ++++++++++ src/robot/running/builder/transformers.py | 3 +++ utest/parsing/test_lexer.py | 20 ++++++++++++-- 20 files changed, 116 insertions(+), 32 deletions(-) create mode 100644 atest/robot/parsing/suite_names.robot create mode 100644 atest/testdata/misc/suites/subsuites2/__init__.robot diff --git a/atest/robot/core/filter_by_names.robot b/atest/robot/core/filter_by_names.robot index b252ecbe4ba..5335cbef80b 100644 --- a/atest/robot/core/filter_by_names.robot +++ b/atest/robot/core/filter_by_names.robot @@ -37,7 +37,7 @@ ${SUITE DIR} misc/suites --suite with . in name Run Suites --suite sub.suite.4 - Should Contain Suites ${SUITE} Subsuites2 + Should Contain Suites ${SUITE} Custom name for 📂 'subsuites2' Should Contain Tests ${SUITE} Test From Sub Suite 4 Should Not Contain Tests ${SUITE} SubSuite3 First SubSuite3 Second @@ -110,8 +110,8 @@ Unnecessary files are not parsed when --suite matches directory Should Contain Tests ${SUITE} SubSuite1 First SubSuite2 First --suite with long name with . in name - Run Suites --suite suites.subsuites2.sub.suite.4 - Should Contain Suites ${SUITE} Subsuites2 + Run Suites --suite "suites.Custom name for 📂 'subsuites2'.sub.suite.4" + Should Contain Suites ${SUITE} Custom name for 📂 'subsuites2' Should Contain Tests ${SUITE} Test From Sub Suite 4 Should Not Contain Tests ${SUITE} SubSuite3 First SubSuite3 Second @@ -121,7 +121,7 @@ Unnecessary files are not parsed when --suite matches directory Should Contain Tests ${SUITE} SubSuite1 First SubSuite2 First --suite with long name when executing multiple suites - Run Suites -s "Subsuites & Subsuites2.Subsuites.Sub1" misc/suites/subsuites misc/suites/subsuites2 + Run Suites -s "Suite With Prefix & Subsuites.Subsuites.Sub1" misc/suites/01__suite_with_prefix misc/suites/subsuites Should Contain Suites ${SUITE} Subsuites Should Contain Suites ${SUITE.suites[0]} Sub1 Should Contain Tests ${SUITE} SubSuite1 First diff --git a/atest/robot/output/processing_output.robot b/atest/robot/output/processing_output.robot index 522772bc735..0a6d72b6baa 100644 --- a/atest/robot/output/processing_output.robot +++ b/atest/robot/output/processing_output.robot @@ -81,7 +81,9 @@ Check Suite Defaults Check Suite Got From Misc/suites/ Directory Check Normal Suite Defaults ${SUITE} teardown=BuiltIn.Log Should Be Equal ${SUITE.status} FAIL - Should Contain Suites ${SUITE} Suite With Prefix Fourth Subsuites Subsuites2 Suite With Double Underscore Tsuite1 Tsuite2 Tsuite3 + Should Contain Suites ${SUITE} Suite With Prefix Fourth Subsuites + ... Custom name for 📂 'subsuites2' Suite With Double Underscore + ... Tsuite1 Tsuite2 Tsuite3 Should Be Empty ${SUITE.tests} Should Contain Suites ${SUITE.suites[2]} Sub1 Sub2 FOR ${s} IN diff --git a/atest/robot/output/source_and_lineno_output.robot b/atest/robot/output/source_and_lineno_output.robot index 2f05bf68c58..0b8837406d8 100644 --- a/atest/robot/output/source_and_lineno_output.robot +++ b/atest/robot/output/source_and_lineno_output.robot @@ -20,5 +20,5 @@ Source info should be correct Should Be Equal ${SUITE.suites[0].source} ${SOURCE / 'sub.suite.4.robot'} Should Be Equal ${SUITE.suites[0].tests[0].lineno} ${2} Should Be Equal ${SUITE.suites[1].source} ${SOURCE / 'subsuite3.robot'} - Should Be Equal ${SUITE.suites[1].tests[0].lineno} ${8} - Should Be Equal ${SUITE.suites[1].tests[1].lineno} ${13} + Should Be Equal ${SUITE.suites[1].tests[0].lineno} ${9} + Should Be Equal ${SUITE.suites[1].tests[1].lineno} ${14} diff --git a/atest/robot/output/statistics.robot b/atest/robot/output/statistics.robot index 9ab66e25f89..0190482c298 100644 --- a/atest/robot/output/statistics.robot +++ b/atest/robot/output/statistics.robot @@ -43,13 +43,13 @@ Combined Tag Statistics Name Can Be Given Tag Node Should Be Correct ${stats[0]} Combined tag with new name AND-OR-NOT ... 1 0 info=combined combined=d1 AND d2 -Suite statistics should be Correct +Suite statistics should be correct ${stats} = Get Element ${OUTFILE} statistics/suite Suite Node Should Be Correct ${stats[0]} Suites 12 1 Suite Node Should Be Correct ${stats[1]} Suites.Suite With Prefix 1 0 Suite Node Should Be Correct ${stats[2]} Suites.Fourth 0 1 Suite Node Should Be Correct ${stats[3]} Suites.Subsuites 2 0 - Suite Node Should Be Correct ${stats[4]} Suites.Subsuites2 3 0 + Suite Node Should Be Correct ${stats[4]} Suites.Custom name for 📂 'subsuites2' 3 0 Suite Node Should Be Correct ${stats[5]} Suites.Suite With Double Underscore 1 0 Suite Node Should Be Correct ${stats[6]} Suites.Tsuite1 3 0 Suite Node Should Be Correct ${stats[7]} Suites.Tsuite2 1 0 diff --git a/atest/robot/output/statistics_in_log_and_report.robot b/atest/robot/output/statistics_in_log_and_report.robot index 95ab992c2cc..d879fab6141 100644 --- a/atest/robot/output/statistics_in_log_and_report.robot +++ b/atest/robot/output/statistics_in_log_and_report.robot @@ -66,7 +66,7 @@ Verify suite stats ... id:s1-s2 pass:0 fail:1 skip:0 Verify stat ${stats[3]} label:Suites.Subsuites name:Subsuites ... id:s1-s3 pass:2 fail:0 skip:0 - Verify stat ${stats[4]} label:Suites.Subsuites2 name:Subsuites2 + Verify stat ${stats[4]} label:Suites.Custom name for 📂 'subsuites2' name:Custom name for 📂 'subsuites2' ... id:s1-s4 pass:3 fail:0 skip:0 Verify stat ${stats[5]} label:Suites.Suite With Double Underscore name:Suite With Double Underscore ... id:s1-s5 pass:1 fail:0 skip:0 diff --git a/atest/robot/output/statistics_with_rebot.robot b/atest/robot/output/statistics_with_rebot.robot index 9e83e99cf91..d71c8ecfdad 100644 --- a/atest/robot/output/statistics_with_rebot.robot +++ b/atest/robot/output/statistics_with_rebot.robot @@ -33,13 +33,13 @@ Tag statistics should be Correct Tag Node Should Be Correct ${stats[7]} XxX ... 12 1 -Suite statistics should be Correct +Suite statistics should be correct ${stats} = Get Element ${OUTFILE} statistics/suite Node Should Be Correct ${stats[0]} Suites 12 1 Node Should Be Correct ${stats[1]} Suites.Suite With Prefix 1 0 Node Should Be Correct ${stats[2]} Suites.Fourth 0 1 Node Should Be Correct ${stats[3]} Suites.Subsuites 2 0 - Node Should Be Correct ${stats[4]} Suites.Subsuites2 3 0 + Node Should Be Correct ${stats[4]} Suites.Custom name for 📂 'subsuites2' 3 0 Node Should Be Correct ${stats[5]} Suites.Suite With Double Underscore 1 0 Node Should Be Correct ${stats[6]} Suites.Tsuite1 3 0 Node Should Be Correct ${stats[7]} Suites.Tsuite2 1 0 diff --git a/atest/robot/parsing/suite_names.robot b/atest/robot/parsing/suite_names.robot new file mode 100644 index 00000000000..f2f4d55b29e --- /dev/null +++ b/atest/robot/parsing/suite_names.robot @@ -0,0 +1,26 @@ +*** Settings *** +Documentation Run testdata and validate that suite names are set correctly +Suite Setup Run Tests ${EMPTY} misc/suites +Test Template Should Be Equal +Resource atest_resource.robot + +*** Test Cases *** +Default directory suite name + ${SUITE.name} Suites + +Default file suite name + ${SUITE.suites[1].name} Fourth + +Default name with prefix + ${SUITE.suites[0].name} Suite With Prefix + ${SUITE.suites[0].suites[0].name} Tests With Prefix + +Name with double underscore at end + ${SUITE.suites[4].name} Suite With Double Underscore + ${SUITE.suites[4].suites[0].name} Tests With Double Underscore + +Custom directory suite name + ${SUITE.suites[3].name} Custom name for 📂 'subsuites2' + +Custom file suite name + ${SUITE.suites[3].suites[1].name} Custom name for 📜 'subsuite3.robot' diff --git a/atest/robot/parsing/suite_settings.robot b/atest/robot/parsing/suite_settings.robot index c890b0a0b4d..56652789171 100644 --- a/atest/robot/parsing/suite_settings.robot +++ b/atest/robot/parsing/suite_settings.robot @@ -7,7 +7,7 @@ Resource atest_resource.robot *** Test Cases *** Suite Name - Should Be Equal ${SUITE.name} Suite Settings + Should Be Equal ${SUITE.name} Custom name Suite Documentation ${doc} = Catenate SEPARATOR=\n @@ -30,8 +30,8 @@ Suite Documentation Should Be Equal ${SUITE.doc} ${doc} Suite Name And Documentation On Console - Stdout Should Contain Suite Settings :: 1st logical line (i.e. paragraph) is shortdoc on console.${SPACE * 3}\n - Stdout Should Contain Suite Settings :: 1st logical line (i.e. paragraph) is shortdoc on... | PASS |\n + Stdout Should Contain Custom name :: 1st logical line (i.e. paragraph) is shortdoc on console.${SPACE * 6}\n + Stdout Should Contain Custom name :: 1st logical line (i.e. paragraph) is shortdoc on co... | PASS |\n Test Setup ${test} = Check Test Case Test Case @@ -51,11 +51,11 @@ Suite Teardown Verify Teardown ${SUITE} BuiltIn.Log Default suite teardown Invalid Setting - Error In File 0 parsing/suite_settings.robot 27 + Error In File 0 parsing/suite_settings.robot 28 ... Non-existing setting 'Invalid Setting'. Small typo should provide recommendation. - Error In File 1 parsing/suite_settings.robot 28 + Error In File 1 parsing/suite_settings.robot 29 ... SEPARATOR=\n ... Non-existing setting 'Megadata'. Did you mean: ... ${SPACE*4}Metadata diff --git a/atest/robot/rebot/merge.robot b/atest/robot/rebot/merge.robot index 77cf638e067..4f03574c4bd 100644 --- a/atest/robot/rebot/merge.robot +++ b/atest/robot/rebot/merge.robot @@ -15,11 +15,11 @@ ${MERGE 2} %{TEMPDIR}/merge-2.xml ... Suite1 First Suite1 Second ... Test With Double Underscore Test With Prefix Third In Suite1 ... Suite2 First Suite3 First -@{ALL SUITES} Fourth Subsuites Subsuites2 +@{ALL SUITES} Fourth Subsuites Custom name for 📂 'subsuites2' ... Suite With Double Underscore Suite With Prefix ... Tsuite1 Tsuite2 Tsuite3 @{SUB SUITES 1} Sub1 Sub2 -@{SUB SUITES 2} Sub.suite.4 Subsuite3 +@{SUB SUITES 2} Sub.suite.4 Custom name for 📜 'subsuite3.robot' @{RERUN TESTS} Suite4 First SubSuite1 First @{RERUN SUITES} Fourth Subsuites diff --git a/atest/testdata/misc/suites/subsuites2/__init__.robot b/atest/testdata/misc/suites/subsuites2/__init__.robot new file mode 100644 index 00000000000..a9bf44d8742 --- /dev/null +++ b/atest/testdata/misc/suites/subsuites2/__init__.robot @@ -0,0 +1,2 @@ +*** Settings *** +Name Custom name for 📂 'subsuites2' diff --git a/atest/testdata/misc/suites/subsuites2/subsuite3.robot b/atest/testdata/misc/suites/subsuites2/subsuite3.robot index c64a8d2ebc9..60f11da9034 100644 --- a/atest/testdata/misc/suites/subsuites2/subsuite3.robot +++ b/atest/testdata/misc/suites/subsuites2/subsuite3.robot @@ -1,4 +1,5 @@ *** Setting *** +Name Custom name for 📜 'subsuite3.robot' Documentation Normal test cases Force Tags f1 Default Tags d1 d2 diff --git a/atest/testdata/parsing/suite_settings.robot b/atest/testdata/parsing/suite_settings.robot index 1ffb0292577..46698a6fee2 100644 --- a/atest/testdata/parsing/suite_settings.robot +++ b/atest/testdata/parsing/suite_settings.robot @@ -1,4 +1,5 @@ *** Settings *** +Name Custom name Documentation ${1}st logical line ... (i.e. paragraph) ... is shortdoc on console. diff --git a/atest/testresources/listeners/listeners.py b/atest/testresources/listeners/listeners.py index 40c4f0b81ce..edec08ac4b0 100644 --- a/atest/testresources/listeners/listeners.py +++ b/atest/testresources/listeners/listeners.py @@ -40,14 +40,21 @@ def __init__(self, integer: int, boolean=False): class SuiteAndTestCounts: ROBOT_LISTENER_API_VERSION = '2' exp_data = { - 'Subsuites & Subsuites2': ([], ['Subsuites', 'Subsuites2'], 5), - 'Subsuites': ([], ['Sub1', 'Sub2'], 2), - 'Sub1': (['SubSuite1 First'], [], 1), - 'Sub2': (['SubSuite2 First'], [], 1), - 'Subsuites2': ([], ['Sub.Suite.4', 'Subsuite3'], 3), - 'Subsuite3': (['SubSuite3 First', 'SubSuite3 Second'], [], 2), - 'Sub.Suite.4': (['Test From Sub Suite 4'], [], 1) - } + "Subsuites & Custom name for 📂 'subsuites2'": + ([], ['Subsuites', "Custom name for 📂 'subsuites2'"], 5), + 'Subsuites': + ([], ['Sub1', 'Sub2'], 2), + 'Sub1': + (['SubSuite1 First'], [], 1), + 'Sub2': + (['SubSuite2 First'], [], 1), + "Custom name for 📂 'subsuites2'": + ([], ['Sub.Suite.4', "Custom name for 📜 'subsuite3.robot'"], 3), + "Custom name for 📜 'subsuite3.robot'": + (['SubSuite3 First', 'SubSuite3 Second'], [], 2), + 'Sub.Suite.4': + (['Test From Sub Suite 4'], [], 1) + } def start_suite(self, name, attrs): data = attrs['tests'], attrs['suites'], attrs['totaltests'] diff --git a/src/robot/api/parsing.py b/src/robot/api/parsing.py index 132afdd4fd0..1a43eda253f 100644 --- a/src/robot/api/parsing.py +++ b/src/robot/api/parsing.py @@ -207,6 +207,7 @@ class were exposed directly via the :mod:`robot.api` package, but other - :class:`~robot.parsing.model.statements.Metadata` - :class:`~robot.parsing.model.statements.ForceTags` - :class:`~robot.parsing.model.statements.DefaultTags` +- :class:`~robot.parsing.model.statements.SuiteName` - :class:`~robot.parsing.model.statements.SuiteSetup` - :class:`~robot.parsing.model.statements.SuiteTeardown` - :class:`~robot.parsing.model.statements.TestSetup` @@ -511,6 +512,7 @@ def visit_File(self, node): VariablesImport, Documentation, Metadata, + SuiteName, SuiteSetup, SuiteTeardown, TestSetup, diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 095b0c53023..7ebb3d2d297 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -166,6 +166,7 @@ class Language: library_setting = None resource_setting = None variables_setting = None + name_setting = None documentation_setting = None metadata_setting = None suite_setup_setting = None @@ -259,6 +260,7 @@ def settings(self): self.library_setting: En.library_setting, self.resource_setting: En.resource_setting, self.variables_setting: En.variables_setting, + self.name_setting: En.name_setting, self.documentation_setting: En.documentation_setting, self.metadata_setting: En.metadata_setting, self.suite_setup_setting: En.suite_setup_setting, @@ -305,6 +307,7 @@ class En(Language): library_setting = 'Library' resource_setting = 'Resource' variables_setting = 'Variables' + name_setting = 'Name' documentation_setting = 'Documentation' metadata_setting = 'Metadata' suite_setup_setting = 'Suite Setup' @@ -469,6 +472,7 @@ class Fi(Language): variables_setting = 'Muuttujat' documentation_setting = 'Dokumentaatio' metadata_setting = 'Metatiedot' + name_setting = "Nimi" suite_setup_setting = 'Setin Alustus' suite_teardown_setting = 'Setin Alasajo' test_setup_setting = 'Testin Alustus' @@ -711,6 +715,7 @@ class Pl(Language): library_setting = 'Biblioteka' resource_setting = 'Zasób' variables_setting = 'Zmienne' + name_setting = "Nazwa" documentation_setting = 'Dokumentacja' metadata_setting = 'Metadane' suite_setup_setting = 'Inicjalizacja zestawu' diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index cce09358176..76aa2280aaf 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -32,7 +32,8 @@ class Settings: 'Test Timeout', 'Test Template', 'Timeout', - 'Template' + 'Template', + 'Name' ) name_and_arguments = ( 'Metadata', @@ -109,7 +110,8 @@ def _lex_error(self, setting, values, error): def _lex_setting(self, setting, values, name): self.settings[name] = values # TODO: Change token type from 'FORCE TAGS' to 'TEST TAGS' in RF 7.0. - setting.type = name.upper() if name != 'Test Tags' else 'FORCE TAGS' + setting_type_map = {'Test Tags': 'FORCE TAGS', 'Name': 'SUITE NAME'} + setting.type = setting_type_map.get(name, name.upper()) if name in self.name_and_arguments: self._lex_name_and_arguments(values) elif name in self.name_arguments_and_with_name: @@ -138,6 +140,7 @@ class SuiteFileSettings(Settings): names = ( 'Documentation', 'Metadata', + 'Name', 'Suite Setup', 'Suite Teardown', 'Test Setup', @@ -168,6 +171,7 @@ class InitFileSettings(Settings): names = ( 'Documentation', 'Metadata', + 'Name', 'Suite Setup', 'Suite Teardown', 'Test Setup', diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 09d3fba6f24..7ae50797990 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -49,7 +49,7 @@ class Token: TESTCASE_NAME = 'TESTCASE NAME' KEYWORD_NAME = 'KEYWORD NAME' - + SUITE_NAME = 'SUITE NAME' DOCUMENTATION = 'DOCUMENTATION' SUITE_SETUP = 'SUITE SETUP' SUITE_TEARDOWN = 'SUITE TEARDOWN' @@ -118,6 +118,7 @@ class Token: )) SETTING_TOKENS = frozenset(( DOCUMENTATION, + SUITE_NAME, SUITE_SETUP, SUITE_TEARDOWN, METADATA, diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index e6d7955b3e0..83f8f956792 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -433,6 +433,20 @@ def from_params(cls, values, separator=FOUR_SPACES, eol=EOL): return cls(tokens) +@Statement.register +class SuiteName(SingleValue): + type = Token.SUITE_NAME + + @classmethod + def from_params(cls, value, separator=FOUR_SPACES, eol=EOL): + return cls([ + Token(Token.SUITE_NAME, 'Name'), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, value), + Token(Token.EOL, eol) + ]) + + @Statement.register class SuiteSetup(Fixture): type = Token.SUITE_SETUP diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index d3b6f136fd6..a276df286c9 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -36,6 +36,9 @@ def visit_Documentation(self, node): def visit_Metadata(self, node): self.suite.metadata[node.name] = node.value + def visit_SuiteName(self, node): + self.suite.name = node.value + def visit_SuiteSetup(self, node): self.suite.setup.config(name=node.name, args=node.args, lineno=node.lineno) diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index c64e5ea36c1..96e602c054a 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -43,6 +43,7 @@ def test_common_suite_settings(self): Test Timeout 1 day Force Tags foo bar Keyword Tags tag +Name Custom Suite Name ''' expected = [ (T.SETTING_HEADER, '*** Settings ***', 1, 0), @@ -88,6 +89,9 @@ def test_common_suite_settings(self): (T.KEYWORD_TAGS, 'Keyword Tags', 12, 0), (T.ARGUMENT, 'tag', 12, 18), (T.EOS, '', 12, 21), + (T.SUITE_NAME, 'Name', 13, 0), + (T.ARGUMENT, 'Custom Suite Name', 13, 18), + (T.EOS, '', 13, 35) ] assert_tokens(data, expected, get_tokens, data_only=True) assert_tokens(data, expected, get_init_tokens, data_only=True) @@ -143,6 +147,7 @@ def test_suite_settings_not_allowed_in_resource_file(self): Default Tags zap Task Tags quux Documentation Valid in all data files. +Name Bad Resource Name ''' # Values of invalid settings are ignored with `data_only=True`. expected = [ @@ -180,7 +185,10 @@ def test_suite_settings_not_allowed_in_resource_file(self): (T.EOS, '', 11, 9), (T.DOCUMENTATION, 'Documentation', 12, 0), (T.ARGUMENT, 'Valid in all data files.', 12, 18), - (T.EOS, '', 12, 42) + (T.EOS, '', 12, 42), + (T.ERROR, "Name", 13, 0, + "Setting 'Name' is not allowed in resource file."), + (T.EOS, '', 13, 4) ] assert_tokens(data, expected, get_resource_tokens, data_only=True) @@ -328,6 +336,8 @@ def test_setting_too_many_times(self): Force Tags Ignored Default Tags Used Default Tags Ignored +Name Used +Name Ignored ''' # Values of invalid settings are ignored with `data_only=True`. expected = [ @@ -386,7 +396,13 @@ def test_setting_too_many_times(self): (T.EOS, '', 18, 22), (T.ERROR, 'Default Tags', 19, 0, "Setting 'Default Tags' is allowed only once. Only the first value is used."), - (T.EOS, '', 19, 12) + (T.EOS, '', 19, 12), + ("SUITE NAME", 'Name', 20, 0), + (T.ARGUMENT, 'Used', 20, 18), + (T.EOS, '', 20, 22), + (T.ERROR, 'Name', 21, 0, + "Setting 'Name' is allowed only once. Only the first value is used."), + (T.EOS, '', 21, 4) ] assert_tokens(data, expected, data_only=True) From aabc17fe44641f59e9767b23d1efe87b1bfcadda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 4 Apr 2023 15:52:15 +0300 Subject: [PATCH 0474/1592] Document new `Name` setting. #4583 --- .../src/Appendices/AvailableSettings.rst | 4 +++- .../CreatingTestData/CreatingTestSuites.rst | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/doc/userguide/src/Appendices/AvailableSettings.rst b/doc/userguide/src/Appendices/AvailableSettings.rst index 0337e2b5c2d..c623010a9a2 100644 --- a/doc/userguide/src/Appendices/AvailableSettings.rst +++ b/doc/userguide/src/Appendices/AvailableSettings.rst @@ -31,7 +31,9 @@ importing libraries, resources, and variables. +-----------------+--------------------------------------------------------+ | Variables | Used for `taking variable files into use`_. | +-----------------+--------------------------------------------------------+ - | Documentation | Used for specifying a `test suite`__ or | + | Name | Used for setting a custom `suite name`_. | + +-----------------+--------------------------------------------------------+ + | Documentation | Used for specifying a `suite`__ or | | | `resource file`__ documentation. | +-----------------+--------------------------------------------------------+ | Metadata | Used for setting `free suite metadata`_. | diff --git a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst index 165eb907456..224ae424e61 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst @@ -25,6 +25,9 @@ only one high-level keyword. The following settings in the Setting section can be used to customize the suite: +`Name`:setting: + Used for setting a custom `suite name`_. The default name is created based + on the file or directory name. `Documentation`:setting: Used for specifying a `suite documentation`_. `Metadata`:setting: @@ -88,8 +91,9 @@ settings similarly as in `suite files`_, but setting some `test case related settings`__ is also possible. How to use different settings in the initialization files is explained below. -`Documentation`:setting:, `Metadata`:setting:, `Suite Setup`:setting:, `Suite Teardown`:setting: - These test suite specific settings work the same way as in test case files. +`Name`:setting:, `Documentation`:setting:, `Metadata`:setting:, `Suite Setup`:setting:, `Suite Teardown`:setting: + These suite specific settings work the same way in suite initialization files + as in suite files. `Test Tags`:setting: Specified tags are unconditionally set to all tests in all suite files this directory contains, recursively. New in Robot Framework 6.1. The @@ -112,7 +116,7 @@ initialization files is explained below. *** Settings *** Documentation Example suite Suite Setup Do Something ${MESSAGE} - Force Tags example + Test Tags example Library SomeLibrary *** Variables *** @@ -130,8 +134,8 @@ __ `Test case related settings in the Setting section`_ Suite name ---------- -The test suite name is constructed from the file or directory name. The name -is created so that the extension is ignored, possible underscores are +The test suite name is constructed from the file or directory name by default. +The name is created so that the extension is ignored, possible underscores are replaced with spaces, and names fully in lower case are title cased. For example, :file:`some_tests.robot` becomes :name:`Some Tests` and :file:`My_test_directory` becomes :name:`My test directory`. @@ -144,6 +148,14 @@ the prefix and underscores are removed. For example files suites :name:`Some Tests` and :name:`More Tests`, respectively, and the former is executed before the latter. +Starting from Robot Framework 6.1, it is also possible to give a custom name +to a suite by using the :setting:`Name` setting in the Setting section: + +.. sourcecode:: robotframework + + *** Settings *** + Name Custom suite name + Suite documentation ------------------- From 184bb6c003a2b3cdd159a47d7a00a8740aa28d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 4 Apr 2023 16:19:57 +0300 Subject: [PATCH 0475/1592] Enhance tests for suite names - Merge `suite_names.robot` and `test_suite_names.robot`. - Add test for invalid custom name Fixes #4583. --- atest/robot/parsing/suite_names.robot | 45 +++++++++++++++------ atest/robot/parsing/test_suite_names.robot | 47 ---------------------- utest/parsing/test_lexer.py | 4 ++ 3 files changed, 36 insertions(+), 60 deletions(-) delete mode 100644 atest/robot/parsing/test_suite_names.robot diff --git a/atest/robot/parsing/suite_names.robot b/atest/robot/parsing/suite_names.robot index f2f4d55b29e..329ff59104a 100644 --- a/atest/robot/parsing/suite_names.robot +++ b/atest/robot/parsing/suite_names.robot @@ -1,26 +1,45 @@ *** Settings *** -Documentation Run testdata and validate that suite names are set correctly -Suite Setup Run Tests ${EMPTY} misc/suites +Documentation Tests for default and custom suite names. +... Using `--name` is tested elsewhere. +Suite Setup Run Tests ${EMPTY} misc/suites misc/multiple_suites Test Template Should Be Equal Resource atest_resource.robot *** Test Cases *** -Default directory suite name - ${SUITE.name} Suites +Combined suite name + ${SUITE.name} Suites & Multiple Suites -Default file suite name - ${SUITE.suites[1].name} Fourth +Directory suite name + ${SUITE.suites[0].name} Suites + ${SUITE.suites[1].name} Multiple Suites -Default name with prefix - ${SUITE.suites[0].name} Suite With Prefix - ${SUITE.suites[0].suites[0].name} Tests With Prefix +File suite name + ${SUITE.suites[0].suites[1].name} Fourth + ${SUITE.suites[1].suites[9].name} Suite 9 Name + +Names with upper case chars are not title cased + ${SUITE.suites[1].suites[7].name} SUite7 + ${SUITE.suites[1].suites[8].name} suiTe 8 + ${SUITE.suites[1].suites[1].suites[1].name} .Sui.te.2. + +Spaces are preserved + ${SUITE.suites[1].suites[6].name} Suite 6 + +Dots in name + ${SUITE.suites[1].suites[1].name} Sub.Suite.1 + ${SUITE.suites[1].suites[1].suites[1].name} .Sui.te.2. + +Name with prefix + ${SUITE.suites[0].suites[0].name} Suite With Prefix + ${SUITE.suites[0].suites[0].suites[0].name} Tests With Prefix + ${SUITE.suites[1].suites[1].name} Sub.Suite.1 Name with double underscore at end - ${SUITE.suites[4].name} Suite With Double Underscore - ${SUITE.suites[4].suites[0].name} Tests With Double Underscore + ${SUITE.suites[0].suites[4].name} Suite With Double Underscore + ${SUITE.suites[0].suites[4].suites[0].name} Tests With Double Underscore Custom directory suite name - ${SUITE.suites[3].name} Custom name for 📂 'subsuites2' + ${SUITE.suites[0].suites[3].name} Custom name for 📂 'subsuites2' Custom file suite name - ${SUITE.suites[3].suites[1].name} Custom name for 📜 'subsuite3.robot' + ${SUITE.suites[0].suites[3].suites[1].name} Custom name for 📜 'subsuite3.robot' diff --git a/atest/robot/parsing/test_suite_names.robot b/atest/robot/parsing/test_suite_names.robot deleted file mode 100644 index 278160739de..00000000000 --- a/atest/robot/parsing/test_suite_names.robot +++ /dev/null @@ -1,47 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} misc/multiple_suites -Resource atest_resource.robot -Documentation Giving suite names from commandline is tested in robot/cli/runner/suite_name_doc_and_metadata.txt - - -*** Test Cases *** -Root Directory Suite Name - Should Be Equal ${SUITE.name} Multiple Suites - -Prefix Is Removed From File Suite Name - Should Be Equal ${SUITE.suites[0].name} Suite First - -Prefix Is Removed From Directory Suite Name - Should Be Equal ${SUITE.suites[1].name} Sub.Suite.1 - -Child File Suite Name - Should Be Equal ${SUITE.suites[6].name} Suite 6 - -Child Directory Suite Name - Should Be Equal ${SUITE.suites[1].name} Sub.Suite.1 - -Dots in suite names - Should Be Equal ${SUITE.suites[1].name} Sub.Suite.1 - Should Be Equal ${SUITE.suites[1].suites[1].name} .Sui.te.2. - -Names without uppercase chars are titlecased - Should Be Equal ${SUITE.suites[1].name} Sub.Suite.1 - Should Be Equal ${SUITE.suites[6].name} Suite 6 - Should Be Equal ${SUITE.suites[9].name} Suite 9 Name - -Names with uppercase chars are not titlecased - Should Be Equal ${SUITE.suites[7].name} SUite7 - Should Be Equal ${SUITE.suites[8].name} suiTe 8 - Should Be Equal ${SUITE.suites[1].suites[1].name} .Sui.te.2. - -Underscores are converted to spaces - Should Be Equal ${SUITE.suites[8].name} suiTe 8 - Should Be Equal ${SUITE.suites[9].name} Suite 9 Name - -Spaces are preserved - Should Be Equal ${SUITE.suites[6].name} Suite 6 - -Root File Suite Name - [Setup] Run Tests ${EMPTY} misc/pass_and_fail.robot - Should Be Equal ${SUITE.name} Pass And Fail - diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 96e602c054a..a45f8b7c44d 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -298,6 +298,7 @@ def test_too_many_values_for_single_value_settings(self): Resource Too many values Test Timeout Too much Test Template 1 2 3 4 5 +NaMe This is an invalid name ''' # Values of invalid settings are ignored with `data_only=True`. expected = [ @@ -312,6 +313,9 @@ def test_too_many_values_for_single_value_settings(self): (T.ERROR, 'Test Template', 4, 0, "Setting 'Test Template' accepts only one value, got 5."), (T.EOS, '', 4, 13), + (T.ERROR, 'NaMe', 5, 0, + "Setting 'NaMe' accepts only one value, got 5."), + (T.EOS, '', 5, 4), ] assert_tokens(data, expected, data_only=True) From 984c2e101873bbc6e34fd3229390d72dc3921df8 Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Tue, 4 Apr 2023 16:00:32 +0100 Subject: [PATCH 0476/1592] Add type hints for TestSuite.Py (#4678) Co-authored-by: serhiy <serhiy.pikho@jitsuin.com> --- src/robot/model/testsuite.py | 55 +++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 97ff5e09e0a..eb4e9c6c604 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -14,6 +14,7 @@ # limitations under the License. from pathlib import Path +from typing import TYPE_CHECKING, Iterable from robot.utils import setter @@ -27,6 +28,9 @@ from .tagsetter import TagSetter from .testcase import TestCase, TestCases +if TYPE_CHECKING: + from robot.model.visitor import SuiteVisitor + from robot.model.tags import Tags class TestSuite(ModelObject): """Base model for single suite. @@ -37,11 +41,13 @@ class TestSuite(ModelObject): test_class = TestCase #: Internal usage only. fixture_class = Keyword #: Internal usage only. repr_args = ('name',) - __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', + __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', '_my_visitors'] - def __init__(self, name: str = '', doc: str = '', metadata: dict = None, - source: Path = None, rpa: bool = False, parent: 'TestSuite' = None): + def __init__(self, name: str = '', doc: str = '', + metadata: 'Metadata | dict | None' = None, + source: 'Path | str | None' = None, rpa: bool = False, + parent: 'TestSuite | None' = None): self._name = name self.doc = doc self.metadata = metadata @@ -55,7 +61,7 @@ def __init__(self, name: str = '', doc: str = '', metadata: dict = None, self._my_visitors = [] @staticmethod - def name_from_source(source: Path): + def name_from_source(source: 'Path | str | None') -> str: if not source: return '' if not isinstance(source, Path): @@ -67,7 +73,7 @@ def name_from_source(source: Path): return name.title() if name.islower() else name @property - def _visitors(self): + def _visitors(self) -> 'list[SuiteVisitor]': parent_visitors = self.parent._visitors if self.parent else [] return self._my_visitors + parent_visitors @@ -83,37 +89,37 @@ def name(self): or ' & '.join(s.name for s in self.suites)) @name.setter - def name(self, name): + def name(self, name: str): self._name = name @setter - def source(self, source): + def source(self, source: 'Path | str | None') -> 'Path | None': return source if isinstance(source, (Path, type(None))) else Path(source) @property - def longname(self): + def longname(self) -> str: """Suite name prefixed with the long name of the parent suite.""" if not self.parent: return self.name return f'{self.parent.longname}.{self.name}' @setter - def metadata(self, metadata): + def metadata(self, metadata) -> Metadata: """Free test suite metadata as a dictionary.""" return Metadata(metadata) @setter - def suites(self, suites): + def suites(self, suites) -> 'TestSuites': """Child suites as a :class:`~.TestSuites` object.""" return TestSuites(self.__class__, self, suites) @setter - def tests(self, tests): + def tests(self, tests) -> TestCases: """Tests as a :class:`~.TestCases` object.""" return TestCases(self.test_class, self, tests) @property - def setup(self): + def setup(self) -> Keyword: """Suite setup as a :class:`~.model.keyword.Keyword` object. This attribute is a ``Keyword`` object also when a suite has no setup @@ -138,11 +144,11 @@ def setup(self): ``suite.keywords.setup``. """ if self._setup is None and self: - self._setup = create_fixture(None, self, Keyword.SETUP) + self._setup : Keyword = create_fixture(None, self, Keyword.SETUP) return self._setup @setup.setter - def setup(self, setup): + def setup(self, setup: 'Keyword | None'): self._setup = create_fixture(setup, self, Keyword.SETUP) @property @@ -161,17 +167,17 @@ def has_setup(self): return bool(self._setup) @property - def teardown(self): + def teardown(self) -> Keyword: """Suite teardown as a :class:`~.model.keyword.Keyword` object. See :attr:`setup` for more information. """ if self._teardown is None and self: - self._teardown = create_fixture(None, self, Keyword.TEARDOWN) + self._teardown: Keyword = create_fixture(None, self, Keyword.TEARDOWN) return self._teardown @teardown.setter - def teardown(self, teardown): + def teardown(self, teardown: 'Keyword | None'): self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) @property @@ -237,7 +243,10 @@ def has_tests(self): return True return any(s.has_tests for s in self.suites) - def set_tags(self, add=None, remove=None, persist=False): + def set_tags(self, + add: 'Tags | str | Iterable[str] | None' = None, + remove: 'Tags | str | Iterable[str] | None' = None, + persist: bool = False): """Add and/or remove specified tags to the tests in this suite. :param add: Tags to add as a list or, if adding only one, @@ -252,8 +261,10 @@ def set_tags(self, add=None, remove=None, persist=False): if persist: self._my_visitors.append(setter) - def filter(self, included_suites=None, included_tests=None, - included_tags=None, excluded_tags=None): + def filter(self, included_suites: 'str | Iterable[str] | None' = None, + included_tests: 'str | Iterable[str] | None' = None, + included_tags: 'str | Iterable[str] | None' = None, + excluded_tags: 'str | Iterable[str] | None' = None): """Select test cases and remove others from this suite. Parameters have the same semantics as ``--suite``, ``--test``, @@ -302,7 +313,7 @@ def visit(self, visitor): def __str__(self): return self.name - def to_dict(self): + def to_dict(self) -> dict: data = {'name': self.name} if self.doc: data['doc'] = self.doc @@ -323,7 +334,7 @@ def to_dict(self): return data -class TestSuites(ItemList): +class TestSuites(ItemList[TestSuite]): __slots__ = [] def __init__(self, suite_class=TestSuite, parent=None, suites=None): From aac85898551f77fcddfebd9ca7afcc527928e177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 4 Apr 2023 23:58:38 +0300 Subject: [PATCH 0477/1592] More type hints to robot.model.TestSuite and elsewhere. The base TestSuite ought to now be pretty well typed. Some code used by it was typed at the same time. Also few code changes to ease typing: - TagPattern is now a base class with a factory method, not a factory function. - MultiMatcher.__iter__ yields Matcher objects, not patterns. This is part of #4570. --- src/robot/model/filter.py | 72 +++++++------ src/robot/model/fixture.py | 4 +- src/robot/model/itemlist.py | 2 +- src/robot/model/namepatterns.py | 21 ++-- src/robot/model/stats.py | 2 +- src/robot/model/tags.py | 159 +++++++++++++++++------------ src/robot/model/tagsetter.py | 16 ++- src/robot/model/testsuite.py | 105 ++++++++++--------- src/robot/result/keywordremover.py | 2 +- src/robot/utils/match.py | 45 ++++---- utest/model/test_tags.py | 2 +- utest/utils/test_match.py | 5 +- 12 files changed, 239 insertions(+), 196 deletions(-) diff --git a/src/robot/model/filter.py b/src/robot/model/filter.py index 4b81a99763a..760cfeda1ea 100644 --- a/src/robot/model/filter.py +++ b/src/robot/model/filter.py @@ -13,33 +13,43 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Sequence, TYPE_CHECKING + from robot.utils import setter from .tags import TagPatterns from .namepatterns import SuiteNamePatterns, TestNamePatterns from .visitor import SuiteVisitor +if TYPE_CHECKING: + from .keyword import Keyword + from .testcase import TestCase + from .testsuite import TestSuite + class EmptySuiteRemover(SuiteVisitor): - def __init__(self, preserve_direct_children=False): + def __init__(self, preserve_direct_children: bool = False): self.preserve_direct_children = preserve_direct_children - def end_suite(self, suite): + def end_suite(self, suite: 'TestSuite'): if suite.parent or not self.preserve_direct_children: suite.suites = [s for s in suite.suites if s.test_count] - def visit_test(self, test): + def visit_test(self, test: 'TestCase'): pass - def visit_keyword(self, kw): + def visit_keyword(self, keyword: 'Keyword'): pass class Filter(EmptySuiteRemover): - def __init__(self, include_suites=None, include_tests=None, - include_tags=None, exclude_tags=None): + def __init__(self, + include_suites: 'SuiteNamePatterns|Sequence[str]|None' = None, + include_tests: 'TestNamePatterns|Sequence[str]|None' = None, + include_tags: 'TagPatterns|Sequence[str]|None' = None, + exclude_tags: 'TagPatterns|Sequence[str]|None' = None): super().__init__() self.include_suites = include_suites self.include_tests = include_tests @@ -47,19 +57,19 @@ def __init__(self, include_suites=None, include_tests=None, self.exclude_tags = exclude_tags @setter - def include_suites(self, suites): + def include_suites(self, suites) -> 'SuiteNamePatterns|None': return self._patterns_or_none(suites, SuiteNamePatterns) @setter - def include_tests(self, tests): + def include_tests(self, tests) -> 'TestNamePatterns|None': return self._patterns_or_none(tests, TestNamePatterns) @setter - def include_tags(self, tags): + def include_tags(self, tags) -> 'TagPatterns|None': return self._patterns_or_none(tags, TagPatterns) @setter - def exclude_tags(self, tags): + def exclude_tags(self, tags) -> 'TagPatterns|None': return self._patterns_or_none(tags, TagPatterns) def _patterns_or_none(self, items, pattern_class): @@ -67,43 +77,31 @@ def _patterns_or_none(self, items, pattern_class): return items return pattern_class(items) - def start_suite(self, suite): + def start_suite(self, suite: 'TestSuite'): if not self: return False if hasattr(suite, 'starttime'): suite.starttime = suite.endtime = None if self.include_suites is not None: - return self._filter_by_suite_name(suite) + if self.include_suites.match(suite.name, suite.longname): + suite.visit(Filter(include_tests=self.include_tests, + include_tags=self.include_tags, + exclude_tags=self.exclude_tags)) + return False + suite.tests = [] + return True if self.include_tests is not None: - suite.tests = self._filter(suite, self._included_by_test_name) + suite.tests = [t for t in suite.tests + if self.include_tests.match(t.name, t.longname)] if self.include_tags is not None: - suite.tests = self._filter(suite, self._included_by_tags) + suite.tests = [t for t in suite.tests + if self.include_tags.match(t.tags)] if self.exclude_tags is not None: - suite.tests = self._filter(suite, self._not_excluded_by_tags) + suite.tests = [t for t in suite.tests + if not self.exclude_tags.match(t.tags)] return bool(suite.suites) - def _filter_by_suite_name(self, suite): - if self.include_suites.match(suite.name, suite.longname): - suite.visit(Filter(include_tests=self.include_tests, - include_tags=self.include_tags, - exclude_tags=self.exclude_tags)) - return False - suite.tests = [] - return True - - def _filter(self, suite, filter): - return [t for t in suite.tests if filter(t)] - - def _included_by_test_name(self, test): - return self.include_tests.match(test.name, test.longname) - - def _included_by_tags(self, test): - return self.include_tags.match(test.tags) - - def _not_excluded_by_tags(self, test): - return not self.exclude_tags.match(test.tags) - - def __bool__(self): + def __bool__(self) -> bool: return bool(self.include_suites is not None or self.include_tests is not None or self.include_tags is not None or diff --git a/src/robot/model/fixture.py b/src/robot/model/fixture.py index a401033412a..79535d0ed3c 100644 --- a/src/robot/model/fixture.py +++ b/src/robot/model/fixture.py @@ -15,8 +15,10 @@ from collections.abc import Mapping +from .keyword import Keyword -def create_fixture(fixture, parent, fixture_type): + +def create_fixture(fixture, parent, fixture_type) -> Keyword: # TestCase and TestSuite have 'fixture_class', Keyword doesn't. fixture_class = getattr(parent, 'fixture_class', parent.__class__) if isinstance(fixture, fixture_class): diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index 8a24f8b8f09..893d3f05171 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -46,7 +46,7 @@ class ItemList(MutableSequence[T]): def __init__(self, item_class: Type[T], common_attrs: Union[dict, None] = None, - items: Union[Iterable[Union[T, dict]], None] = None): + items: Iterable[Union[T, dict]] = ()): self._item_class = item_class self._common_attrs = common_attrs self._items: List[T] = [] diff --git a/src/robot/model/namepatterns.py b/src/robot/model/namepatterns.py index 4792b5ef191..f025ccbea77 100644 --- a/src/robot/model/namepatterns.py +++ b/src/robot/model/namepatterns.py @@ -13,28 +13,31 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Iterable, Iterator, Sequence + from robot.utils import MultiMatcher -class NamePatterns: +class NamePatterns(Iterable[str]): - def __init__(self, patterns=None): - self._matcher = MultiMatcher(patterns, ignore='_') + def __init__(self, patterns: Sequence[str] = ()): + self.matcher = MultiMatcher(patterns, ignore='_') - def match(self, name, longname=None): + def match(self, name: str, longname: 'str|None' = None) -> bool: return self._match(name) or longname and self._match_longname(longname) def _match(self, name): - return self._matcher.match(name) + return self.matcher.match(name) def _match_longname(self, name): raise NotImplementedError - def __bool__(self): - return bool(self._matcher) + def __bool__(self) -> bool: + return bool(self.matcher) - def __iter__(self): - return iter(self._matcher) + def __iter__(self) -> Iterator[str]: + for matcher in self.matcher: + yield matcher.pattern class SuiteNamePatterns(NamePatterns): diff --git a/src/robot/model/stats.py b/src/robot/model/stats.py index b4b2546df8e..6cef9b24ddc 100644 --- a/src/robot/model/stats.py +++ b/src/robot/model/stats.py @@ -165,7 +165,7 @@ class CombinedTagStat(TagStat): def __init__(self, pattern, name=None, doc='', links=None): TagStat.__init__(self, name or pattern, doc, links, combined=pattern) - self.pattern = TagPattern(pattern) + self.pattern = TagPattern.from_string(pattern) def match(self, tags): return self.pattern.match(tags) diff --git a/src/robot/model/tags.py b/src/robot/model/tags.py index 4cb61b735b7..8ce7068abf8 100644 --- a/src/robot/model/tags.py +++ b/src/robot/model/tags.py @@ -13,23 +13,26 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC, abstractmethod +from typing import Iterator, overload, Sequence + from robot.utils import is_string, normalize, NormalizedDict, Matcher -class Tags: +class Tags(Sequence[str]): __slots__ = ['_tags', '_reserved'] - def __init__(self, tags=None): + def __init__(self, tags: Sequence[str] = ()): self._tags, self._reserved = self._init_tags(tags) - def robot(self, name): + def robot(self, name: str) -> bool: """Check do tags contain a special tag in format `robot:<name>`. This is same as `'robot:<name>' in tags` but considerably faster. """ return name in self._reserved - def _init_tags(self, tags): + def _init_tags(self, tags) -> 'tuple[tuple[str, ...], tuple[str, ...]]': if not tags: return (), () if is_string(tags): @@ -46,147 +49,173 @@ def _normalize(self, tags): if tag[:6] == 'robot:') return tuple(normalized), reserved - def add(self, tags): + def add(self, tags: Sequence[str]): self.__init__(tuple(self) + tuple(Tags(tags))) - def remove(self, tags): - tags = TagPatterns(tags) - self.__init__([t for t in self if not tags.match(t)]) + def remove(self, tags: Sequence[str]): + match = TagPatterns(tags).match + self.__init__([t for t in self if not match(t)]) - def match(self, tags): + def match(self, tags: Sequence[str]) -> bool: return TagPatterns(tags).match(self) - def __contains__(self, tags): + def __contains__(self, tags) -> bool: return self.match(tags) - def __len__(self): + def __len__(self) -> int: return len(self._tags) - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self._tags) - def __str__(self): - return '[%s]' % ', '.join(self) + def __str__(self) -> str: + tags = ', '.join(self) + return f'[{tags}]' - def __repr__(self): + def __repr__(self) -> str: return repr(list(self)) - def __eq__(self, other): + def __eq__(self, other) -> bool: if not isinstance(other, Tags): other = Tags(other) self_normalized = [normalize(tag, ignore='_') for tag in self] other_normalized = [normalize(tag, ignore='_') for tag in other] return sorted(self_normalized) == sorted(other_normalized) - def __getitem__(self, index): - item = self._tags[index] - return item if not isinstance(index, slice) else Tags(item) + @overload + def __getitem__(self, index: int) -> str: + ... + + @overload + def __getitem__(self, index: slice) -> 'Tags': + ... - def __add__(self, other): + def __getitem__(self, index: 'int|slice') -> 'str|Tags': + if isinstance(index, slice): + return Tags(self._tags[index]) + return self._tags[index] + + def __add__(self, other: Sequence[str]) -> 'Tags': return Tags(tuple(self) + tuple(Tags(other))) -class TagPatterns: +class TagPatterns(Sequence['TagPattern']): - def __init__(self, patterns): - self._patterns = tuple(TagPattern(p) for p in Tags(patterns)) + def __init__(self, patterns: Sequence[str]): + self._patterns = tuple(TagPattern.from_string(p) for p in Tags(patterns)) - def match(self, tags): + def match(self, tags: Sequence[str]) -> bool: if not self._patterns: return False tags = tags if isinstance(tags, Tags) else Tags(tags) return any(p.match(tags) for p in self._patterns) - def __contains__(self, tag): + def __contains__(self, tag: str) -> bool: return self.match(tag) - def __len__(self): + def __len__(self) -> int: return len(self._patterns) - def __iter__(self): + def __iter__(self) -> Iterator['TagPattern']: return iter(self._patterns) - def __getitem__(self, index): + def __getitem__(self, index: int) -> 'TagPattern': return self._patterns[index] - def __str__(self): - return '[%s]' % ', '.join(str(pattern) for pattern in self) + def __str__(self) -> str: + patterns = ', '.join(str(pattern) for pattern in self) + return f'[{patterns}]' + + +class TagPattern(ABC): + + @classmethod + def from_string(cls, pattern: str) -> 'TagPattern': + pattern = pattern.replace(' ', '') + if 'NOT' in pattern: + must_match, *must_not_match = pattern.split('NOT') + return NotTagPattern(must_match, must_not_match) + if 'OR' in pattern: + return OrTagPattern(pattern.split('OR')) + if 'AND' in pattern or '&' in pattern: + return AndTagPattern(pattern.replace('&', 'AND').split('AND')) + return SingleTagPattern(pattern) + + @abstractmethod + def match(self, tags: Sequence[str]) -> bool: + raise NotImplementedError + @abstractmethod + def __iter__(self) -> Iterator['TagPattern']: + raise NotImplementedError -def TagPattern(pattern): - pattern = pattern.replace(' ', '') - if 'NOT' in pattern: - return NotTagPattern(*pattern.split('NOT')) - if 'OR' in pattern: - return OrTagPattern(pattern.split('OR')) - if 'AND' in pattern or '&' in pattern: - return AndTagPattern(pattern.replace('&', 'AND').split('AND')) - return SingleTagPattern(pattern) + @abstractmethod + def __str__(self) -> str: + raise NotImplementedError -class SingleTagPattern: +class SingleTagPattern(TagPattern): - def __init__(self, pattern): + def __init__(self, pattern: str): self._matcher = Matcher(pattern, ignore='_') - def match(self, tags): + def match(self, tags: Sequence[str]) -> bool: return self._matcher.match_any(tags) - def __iter__(self): + def __iter__(self) -> Iterator['TagPattern']: yield self - def __str__(self): + def __str__(self) -> str: return self._matcher.pattern - def __bool__(self): + def __bool__(self) -> bool: return bool(self._matcher) -class AndTagPattern: +class AndTagPattern(TagPattern): - def __init__(self, patterns): - self._patterns = tuple(TagPattern(p) for p in patterns) + def __init__(self, patterns: Sequence[str]): + self._patterns = tuple(TagPattern.from_string(p) for p in patterns) - def match(self, tags): + def match(self, tags: Sequence[str]) -> bool: return all(p.match(tags) for p in self._patterns) - def __iter__(self): + def __iter__(self) -> Iterator['TagPattern']: return iter(self._patterns) - def __str__(self): + def __str__(self) -> str: return ' AND '.join(str(pattern) for pattern in self) -class OrTagPattern: +class OrTagPattern(TagPattern): - def __init__(self, patterns): - self._patterns = tuple(TagPattern(p) for p in patterns) + def __init__(self, patterns: Sequence[str]): + self._patterns = tuple(TagPattern.from_string(p) for p in patterns) - def match(self, tags): + def match(self, tags: Sequence[str]) -> bool: return any(p.match(tags) for p in self._patterns) - def __iter__(self): + def __iter__(self) -> Iterator['TagPattern']: return iter(self._patterns) - def __str__(self): + def __str__(self) -> str: return ' OR '.join(str(pattern) for pattern in self) -class NotTagPattern: +class NotTagPattern(TagPattern): - def __init__(self, must_match, *must_not_match): - self._first = TagPattern(must_match) + def __init__(self, must_match: str, must_not_match: Sequence[str]): + self._first = TagPattern.from_string(must_match) self._rest = OrTagPattern(must_not_match) - def match(self, tags): + def match(self, tags: Sequence[str]) -> bool: if not self._first: return not self._rest.match(tags) return self._first.match(tags) and not self._rest.match(tags) - def __iter__(self): + def __iter__(self) -> Iterator['TagPattern']: yield self._first - for pattern in self._rest: - yield pattern + yield from self._rest - def __str__(self): + def __str__(self) -> str: return ' NOT '.join(str(pattern) for pattern in self).lstrip() diff --git a/src/robot/model/tagsetter.py b/src/robot/model/tagsetter.py index 08466b17a9c..ba5662f5cb7 100644 --- a/src/robot/model/tagsetter.py +++ b/src/robot/model/tagsetter.py @@ -13,23 +13,31 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Sequence, TYPE_CHECKING + from .visitor import SuiteVisitor +if TYPE_CHECKING: + from .keyword import Keyword + from .testcase import TestCase + from .testsuite import TestSuite + class TagSetter(SuiteVisitor): - def __init__(self, add=None, remove=None): + def __init__(self, add: 'Sequence[str]|str' = (), + remove: 'Sequence[str]|str' = ()): self.add = add self.remove = remove - def start_suite(self, suite): + def start_suite(self, suite: 'TestSuite'): return bool(self) - def visit_test(self, test): + def visit_test(self, test: 'TestCase'): test.tags.add(self.add) test.tags.remove(self.remove) - def visit_keyword(self, keyword): + def visit_keyword(self, keyword: 'Keyword'): pass def __bool__(self): diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index eb4e9c6c604..a54f374470d 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -13,8 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, Iterable +from typing import Iterator, Sequence, Type, TYPE_CHECKING from robot.utils import setter @@ -30,7 +31,7 @@ if TYPE_CHECKING: from robot.model.visitor import SuiteVisitor - from robot.model.tags import Tags + class TestSuite(ModelObject): """Base model for single suite. @@ -44,24 +45,23 @@ class TestSuite(ModelObject): __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', '_my_visitors'] - def __init__(self, name: str = '', doc: str = '', - metadata: 'Metadata | dict | None' = None, - source: 'Path | str | None' = None, rpa: bool = False, - parent: 'TestSuite | None' = None): + def __init__(self, name: str = '', doc: str = '', metadata: 'Mapping|None' = None, + source: 'Path|str|None' = None, rpa: bool = False, + parent: 'TestSuite|None' = None): self._name = name self.doc = doc self.metadata = metadata self.source = source self.parent = parent - self.rpa = rpa #: ``True`` when RPA mode is enabled. - self.suites = None - self.tests = None - self._setup = None - self._teardown = None - self._my_visitors = [] + self.rpa = rpa + self.suites = [] + self.tests = [] + self._setup: 'Keyword|None' = None + self._teardown: 'Keyword|None' = None + self._my_visitors: 'list[SuiteVisitor]' = [] @staticmethod - def name_from_source(source: 'Path | str | None') -> str: + def name_from_source(source: 'Path|str|None') -> str: if not source: return '' if not isinstance(source, Path): @@ -78,11 +78,12 @@ def _visitors(self) -> 'list[SuiteVisitor]': return self._my_visitors + parent_visitors @property - def name(self): - """Test suite name. + def name(self) -> str: + """Suite name. If name is not set, it is constructed from source. If source is not set, - name is constructed from child suite names or. + name is constructed from child suite names by concatenating them with + `` & ``. If there are no child suites, name is an empty string. """ return (self._name or self.name_from_source(self.source) @@ -93,7 +94,7 @@ def name(self, name: str): self._name = name @setter - def source(self, source: 'Path | str | None') -> 'Path | None': + def source(self, source: 'Path|str|None') -> 'Path|None': return source if isinstance(source, (Path, type(None))) else Path(source) @property @@ -104,26 +105,25 @@ def longname(self) -> str: return f'{self.parent.longname}.{self.name}' @setter - def metadata(self, metadata) -> Metadata: - """Free test suite metadata as a dictionary.""" + def metadata(self, metadata: 'Mapping|None') -> Metadata: + """Free suite metadata as dictionary-like ``Metadata`` object.""" return Metadata(metadata) @setter - def suites(self, suites) -> 'TestSuites': - """Child suites as a :class:`~.TestSuites` object.""" + def suites(self, suites: Sequence['TestSuite']) -> 'TestSuites': return TestSuites(self.__class__, self, suites) @setter - def tests(self, tests) -> TestCases: - """Tests as a :class:`~.TestCases` object.""" + def tests(self, tests: Sequence[TestCase]) -> TestCases: return TestCases(self.test_class, self, tests) @property def setup(self) -> Keyword: - """Suite setup as a :class:`~.model.keyword.Keyword` object. + """Suite setup. This attribute is a ``Keyword`` object also when a suite has no setup - but in that case its truth value is ``False``. + but in that case its truth value is ``False``. The preferred way to + check does a suite have a setup is using :attr:`has_setup`. Setup can be modified by setting attributes directly:: @@ -143,8 +143,8 @@ def setup(self) -> Keyword: New in Robot Framework 4.0. Earlier setup was accessed like ``suite.keywords.setup``. """ - if self._setup is None and self: - self._setup : Keyword = create_fixture(None, self, Keyword.SETUP) + if self._setup is None: + self._setup = create_fixture(None, self, Keyword.SETUP) return self._setup @setup.setter @@ -152,28 +152,27 @@ def setup(self, setup: 'Keyword | None'): self._setup = create_fixture(setup, self, Keyword.SETUP) @property - def has_setup(self): + def has_setup(self) -> bool: """Check does a suite have a setup without creating a setup object. A difference between using ``if suite.has_setup:`` and ``if suite.setup:`` is that accessing the :attr:`setup` attribute creates a :class:`Keyword` object representing the setup even when the suite actually does not have one. This typically does not matter, but with bigger suite structures - containing a huge about of suites it can have some effect on memory usage. + it can have some effect on memory usage. New in Robot Framework 5.0. """ - return bool(self._setup) @property def teardown(self) -> Keyword: - """Suite teardown as a :class:`~.model.keyword.Keyword` object. + """Suite teardown. See :attr:`setup` for more information. """ - if self._teardown is None and self: - self._teardown: Keyword = create_fixture(None, self, Keyword.TEARDOWN) + if self._teardown is None: + self._teardown = create_fixture(None, self, Keyword.TEARDOWN) return self._teardown @teardown.setter @@ -181,7 +180,7 @@ def teardown(self, teardown: 'Keyword | None'): self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) @property - def has_teardown(self): + def has_teardown(self) -> bool: """Check does a suite have a teardown without creating a teardown object. See :attr:`has_setup` for more information. @@ -191,8 +190,8 @@ def has_teardown(self): return bool(self._teardown) @property - def keywords(self): - """Deprecated since Robot Framework 4.0 + def keywords(self) -> Keywords: + """Deprecated since Robot Framework 4.0. Use :attr:`setup` or :attr:`teardown` instead. """ @@ -204,7 +203,7 @@ def keywords(self, keywords): Keywords.raise_deprecation_error() @property - def id(self): + def id(self) -> str: """An automatically generated unique id. The root suite has id ``s1``, its child suites have ids ``s1-s1``, @@ -222,7 +221,7 @@ def id(self): return f'{self.parent.id}-s{index + 1}' @property - def all_tests(self): + def all_tests(self) -> Iterator[TestCase]: """Yields all tests this suite and its child suites contain. New in Robot Framework 6.1. @@ -232,21 +231,19 @@ def all_tests(self): yield from suite.all_tests @property - def test_count(self): + def test_count(self) -> int: """Total number of the tests in this suite and in its child suites.""" # This is considerably faster than `return len(list(self.all_tests))`. return len(self.tests) + sum(suite.test_count for suite in self.suites) @property - def has_tests(self): + def has_tests(self) -> bool: if self.tests: return True return any(s.has_tests for s in self.suites) - def set_tags(self, - add: 'Tags | str | Iterable[str] | None' = None, - remove: 'Tags | str | Iterable[str] | None' = None, - persist: bool = False): + def set_tags(self, add: Sequence[str] = (), remove: Sequence[str] = (), + persist: bool = False): """Add and/or remove specified tags to the tests in this suite. :param add: Tags to add as a list or, if adding only one, @@ -261,10 +258,10 @@ def set_tags(self, if persist: self._my_visitors.append(setter) - def filter(self, included_suites: 'str | Iterable[str] | None' = None, - included_tests: 'str | Iterable[str] | None' = None, - included_tags: 'str | Iterable[str] | None' = None, - excluded_tags: 'str | Iterable[str] | None' = None): + def filter(self, included_suites: 'Sequence[str]|None' = None, + included_tests: 'Sequence[str]|None' = None, + included_tags: 'Sequence[str]|None' = None, + excluded_tags: 'Sequence[str]|None' = None): """Select test cases and remove others from this suite. Parameters have the same semantics as ``--suite``, ``--test``, @@ -302,19 +299,20 @@ def configure(self, **options): if options: self.visit(SuiteConfigurer(**options)) - def remove_empty_suites(self, preserve_direct_children=False): + def remove_empty_suites(self, preserve_direct_children: bool = False): """Removes all child suites not containing any tests, recursively.""" self.visit(EmptySuiteRemover(preserve_direct_children)) - def visit(self, visitor): + def visit(self, visitor: 'SuiteVisitor'): """:mod:`Visitor interface <robot.model.visitor>` entry-point.""" visitor.visit_suite(self) - def __str__(self): + def __str__(self) -> str: return self.name def to_dict(self) -> dict: - data = {'name': self.name} + data = {} + data['name'] = self.name if self.doc: data['doc'] = self.doc if self.metadata: @@ -337,5 +335,6 @@ def to_dict(self) -> dict: class TestSuites(ItemList[TestSuite]): __slots__ = [] - def __init__(self, suite_class=TestSuite, parent=None, suites=None): + def __init__(self, suite_class: Type[TestSuite] = TestSuite, + parent: 'TestSuite|None' = None, suites: Sequence[TestSuite] = ()): super().__init__(suite_class, {'parent': parent}, suites) diff --git a/src/robot/result/keywordremover.py b/src/robot/result/keywordremover.py index 9e58eb1b7ae..cf7c16d8cbe 100644 --- a/src/robot/result/keywordremover.py +++ b/src/robot/result/keywordremover.py @@ -98,7 +98,7 @@ class ByTagKeywordRemover(_KeywordRemover): def __init__(self, pattern): _KeywordRemover.__init__(self) - self._pattern = TagPattern(pattern) + self._pattern = TagPattern.from_string(pattern) def start_keyword(self, kw): if self._pattern.match(kw.tags) and not self._warning_or_error(kw): diff --git a/src/robot/utils/match.py b/src/robot/utils/match.py index ed567b49918..d941e438843 100644 --- a/src/robot/utils/match.py +++ b/src/robot/utils/match.py @@ -16,12 +16,14 @@ import re import fnmatch from functools import partial +from typing import Iterable, Iterator, Sequence from .normalizing import normalize from .robottypes import is_string -def eq(str1, str2, ignore=(), caseless=True, spaceless=True): +def eq(str1: str, str2: str, ignore: Sequence[str] = (), caseless: bool = True, + spaceless: bool = True) -> bool: str1 = normalize(str1, ignore, caseless, spaceless) str2 = normalize(str2, ignore, caseless, spaceless) return str1 == str2 @@ -29,7 +31,8 @@ def eq(str1, str2, ignore=(), caseless=True, spaceless=True): class Matcher: - def __init__(self, pattern, ignore=(), caseless=True, spaceless=True, regexp=False): + def __init__(self, pattern: str, ignore: Sequence[str] = (), caseless: bool = True, + spaceless: bool = True, regexp: bool = False): self.pattern = pattern self._normalize = partial(normalize, ignore=ignore, caseless=caseless, spaceless=spaceless) @@ -40,23 +43,24 @@ def _compile(self, pattern, regexp=False): pattern = fnmatch.translate(pattern) return re.compile(pattern, re.DOTALL) - def match(self, string): + def match(self, string: str) -> bool: return self._regexp.match(self._normalize(string)) is not None - def match_any(self, strings): + def match_any(self, strings: Sequence[str]) -> bool: return any(self.match(s) for s in strings) - def __bool__(self): + def __bool__(self) -> bool: return bool(self._normalize(self.pattern)) -class MultiMatcher: +class MultiMatcher(Iterable[Matcher]): - def __init__(self, patterns=None, ignore=(), caseless=True, spaceless=True, - match_if_no_patterns=False, regexp=False): - self._matchers = [Matcher(pattern, ignore, caseless, spaceless, regexp) - for pattern in self._ensure_list(patterns)] - self._match_if_no_patterns = match_if_no_patterns + def __init__(self, patterns: Sequence[str] = (), ignore: Sequence[str] = (), + caseless: bool = True, spaceless: bool = True, + match_if_no_patterns: bool = False, regexp: bool = False): + self.matchers = [Matcher(pattern, ignore, caseless, spaceless, regexp) + for pattern in self._ensure_list(patterns)] + self.match_if_no_patterns = match_if_no_patterns def _ensure_list(self, patterns): if patterns is None: @@ -65,17 +69,16 @@ def _ensure_list(self, patterns): return [patterns] return patterns - def match(self, string): - if self._matchers: - return any(m.match(string) for m in self._matchers) - return self._match_if_no_patterns + def match(self, string: str) -> bool: + if self.matchers: + return any(m.match(string) for m in self.matchers) + return self.match_if_no_patterns - def match_any(self, strings): + def match_any(self, strings: Sequence[str]) -> bool: return any(self.match(s) for s in strings) - def __len__(self): - return len(self._matchers) + def __len__(self) -> int: + return len(self.matchers) - def __iter__(self): - for matcher in self._matchers: - yield matcher.pattern + def __iter__(self) -> Iterator[Matcher]: + return iter(self.matchers) diff --git a/utest/model/test_tags.py b/utest/model/test_tags.py index d9f272e3271..0dcf9dca072 100644 --- a/utest/model/test_tags.py +++ b/utest/model/test_tags.py @@ -239,7 +239,7 @@ def test_multiple_ors(self): def test_ands_and_ors(self): for pattern in AndOrPatternGenerator(max_length=5): expected = eval(pattern.lower()) - assert_equal(TagPattern(pattern).match('1'), expected) + assert_equal(TagPattern.from_string(pattern).match('1'), expected) def test_not(self): patterns = TagPatterns(['xNOTy', '???NOT?']) diff --git a/utest/utils/test_match.py b/utest/utils/test_match.py index ebe1299a31e..460b4aad75e 100644 --- a/utest/utils/test_match.py +++ b/utest/utils/test_match.py @@ -180,9 +180,10 @@ def test_len(self): def test_iter(self): assert_equal(tuple(MultiMatcher()), ()) - assert_equal(list(MultiMatcher(['1', 'xxx', '3'])), ['1', 'xxx', '3']) + assert_equal([m.pattern for m in MultiMatcher(['1', 'xxx', '3'])], + ['1', 'xxx', '3']) assert_equal(tuple(MultiMatcher(regexp=True)), ()) - assert_equal(list(MultiMatcher(['1', 'xxx', '3'], regexp=True)), + assert_equal([m.pattern for m in MultiMatcher(['1', 'xxx', '3'], regexp=True)], ['1', 'xxx', '3']) def test_single_string_is_converted_to_list(self): From afcbbffd41c479e262260e858f62c77772419d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 5 Apr 2023 00:15:06 +0300 Subject: [PATCH 0478/1592] Fix `Tags.robot()` if reserved tag contains `:`. We don't have such reserved tags now, but may have in the future. Also add public API to `NormalizedDict` for getting normalized keys. --- src/robot/model/tags.py | 17 ++++++++--------- src/robot/utils/normalizing.py | 11 ++++++----- utest/model/test_tags.py | 7 +++++++ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/robot/model/tags.py b/src/robot/model/tags.py index 8ce7068abf8..096ce30656d 100644 --- a/src/robot/model/tags.py +++ b/src/robot/model/tags.py @@ -26,7 +26,7 @@ def __init__(self, tags: Sequence[str] = ()): self._tags, self._reserved = self._init_tags(tags) def robot(self, name: str) -> bool: - """Check do tags contain a special tag in format `robot:<name>`. + """Check do tags contain a reserved tag in format `robot:<name>`. This is same as `'robot:<name>' in tags` but considerably faster. """ @@ -40,14 +40,13 @@ def _init_tags(self, tags) -> 'tuple[tuple[str, ...], tuple[str, ...]]': return self._normalize(tags) def _normalize(self, tags): - normalized = NormalizedDict([(str(t), None) for t in tags], ignore='_') - if '' in normalized: - del normalized[''] - if 'NONE' in normalized: - del normalized['NONE'] - reserved = tuple(tag.split(':')[1] for tag in normalized._keys - if tag[:6] == 'robot:') - return tuple(normalized), reserved + nd = NormalizedDict([(str(t), None) for t in tags], ignore='_') + if '' in nd: + del nd[''] + if 'NONE' in nd: + del nd['NONE'] + reserved = tuple(tag[6:] for tag in nd.normalized_keys if tag[:6] == 'robot:') + return tuple(nd), reserved def add(self, tags: Sequence[str]): self.__init__(tuple(self) + tuple(Tags(tags))) diff --git a/src/robot/utils/normalizing.py b/src/robot/utils/normalizing.py index c1b36109c26..5f971564510 100644 --- a/src/robot/utils/normalizing.py +++ b/src/robot/utils/normalizing.py @@ -62,12 +62,13 @@ def __init__(self, initial=None, ignore=(), caseless=True, spaceless=True): self._keys = {} self._normalize = lambda s: normalize(s, ignore, caseless, spaceless) if initial: - self._add_initial(initial) + items = initial.items() if hasattr(initial, 'items') else initial + for key, value in items: + self[key] = value - def _add_initial(self, initial): - items = initial.items() if hasattr(initial, 'items') else initial - for key, value in items: - self[key] = value + @property + def normalized_keys(self): + return self._keys def __getitem__(self, key): return self._data[self._normalize(key)] diff --git a/utest/model/test_tags.py b/utest/model/test_tags.py index 0dcf9dca072..80350b36fe6 100644 --- a/utest/model/test_tags.py +++ b/utest/model/test_tags.py @@ -28,6 +28,13 @@ def test_init_with_non_strings(self): def test_init_with_none(self): assert_equal(list(Tags(None)), []) + def test_robot(self): + assert_equal(Tags().robot('x'), False) + assert_equal(Tags('robot:x').robot('x'), True) + assert_equal(Tags(['ROBOT : X']).robot('x'), True) + assert_equal(Tags('robot:x:y').robot('x:y'), True) + assert_equal(Tags('robot:x').robot('y'), False) + def test_add_string(self): tags = Tags(['Y']) tags.add('x') From 9e675bb118e9f1cdf3dde486faf0f32f92bb2f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 5 Apr 2023 09:33:31 +0300 Subject: [PATCH 0479/1592] Localization tests for new Name setting #4583. --- atest/robot/parsing/translations.robot | 1 + atest/testdata/parsing/translations/custom/custom.py | 1 + atest/testdata/parsing/translations/custom/custom_per_file.robot | 1 + atest/testdata/parsing/translations/custom/tests.robot | 1 + atest/testdata/parsing/translations/finnish/tests.robot | 1 + atest/testdata/parsing/translations/per_file_config/fi.robot | 1 + 6 files changed, 6 insertions(+) diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index d7b8cea5edb..b06b83b686d 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -62,6 +62,7 @@ Per file configuration bleeds to other files *** Keywords *** Validate Translations [Arguments] ${suite}=${SUITE} + Should Be Equal ${suite.name} Custom name Should Be Equal ${suite.doc} Suite documentation. Should Be Equal ${suite.metadata}[Metadata] Value Should Be Equal ${suite.setup.name} Suite Setup diff --git a/atest/testdata/parsing/translations/custom/custom.py b/atest/testdata/parsing/translations/custom/custom.py index 2ff93fb6665..9f971c5267f 100644 --- a/atest/testdata/parsing/translations/custom/custom.py +++ b/atest/testdata/parsing/translations/custom/custom.py @@ -11,6 +11,7 @@ class Custom(Language): library_setting = 'L' resource_setting = 'R' variables_setting = 'V' + name_setting = 'N' documentation_setting = 'D' metadata_setting = 'M' suite_setup_setting = 'S S' diff --git a/atest/testdata/parsing/translations/custom/custom_per_file.robot b/atest/testdata/parsing/translations/custom/custom_per_file.robot index 3b2be0ed403..a32d124e97f 100644 --- a/atest/testdata/parsing/translations/custom/custom_per_file.robot +++ b/atest/testdata/parsing/translations/custom/custom_per_file.robot @@ -1,5 +1,6 @@ language: custom *** H S *** +N Custom name D Suite documentation. M Metadata Value S S Suite Setup diff --git a/atest/testdata/parsing/translations/custom/tests.robot b/atest/testdata/parsing/translations/custom/tests.robot index 73b54ef9538..c45e1aa41f0 100644 --- a/atest/testdata/parsing/translations/custom/tests.robot +++ b/atest/testdata/parsing/translations/custom/tests.robot @@ -1,4 +1,5 @@ *** H S *** +N Custom name D Suite documentation. M Metadata Value S S Suite Setup diff --git a/atest/testdata/parsing/translations/finnish/tests.robot b/atest/testdata/parsing/translations/finnish/tests.robot index acdf47a5226..56b939d4c83 100644 --- a/atest/testdata/parsing/translations/finnish/tests.robot +++ b/atest/testdata/parsing/translations/finnish/tests.robot @@ -1,4 +1,5 @@ *** Asetukset *** +Nimi Custom name Dokumentaatio Suite documentation. Metatiedot Metadata Value Setin Alustus Suite Setup diff --git a/atest/testdata/parsing/translations/per_file_config/fi.robot b/atest/testdata/parsing/translations/per_file_config/fi.robot index 3ac09b1d617..dcae91c4ea9 100644 --- a/atest/testdata/parsing/translations/per_file_config/fi.robot +++ b/atest/testdata/parsing/translations/per_file_config/fi.robot @@ -1,6 +1,7 @@ language: fi *** Asetukset *** +Nimi Custom name Dokumentaatio Suite documentation. Metatiedot Metadata Value Setin Alustus Suite Setup From 1ad6c528e86fa5421c45c38c66416a125d75ba3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 5 Apr 2023 09:35:36 +0300 Subject: [PATCH 0480/1592] Include new Name setting in UG translations tables #4583 --- doc/userguide/src/Appendices/Translations.rst | 42 +++++++++++++++++++ doc/userguide/translations.py | 2 + 2 files changed, 44 insertions(+) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index 132452a0b33..79280944289 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -65,6 +65,8 @@ Settings - Ресурс * - Variables - Променлива + * - Name + - * - Documentation - Документация * - Metadata @@ -190,6 +192,8 @@ Settings - Resursi * - Variables - Varijable + * - Name + - * - Documentation - Dokumentacija * - Metadata @@ -315,6 +319,8 @@ Settings - Zdroj * - Variables - Proměnná + * - Name + - * - Documentation - Dokumentace * - Metadata @@ -440,6 +446,8 @@ Settings - Ressource * - Variables - Variablen + * - Name + - * - Documentation - Dokumentation * - Metadata @@ -565,6 +573,8 @@ Settings - Recursos * - Variables - Variable + * - Name + - * - Documentation - Documentación * - Metadata @@ -690,6 +700,8 @@ Settings - Resurssi * - Variables - Muuttujat + * - Name + - Nimi * - Documentation - Dokumentaatio * - Metadata @@ -815,6 +827,8 @@ Settings - Ressource * - Variables - Variable + * - Name + - * - Documentation - Documentation * - Metadata @@ -940,6 +954,8 @@ Settings - संसाधन * - Variables - चर + * - Name + - * - Documentation - प्रलेखन * - Metadata @@ -1065,6 +1081,8 @@ Settings - Risorsa * - Variables - Variabile + * - Name + - * - Documentation - Documentazione * - Metadata @@ -1190,6 +1208,8 @@ Settings - Resource * - Variables - Variabele + * - Name + - * - Documentation - Documentatie * - Metadata @@ -1315,6 +1335,8 @@ Settings - Zasób * - Variables - Zmienne + * - Name + - Nazwa * - Documentation - Dokumentacja * - Metadata @@ -1440,6 +1462,8 @@ Settings - Recurso * - Variables - Variável + * - Name + - * - Documentation - Documentação * - Metadata @@ -1565,6 +1589,8 @@ Settings - Recurso * - Variables - Variável + * - Name + - * - Documentation - Documentação * - Metadata @@ -1690,6 +1716,8 @@ Settings - Resursa * - Variables - Variabila + * - Name + - * - Documentation - Documentatie * - Metadata @@ -1815,6 +1843,8 @@ Settings - Ресурс * - Variables - Переменные + * - Name + - * - Documentation - Документация * - Metadata @@ -1940,6 +1970,8 @@ Settings - Resurs * - Variables - Variabel + * - Name + - * - Documentation - Dokumentation * - Metadata @@ -2065,6 +2097,8 @@ Settings - ไฟล์ที่ใช้ * - Variables - ชุดตัวแปร + * - Name + - * - Documentation - เอกสาร * - Metadata @@ -2190,6 +2224,8 @@ Settings - Kaynak * - Variables - Değişkenler + * - Name + - * - Documentation - Dokümantasyon * - Metadata @@ -2315,6 +2351,8 @@ Settings - Ресурс * - Variables - Змінна + * - Name + - * - Documentation - Документація * - Metadata @@ -2440,6 +2478,8 @@ Settings - 资源文件 * - Variables - 变量文件 + * - Name + - * - Documentation - 说明 * - Metadata @@ -2565,6 +2605,8 @@ Settings - 資源文件 * - Variables - 變量文件 + * - Name + - * - Documentation - 說明 * - Metadata diff --git a/doc/userguide/translations.py b/doc/userguide/translations.py index bfcfdd6d05c..8125276f219 100644 --- a/doc/userguide/translations.py +++ b/doc/userguide/translations.py @@ -100,6 +100,8 @@ def false_strings(self): - {lang.resource_setting} * - Variables - {lang.variables_setting} + * - Name + - {lang.name_setting} * - Documentation - {lang.documentation_setting} * - Metadata From adf1a866bb8f7cd7ae2a2e8b16dae0bf80526a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 5 Apr 2023 16:23:45 +0300 Subject: [PATCH 0481/1592] UG: Enhance --test and --suite documentation. - Explain their usage and how they work together better. - Explain that new Name is not compatible with --suite (#4583) - Explain how --suite is going to be changed (incl. fixing of the above issue) in RF 7.0 (#4720, #4721, #4688) --- .../src/Appendices/CommandLineOptions.rst | 5 +- .../CreatingTestData/CreatingTestSuites.rst | 6 + .../ConfiguringExecution.rst | 117 +++++++++++++----- 3 files changed, 91 insertions(+), 37 deletions(-) diff --git a/doc/userguide/src/Appendices/CommandLineOptions.rst b/doc/userguide/src/Appendices/CommandLineOptions.rst index f112a02b9a2..0d5155ad2fe 100644 --- a/doc/userguide/src/Appendices/CommandLineOptions.rst +++ b/doc/userguide/src/Appendices/CommandLineOptions.rst @@ -148,15 +148,14 @@ Command line options for post-processing outputs -h, --help Prints `usage instructions`_. --version Prints the `version information`_. - .. _generic automation: `Task execution`_ .. _Parse only these files: `Selecting files to parse`_ .. _Sets the name: `Setting the name`_ .. _Sets the documentation: `Setting the documentation`_ .. _Sets free metadata: `Setting free metadata`_ .. _Sets the tag(s): `Setting tags`_ -.. _Selects the test cases by name: `By test suite and test case names`_ -.. _Selects the test suites: `Selects the test cases by name`_ +.. _Selects the test cases by name: `By test names`_ +.. _Selects the test suites: `By suite names`_ .. _Selects failed test suites: `Re-executing failed test suites`_ .. _Selects failed tests: `Re-executing failed test cases`_ .. _Selects the test cases: `By tag names`_ diff --git a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst index 224ae424e61..31e71952582 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst @@ -156,6 +156,12 @@ to a suite by using the :setting:`Name` setting in the Setting section: *** Settings *** Name Custom suite name +.. note:: The :setting:`Name` setting is not compatible with the :option:`--suite` + option that can be used to select tests `by suite names`_. This `will + fixed`__ in Robot Framework 7.0. + +__ https://github.com/robotframework/robotframework/issues/4688 + Suite documentation ------------------- diff --git a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst index b218299725f..e66f3bbf4b3 100644 --- a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst @@ -54,49 +54,98 @@ Robot Framework offers several command line options for selecting which test cases to execute. The same options work also when `executing tasks`_ and when post-processing outputs with Rebot_. -By test suite and test case names -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Test suites and test cases can be selected by their names with the command -line options :option:`--suite (-s)` and :option:`--test (-t)`, -respectively. Both of these options can be used several times to -select several test suites or cases. Arguments to these options are -case- and space-insensitive, and there can also be `simple -patterns`_ matching multiple names. If both the :option:`--suite` and -:option:`--test` options are used, only test cases in matching suites -with matching names are selected. +By test names +~~~~~~~~~~~~~ -:: +The easiest way to select only some tests to be run is using the +:option:`--test (-t)` option. As the name implies, it can be used for +selecting tests by their names. Given names are case, space and underscore +insensitive and they also support `simple patterns`_. The option can be +used multiple times to match multiple tests:: - --test Example - --test mytest --test yourtest - --test example* - --test mysuite.mytest - --test *.suite.mytest - --suite example-?? - --suite mysuite --test mytest --test your* - -Using the :option:`--suite` option is more or less the same as executing only -the appropriate test case file or directory. One major benefit is the -possibility to select the suite based on its parent suite. The syntax -for this is specifying both the parent and child suite names separated -with a dot. In this case, the possible setup and teardown of the parent -suite are executed. + --test Example # Match only tests with name 'Example'. + --test example* # Match tests starting with 'example'. + --test first --test second # Match tests with name 'first' or 'second'. -:: +To pinpoint a test more precisely, it is possible to prefix the test name +with a suite name:: + + --test mysuite.mytest # Match test 'mytest' in suite 'mysuite'. + --test root.sub.test # Match test 'test' in suite 'sub' in suite 'root'. + --test *.sub.test # Match test 'test' in suite 'sub' anywhere. - --suite parent.child - --suite myhouse.myhousemusic --test jack* +Notice that when the given name includes a suite name, it must match the whole +suite name starting from the root suite. Using a wildcard as in the last example +above allows matching suites anywhere. -Selecting individual test cases with the :option:`--test` option is very -practical when creating test cases, but quite limited when running tests -automatically. The :option:`--suite` option can be useful in that -case, but in general, selecting test cases by tag names is more -flexible. +Using the :option:`--test` option is convenient when only a few tests needs +to be selected. A common use case is running just the test that is currently +being worked on. If a bigger number of tests needs to be selected, +it is typically easier to select them `by suite names`_ or `by tag names`_. When `executing tasks`_, it is possible to use the :option:`--task` option as an alias for :option:`--test`. +By suite names +~~~~~~~~~~~~~~ + +Tests can be selected also by suite names with the :option:`--suite (-s)` +option that selects all tests in matching suites. Similarly +as with :option:`--test`, given names are case, space and underscore +insensitive and support `simple patterns`_. To pinpoint a suite +more precisely, it is possible to prefix the name with the parent suite +name:: + + --suite Example # Match only suites with name 'Example'. + --suite example* # Match suites starting with 'example'. + --suite first --suite second # Match suites with name 'first' or 'second'. + --suite parent.child # Match suite 'child' in suite 'parent'. + +Unlike with :option:`--test`, the name does not need to match the whole +suite name, starting from the root suite, when the name contains a parent +suite name. This behavior `will be changed`__ in the future and should not be relied +upon. It is recommended to use the full name like `--suite root.parent.child` +or `--suite *.parent.child`. + +If both :option:`--suite` and :option:`--test` options are used, only the +specified tests in specified suites are selected:: + + --suite mysuite --test mytest # Match test 'mytest' if its inside suite 'mysuite'. + +Also this behavior `is likely to change`__ in the future and the above changed to mean +selecting all tests in suite `mysuite` in addition to all tests with name `mytest`. +A more reliable way to select a test in a suite is using `--test *.mysuite.mytest` +or `--test *.mysuite.*.mytest` depending on should the test be directly inside +the suite or not. + +Using the :option:`--suite` option is more or less the same as executing +the appropriate suite file or directory directly. The main difference is +that if a file or directory is run directly, possible suite setups and teardowns +on higher level are not executed:: + + # Root suite is 'Tests' and its possible setup and teardown are run. + robot --suite example path/to/tests + + # Root suite is 'Example' and possible higher level setups and teardowns are ignored. + robot path/to/tests/example.robot + +When using the :option:`--suite` option, Robot Framework does not parse +files that do not match the given suite name. For example, when using +`--suite example`, only files that have a name :file:`example.robot` or are in +a directory :file:`example` are parsed. This is done for performance reasons +to avoid the parsing overhead with larger directory structures. Unfortunately +this approach does not work well with the new :setting:`Name` setting that can +be used for setting a custom `suite name`_. In practice the new setting and +the :option:`--suite` option are incompatible. This will be changed in Robot +Framework 7.0 so that `files are not excluded`__ when using the :option:`--suite` +option. The plan is to add an explicit option for `selecting files to parse`__ +before that. + +__ https://github.com/robotframework/robotframework/issues/4720 +__ https://github.com/robotframework/robotframework/issues/4721 +__ https://github.com/robotframework/robotframework/issues/4688 +__ https://github.com/robotframework/robotframework/issues/4687 + By tag names ~~~~~~~~~~~~ From 0e6088ff6d28f14fe6c41f897cb7e37bd1704cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 5 Apr 2023 17:14:45 +0300 Subject: [PATCH 0482/1592] TestSuite typing tuning. #4570 - ItemList: Accept `Mapping`, not `dict`. - ItemList: Use stringified types instead of `Union` and `List`. - TestSuite: Accept tests and suites as mappings. --- src/robot/model/itemlist.py | 31 ++++++++++++++++--------------- src/robot/model/testsuite.py | 7 ++++--- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index 893d3f05171..0e48a451913 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -14,8 +14,9 @@ # limitations under the License. from functools import total_ordering -from typing import (Iterable, Iterator, List, MutableSequence, overload, - TYPE_CHECKING, Type, TypeVar, Union) +from collections.abc import Mapping +from typing import (Iterable, Iterator, MutableSequence, overload, TYPE_CHECKING, + Type, TypeVar) from robot.utils import type_name @@ -45,11 +46,11 @@ class ItemList(MutableSequence[T]): __slots__ = ['_item_class', '_common_attrs', '_items'] def __init__(self, item_class: Type[T], - common_attrs: Union[dict, None] = None, - items: Iterable[Union[T, dict]] = ()): + common_attrs: 'Mapping|None' = None, + items: 'Iterable[T|Mapping]' = ()): self._item_class = item_class self._common_attrs = common_attrs - self._items: List[T] = [] + self._items: 'list[T]' = [] if items: self.extend(items) @@ -57,14 +58,14 @@ def create(self, *args, **kwargs) -> T: """Create a new item using the provided arguments.""" return self.append(self._item_class(*args, **kwargs)) - def append(self, item: Union[T, dict]): + def append(self, item: 'T|Mapping'): item = self._check_type_and_set_attrs(item) self._items.append(item) return item - def _check_type_and_set_attrs(self, item: Union[T, dict]) -> T: + def _check_type_and_set_attrs(self, item: 'T|Mapping') -> T: if not isinstance(item, self._item_class): - if isinstance(item, dict): + if isinstance(item, Mapping): item = self._item_from_dict(item) else: raise TypeError(f'Only {type_name(self._item_class)} objects ' @@ -74,15 +75,15 @@ def _check_type_and_set_attrs(self, item: Union[T, dict]) -> T: setattr(item, attr, value) return item - def _item_from_dict(self, data: dict) -> T: + def _item_from_dict(self, data: Mapping) -> T: if hasattr(self._item_class, 'from_dict'): return self._item_class.from_dict(data) # type: ignore return self._item_class(**data) - def extend(self, items: Iterable[Union[T, dict]]): + def extend(self, items: 'Iterable[T|Mapping]'): self._items.extend(self._check_type_and_set_attrs(i) for i in items) - def insert(self, index: int, item: Union[T, dict]): + def insert(self, index: int, item: 'T|Mapping'): item = self._check_type_and_set_attrs(item) self._items.insert(index, item) @@ -124,11 +125,11 @@ def _create_new_from(self: Self, items: Iterable[T]) -> Self: return new @overload - def __setitem__(self, index: int, item: Union[T, dict]): + def __setitem__(self, index: int, item: 'T|Mapping'): ... @overload - def __setitem__(self, index: slice, item: Iterable[Union[T, dict]]): + def __setitem__(self, index: slice, item: 'Iterable[T|Mapping]'): ... def __setitem__(self, index, item): @@ -137,7 +138,7 @@ def __setitem__(self, index, item): else: self._items[index] = self._check_type_and_set_attrs(item) - def __delitem__(self, index: Union[int, slice]): + def __delitem__(self, index: 'int|slice'): del self._items[index] def __contains__(self, item: object) -> bool: @@ -208,7 +209,7 @@ def __imul__(self: Self, count: int) -> Self: def __rmul__(self: Self, count: int) -> Self: return self * count - def to_dicts(self) -> List[dict]: + def to_dicts(self) -> 'list[dict]': """Return list of items converted to dictionaries. Items are converted to dictionaries using the ``to_dict`` method, if diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index a54f374470d..5723d2e93d3 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -110,11 +110,11 @@ def metadata(self, metadata: 'Mapping|None') -> Metadata: return Metadata(metadata) @setter - def suites(self, suites: Sequence['TestSuite']) -> 'TestSuites': + def suites(self, suites: 'Sequence[TestSuite|Mapping]') -> 'TestSuites': return TestSuites(self.__class__, self, suites) @setter - def tests(self, tests: Sequence[TestCase]) -> TestCases: + def tests(self, tests: 'Sequence[TestCase|Mapping]') -> TestCases: return TestCases(self.test_class, self, tests) @property @@ -336,5 +336,6 @@ class TestSuites(ItemList[TestSuite]): __slots__ = [] def __init__(self, suite_class: Type[TestSuite] = TestSuite, - parent: 'TestSuite|None' = None, suites: Sequence[TestSuite] = ()): + parent: 'TestSuite|None' = None, + suites: 'Sequence[TestSuite|Mapping]' = ()): super().__init__(suite_class, {'parent': parent}, suites) From a9160a8d53d68d121f7731a6e98e9eefc6a179da Mon Sep 17 00:00:00 2001 From: Ygor Pontelo <32963605+ygorpontelo@users.noreply.github.com> Date: Wed, 5 Apr 2023 11:43:00 -0300 Subject: [PATCH 0483/1592] Support async functions as keywords (#4677) Fixes #4089. --- atest/robot/keywords/async_keywords.robot | 27 ++++++++++ atest/testdata/keywords/AsyncLib.py | 48 +++++++++++++++++ atest/testdata/keywords/async_keywords.robot | 34 ++++++++++++ .../CreatingTestLibraries.rst | 52 +++++++++++++++++++ src/robot/running/context.py | 49 ++++++++++++++++- src/robot/running/librarykeywordrunner.py | 5 +- utest/running/test_testlibrary.py | 6 +++ 7 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 atest/robot/keywords/async_keywords.robot create mode 100644 atest/testdata/keywords/AsyncLib.py create mode 100644 atest/testdata/keywords/async_keywords.robot diff --git a/atest/robot/keywords/async_keywords.robot b/atest/robot/keywords/async_keywords.robot new file mode 100644 index 00000000000..034ade1cb7e --- /dev/null +++ b/atest/robot/keywords/async_keywords.robot @@ -0,0 +1,27 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/async_keywords.robot +Resource atest_resource.robot + +*** Test Cases *** +Works With Asyncio Run + [Tags] require-py3.7 + Check Test Case ${TESTNAME} + +Basic Async Works + Check Test Case ${TESTNAME} + +Works Using Gather + Check Test Case ${TESTNAME} + +Long Async Tasks Run In Background + [Tags] require-py3.7 + Check Test Case ${TESTNAME} + +Builtin Call From Library Works + Check Test Case ${TESTNAME} + +Create Task With Loop Reference + Check Test Case ${TESTNAME} + +Generators Do Not Use Event Loop + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/AsyncLib.py b/atest/testdata/keywords/AsyncLib.py new file mode 100644 index 00000000000..3ab1d7be26d --- /dev/null +++ b/atest/testdata/keywords/AsyncLib.py @@ -0,0 +1,48 @@ +import asyncio + +from robot.libraries.BuiltIn import BuiltIn + + +class Hanger: + + def __init__(self) -> None: + self.task = None + self.ticks = [] + + async def start_async_process(self): + while True: + self.ticks.append('tick') + await asyncio.sleep(0.01) + + +class AsyncLib: + + async def basic_async_test(self): + await asyncio.sleep(0.1) + return 'Got it' + + def async_with_run_inside(self): + async def inner(): + await asyncio.sleep(0.1) + return 'Works' + return asyncio.run(inner()) + + async def can_use_gather(self): + tasks = [asyncio.sleep(0.1) for _ in range(5)] + await asyncio.gather(*tasks) + + async def create_hanger(self): + hanger = Hanger() + hanger.task = asyncio.create_task(hanger.start_async_process()) + return hanger + + async def stop_task_from_hanger(self, hanger): + hanger.task.cancel() + + async def run_keyword_using_builtin(self): + return await BuiltIn().run_keyword("Basic Async Test") + + async def create_task_with_loop(self): + loop = asyncio.get_event_loop() + task = loop.create_task(self.basic_async_test()) + return await task diff --git a/atest/testdata/keywords/async_keywords.robot b/atest/testdata/keywords/async_keywords.robot new file mode 100644 index 00000000000..8cf75286556 --- /dev/null +++ b/atest/testdata/keywords/async_keywords.robot @@ -0,0 +1,34 @@ +*** Settings *** +Library AsyncLib.py + +*** Test Cases *** +Works With Asyncio Run + [Tags] require-py3.7 + ${result} = Async With Run Inside + Should Be Equal ${result} Works + +Basic Async Works + ${result} = Basic Async Test + Should Be Equal ${result} Got it + +Works Using Gather + Can Use Gather + +Long Async Tasks Run In Background + [Tags] require-py3.7 + ${hanger} = Create Hanger + Basic Async Test + Stop task From Hanger ${hanger} + ${size} = Evaluate len($hanger.ticks) + Should Be True ${size} > 1 + +Builtin Call From Library Works + ${result} = Run Keyword Using Builtin + Should Be Equal ${result} Got it + +Create Task With Loop Reference + ${result} = Create Task With Loop + Should Be Equal ${result} Got it + +Generators Do Not Use Event Loop + ${generator} = Evaluate (i for i in range(5)) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 1a182ec73e7..a7ad544b27b 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1944,6 +1944,58 @@ __ `Specifying argument types using @keyword decorator`_ .. note:: Automatic type conversion is new in Robot Framework 3.1. +Asynchronous keywords +~~~~~~~~~~~~~~~~~~~~~ + +Starting from Robot Framework 6.1, it is possible to run native asynchronous +functions (created by async def) just like normal functions. For example: + +.. sourcecode:: python + + import asyncio + from robot.api.deco import keyword + + + @keyword + async def this_keyword_waits(): + await asyncio.sleep(5) + +You can get the reference of the loop using `asyncio.get_running_loop()` or +`asyncio.get_event_loop()`. Be careful when modifying how the loop runs, it is +a global resource. For example: never call `loop.close()` because it will make it +impossible to run any further coroutines. If you have any function or resource that +requires the event loop, even though await is not used explicitly, you have to define +your function as async to have the event loop available. + +More examples of functionality: + +.. sourcecode:: python + + import asyncio + from robot.api.deco import keyword + + + async def task_async(): + await asyncio.sleep(5) + + @keyword + async def examples(): + tasks = [task_async() for _ in range(10)] + results = await asyncio.gather(*tasks) + + background_task = asyncio.create_task(task_async()) # create a task in background + await background_task + + # If running with python 3.10 or higher + async with asyncio.TaskGroup() as tg: + task1 = tg.create_task(task_async()) + task2 = tg.create_task(task_async()) + +.. note:: Robot Framework waits for the function to complete, If you want to have a task that runs + for a long time, use `asyncio.create_task()` for example. It is your responsibility to + manage the task and save a reference to avoid it being garbage collected. If the event loop + closes and a task is still pending, a message will be printed in the console. + Communicating with Robot Framework ---------------------------------- diff --git a/src/robot/running/context.py b/src/robot/running/context.py index a283fe59d81..249550d63cf 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -13,15 +13,57 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys +import inspect +import asyncio from contextlib import contextmanager from robot.errors import DataError +class Asynchronous: + + def __init__(self) -> None: + self._loop_ref = None + + @property + def event_loop(self): + if self._loop_ref is None: + self._loop_ref = asyncio.new_event_loop() + return self._loop_ref + + def close_loop(self): + if self._loop_ref: + self._loop_ref.close() + + def run_until_complete(self, coroutine): + return self.event_loop.run_until_complete(coroutine) + + def is_loop_required(self, obj): + return self.is_coroutine(obj) and not self.is_loop_running() + + def is_coroutine(self, obj): + # match native coroutines only + return inspect.iscoroutine(obj) + + def is_loop_running(self): + # ensure 3.6 compatibility + if sys.version_info.minor == 6: + return asyncio._get_running_loop() is not None + else: + try: + asyncio.get_running_loop() + except RuntimeError: + return False + else: + return True + + class ExecutionContexts: def __init__(self): self._contexts = [] + self._asynchronous = Asynchronous() @property def current(self): @@ -39,12 +81,14 @@ def namespaces(self): return (context.namespace for context in self) def start_suite(self, suite, namespace, output, dry_run=False): - ctx = _ExecutionContext(suite, namespace, output, dry_run) + ctx = _ExecutionContext(suite, namespace, output, dry_run, self._asynchronous) self._contexts.append(ctx) return ctx def end_suite(self): self._contexts.pop() + if not self._contexts: + self._asynchronous.close_loop() # This is ugly but currently needed e.g. by BuiltIn @@ -54,7 +98,7 @@ def end_suite(self): class _ExecutionContext: _started_keywords_threshold = 100 - def __init__(self, suite, namespace, output, dry_run=False): + def __init__(self, suite, namespace, output, dry_run=False, asynchronous=None): self.suite = suite self.test = None self.timeouts = set() @@ -67,6 +111,7 @@ def __init__(self, suite, namespace, output, dry_run=False): self.timeout_occurred = False self.steps = [] self.user_keywords = [] + self.asynchronous = asynchronous @contextmanager def suite_teardown(self): diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index af134e411ee..29af0cf3fe8 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -104,7 +104,10 @@ def _run_with_output_captured_and_signal_monitor(self, runner, context): def _run_with_signal_monitoring(self, runner, context): try: STOP_SIGNAL_MONITOR.start_running_keyword(context.in_teardown) - return runner() + runner_result = runner() + if context.asynchronous.is_loop_required(runner_result): + return context.asynchronous.run_until_complete(runner_result) + return runner_result finally: STOP_SIGNAL_MONITOR.stop_running_keyword() diff --git a/utest/running/test_testlibrary.py b/utest/running/test_testlibrary.py index 8600dae1eef..29d0f164c7f 100644 --- a/utest/running/test_testlibrary.py +++ b/utest/running/test_testlibrary.py @@ -532,6 +532,11 @@ def log_output(self, output): pass +class _FakeAsynchronous: + def is_loop_required(self, obj): + return False + + class _FakeContext: def __init__(self): self.output = _FakeOutput() @@ -541,6 +546,7 @@ def __init__(self): self.variables = _FakeVariableScope() self.timeouts = set() self.test = None + self.asynchronous = _FakeAsynchronous() if __name__ == '__main__': From 5bb065f063084f29797e63cd80c042e816190ac4 Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Wed, 5 Apr 2023 16:30:51 +0100 Subject: [PATCH 0484/1592] Add type hints for model.testcase (#4719) Part of #4570. Co-authored-by: serhiy <serhiy.pikho@jitsuin.com> --- src/robot/model/testcase.py | 66 +++++++++++++++++++++--------------- src/robot/model/testsuite.py | 4 +-- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index cb07ad4230a..e0b9f0a56c7 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path +from typing import Iterable, Mapping, Sequence, TYPE_CHECKING, Type + from robot.utils import setter from .body import Body @@ -22,6 +25,10 @@ from .modelobject import ModelObject from .tags import Tags +if TYPE_CHECKING: + from robot.model.testsuite import TestSuite + from robot.model.visitor import SuiteVisitor + class TestCase(ModelObject): """Base model for a single test case. @@ -34,30 +41,31 @@ class TestCase(ModelObject): repr_args = ('name',) __slots__ = ['parent', 'name', 'doc', 'timeout', 'lineno', '_setup', '_teardown'] - def __init__(self, name='', doc='', tags=None, timeout=None, lineno=None, - parent=None): + def __init__(self, name: str = '', doc: str = '', tags: Sequence[str] = (), + timeout: 'str|None' = None, lineno: 'int|None' = None, + parent: 'TestSuite|None' = None): self.name = name self.doc = doc self.tags = tags self.timeout = timeout self.lineno = lineno self.parent = parent - self.body = None - self._setup = None - self._teardown = None + self.body = [] + self._setup: 'Keyword|None' = None + self._teardown: 'Keyword|None' = None @setter - def body(self, body): + def body(self, body: 'Iterable[Keyword|Mapping]') -> Body: """Test body as a :class:`~robot.model.body.Body` object.""" return self.body_class(self, body) @setter - def tags(self, tags): + def tags(self, tags: Sequence[str]) -> Tags: """Test tags as a :class:`~.model.tags.Tags` object.""" return Tags(tags) @property - def setup(self): + def setup(self) -> Keyword: """Test setup as a :class:`~.model.keyword.Keyword` object. This attribute is a ``Keyword`` object also when a test has no setup @@ -81,16 +89,16 @@ def setup(self): New in Robot Framework 4.0. Earlier setup was accessed like ``test.keywords.setup``. """ - if self._setup is None and self: + if self._setup is None: self._setup = create_fixture(None, self, Keyword.SETUP) return self._setup @setup.setter - def setup(self, setup): + def setup(self, setup: 'Keyword|Mapping|None'): self._setup = create_fixture(setup, self, Keyword.SETUP) @property - def has_setup(self): + def has_setup(self) -> bool: """Check does a suite have a setup without creating a setup object. A difference between using ``if test.has_setup:`` and ``if test.setup:`` @@ -104,21 +112,21 @@ def has_setup(self): return bool(self._setup) @property - def teardown(self): + def teardown(self) -> Keyword: """Test teardown as a :class:`~.model.keyword.Keyword` object. See :attr:`setup` for more information. """ - if self._teardown is None and self: + if self._teardown is None: self._teardown = create_fixture(None, self, Keyword.TEARDOWN) return self._teardown @teardown.setter - def teardown(self, teardown): + def teardown(self, teardown: 'Keyword|Mapping|None'): self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) @property - def has_teardown(self): + def has_teardown(self) -> bool: """Check does a test have a teardown without creating a teardown object. See :attr:`has_setup` for more information. @@ -128,7 +136,7 @@ def has_teardown(self): return bool(self._teardown) @property - def keywords(self): + def keywords(self) -> Keywords: """Deprecated since Robot Framework 4.0 Use :attr:`body`, :attr:`setup` or :attr:`teardown` instead. @@ -141,7 +149,7 @@ def keywords(self, keywords): Keywords.raise_deprecation_error() @property - def id(self): + def id(self) -> str: """Test case id in format like ``s1-t3``. See :attr:`TestSuite.id <robot.model.testsuite.TestSuite.id>` for @@ -154,25 +162,26 @@ def id(self): return f'{self.parent.id}-t{index + 1}' @property - def longname(self): + def longname(self) -> str: """Test name prefixed with the long name of the parent suite.""" if not self.parent: return self.name return f'{self.parent.longname}.{self.name}' @property - def source(self): + def source(self) -> 'Path|None': return self.parent.source if self.parent is not None else None - def visit(self, visitor): + def visit(self, visitor: 'SuiteVisitor'): """:mod:`Visitor interface <robot.model.visitor>` entry-point.""" visitor.visit_test(self) - def __str__(self): + def __str__(self) -> str: return self.name - def to_dict(self): - data = {'name': self.name} + def to_dict(self) -> dict: + data = {} + data['name'] = self.name if self.doc: data['doc'] = self.doc if self.tags: @@ -189,14 +198,17 @@ def to_dict(self): return data -class TestCases(ItemList): +class TestCases(ItemList[TestCase]): __slots__ = [] - def __init__(self, test_class=TestCase, parent=None, tests=None): + def __init__(self, test_class: Type[TestCase] = TestCase, + parent: 'TestSuite|None' = None, + tests: 'Sequence[TestCase|Mapping]' = ()): super().__init__(test_class, {'parent': parent}, tests) def _check_type_and_set_attrs(self, test): test = super()._check_type_and_set_attrs(test) - for visitor in test.parent._visitors: - test.visit(visitor) + if test.parent: + for visitor in test.parent._visitors: + test.visit(visitor) return test diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 5723d2e93d3..e303a4432e9 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -148,7 +148,7 @@ def setup(self) -> Keyword: return self._setup @setup.setter - def setup(self, setup: 'Keyword | None'): + def setup(self, setup: 'Keyword|Mapping|None'): self._setup = create_fixture(setup, self, Keyword.SETUP) @property @@ -176,7 +176,7 @@ def teardown(self) -> Keyword: return self._teardown @teardown.setter - def teardown(self, teardown: 'Keyword | None'): + def teardown(self, teardown: 'Keyword|Mapping|None'): self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) @property From 8bbb06cf58c655b9487cb29cfd49774d1d8bf10a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 5 Apr 2023 17:56:56 +0300 Subject: [PATCH 0485/1592] Tiny enhancements to async keyword support (#4089) --- atest/testdata/keywords/async_keywords.robot | 2 ++ .../CreatingTestLibraries.rst | 16 ++++++------- src/robot/running/context.py | 23 ++++++++----------- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/atest/testdata/keywords/async_keywords.robot b/atest/testdata/keywords/async_keywords.robot index 8cf75286556..770f8b3d822 100644 --- a/atest/testdata/keywords/async_keywords.robot +++ b/atest/testdata/keywords/async_keywords.robot @@ -32,3 +32,5 @@ Create Task With Loop Reference Generators Do Not Use Event Loop ${generator} = Evaluate (i for i in range(5)) + Should Be Equal ${{sum($generator)}} ${10} + Should Be Equal ${{sum($generator)}} ${0} diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index a7ad544b27b..f6e85e57b13 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1948,7 +1948,7 @@ Asynchronous keywords ~~~~~~~~~~~~~~~~~~~~~ Starting from Robot Framework 6.1, it is possible to run native asynchronous -functions (created by async def) just like normal functions. For example: +functions (created by `async def`) just like normal functions: .. sourcecode:: python @@ -1962,9 +1962,9 @@ functions (created by async def) just like normal functions. For example: You can get the reference of the loop using `asyncio.get_running_loop()` or `asyncio.get_event_loop()`. Be careful when modifying how the loop runs, it is -a global resource. For example: never call `loop.close()` because it will make it +a global resource. For example, never call `loop.close()` because it will make it impossible to run any further coroutines. If you have any function or resource that -requires the event loop, even though await is not used explicitly, you have to define +requires the event loop, even though `await` is not used explicitly, you have to define your function as async to have the event loop available. More examples of functionality: @@ -1983,18 +1983,18 @@ More examples of functionality: tasks = [task_async() for _ in range(10)] results = await asyncio.gather(*tasks) - background_task = asyncio.create_task(task_async()) # create a task in background + background_task = asyncio.create_task(task_async()) await background_task - # If running with python 3.10 or higher + # If running with Python 3.10 or higher async with asyncio.TaskGroup() as tg: task1 = tg.create_task(task_async()) task2 = tg.create_task(task_async()) -.. note:: Robot Framework waits for the function to complete, If you want to have a task that runs - for a long time, use `asyncio.create_task()` for example. It is your responsibility to +.. note:: Robot Framework waits for the function to complete. If you want to have a task that runs + for a long time, use, for example, `asyncio.create_task()`. It is your responsibility to manage the task and save a reference to avoid it being garbage collected. If the event loop - closes and a task is still pending, a message will be printed in the console. + closes and a task is still pending, a message will be printed to the console. Communicating with Robot Framework ---------------------------------- diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 249550d63cf..28a865f84bd 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -23,9 +23,9 @@ class Asynchronous: - def __init__(self) -> None: + def __init__(self): self._loop_ref = None - + @property def event_loop(self): if self._loop_ref is None: @@ -40,23 +40,18 @@ def run_until_complete(self, coroutine): return self.event_loop.run_until_complete(coroutine) def is_loop_required(self, obj): - return self.is_coroutine(obj) and not self.is_loop_running() - - def is_coroutine(self, obj): - # match native coroutines only - return inspect.iscoroutine(obj) + return inspect.iscoroutine(obj) and not self._is_loop_running() - def is_loop_running(self): + def _is_loop_running(self): # ensure 3.6 compatibility if sys.version_info.minor == 6: return asyncio._get_running_loop() is not None + try: + asyncio.get_running_loop() + except RuntimeError: + return False else: - try: - asyncio.get_running_loop() - except RuntimeError: - return False - else: - return True + return True class ExecutionContexts: From c916ce2bf4e0f4b52c6e347e31c9058fa89282c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 5 Apr 2023 21:47:15 +0300 Subject: [PATCH 0486/1592] Fix validating nested variables in variable section. Variables defined in the Variables section using nested name were reported invalid in parsing model. It did not affect execution, though. Fixes #4716. --- src/robot/parsing/model/statements.py | 2 +- src/robot/variables/search.py | 16 ++++++++-------- utest/parsing/test_model.py | 3 +++ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 83f8f956792..b5698d7d754 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -568,7 +568,7 @@ def value(self): def validate(self, ctx: 'ValidationContext'): name = self.get_value(Token.VARIABLE) match = search_variable(name, ignore_errors=True) - if not match.is_assign(allow_assign_mark=True): + if not match.is_assign(allow_assign_mark=True, allow_nested=True): self.errors += (f"Invalid variable name '{name}'.",) if match.is_dict_assign(allow_assign_mark=True): self._validate_dict_items() diff --git a/src/robot/variables/search.py b/src/robot/variables/search.py index 5339227a0d6..5102519c244 100644 --- a/src/robot/variables/search.py +++ b/src/robot/variables/search.py @@ -114,23 +114,23 @@ def is_list_variable(self): def is_dict_variable(self): return self.identifier == '&' and self.is_variable() - def is_assign(self, allow_assign_mark=False): + def is_assign(self, allow_assign_mark=False, allow_nested=False): if allow_assign_mark and self.string.endswith('='): match = search_variable(self.string[:-1].rstrip(), ignore_errors=True) return match.is_assign() return (self.is_variable() and self.identifier in '$@&' and not self.items - and not search_variable(self.base)) + and (allow_nested or not search_variable(self.base))) - def is_scalar_assign(self, allow_assign_mark=False): - return self.identifier == '$' and self.is_assign(allow_assign_mark) + def is_scalar_assign(self, allow_assign_mark=False, allow_nested=False): + return self.identifier == '$' and self.is_assign(allow_assign_mark, allow_nested) - def is_list_assign(self, allow_assign_mark=False): - return self.identifier == '@' and self.is_assign(allow_assign_mark) + def is_list_assign(self, allow_assign_mark=False, allow_nested=False): + return self.identifier == '@' and self.is_assign(allow_assign_mark, allow_nested) - def is_dict_assign(self, allow_assign_mark=False): - return self.identifier == '&' and self.is_assign(allow_assign_mark) + def is_dict_assign(self, allow_assign_mark=False, allow_nested=False): + return self.identifier == '&' and self.is_assign(allow_assign_mark, allow_nested) def __bool__(self): return self.identifier is not None diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 836f3d08bcd..be562984ef4 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -866,6 +866,7 @@ def test_valid(self): ${x} value @{y}= two values &{z} = one=item +${x${y}} nested name ''' expected = VariableSection( header=SectionHeader( @@ -879,6 +880,8 @@ def test_valid(self): Token(Token.ARGUMENT, 'values', 3, 17)]), Variable([Token(Token.VARIABLE, '&{z} =', 4, 0), Token(Token.ARGUMENT, 'one=item', 4, 10)]), + Variable([Token(Token.VARIABLE, '${x${y}}', 5, 0), + Token(Token.ARGUMENT, 'nested name', 5, 10)]), ] ) get_and_assert_model(data, expected, depth=0) From 0964acfa4cc0020f5800ffda71dae5617848ff41 Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Thu, 6 Apr 2023 14:44:16 +0100 Subject: [PATCH 0487/1592] Add type hints for robot.model.keywords (#4723) Co-authored-by: serhiy <serhiy.pikho@jitsuin.com> --- src/robot/model/keyword.py | 55 ++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index 72c58447f29..9b114dda1a3 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -13,11 +13,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Sequence, TYPE_CHECKING, Type import warnings from .body import Body, BodyItem from .itemlist import ItemList +if TYPE_CHECKING: + from robot.model.testcase import TestCase + from robot.model.testsuite import TestSuite + from robot.model.visitor import SuiteVisitor + @Body.register class Keyword(BodyItem): @@ -29,7 +35,9 @@ class Keyword(BodyItem): repr_args = ('name', 'args', 'assign') __slots__ = ['_name', 'args', 'assign', 'type'] - def __init__(self, name='', args=(), assign=(), type=BodyItem.KEYWORD, parent=None): + def __init__(self, name: str = '', args: Sequence[str] = (), + assign: Sequence[str] = (), type: str = BodyItem.KEYWORD, + parent: 'TestSuite|TestCase|BodyItem|None' = None): self._name = name self.args = args self.assign = assign @@ -37,27 +45,27 @@ def __init__(self, name='', args=(), assign=(), type=BodyItem.KEYWORD, parent=No self.parent = parent @property - def name(self): + def name(self) -> str: return self._name @name.setter - def name(self, name): + def name(self, name: str): self._name = name - def visit(self, visitor): + def visit(self, visitor: 'SuiteVisitor'): """:mod:`Visitor interface <robot.model.visitor>` entry-point.""" if self: visitor.visit_keyword(self) - def __bool__(self): + def __bool__(self) -> bool: return self.name is not None - def __str__(self): + def __str__(self) -> str: parts = list(self.assign) + [self.name] + list(self.args) return ' '.join(str(p) for p in parts) - def to_dict(self): - data = {'name': self.name} + def to_dict(self) -> dict: + data: 'dict[str,list|str]' = {'name': self.name} if self.args: data['args'] = list(self.args) if self.assign: @@ -65,7 +73,7 @@ def to_dict(self): return data -class Keywords(ItemList): +class Keywords(ItemList[Keyword]): """A list-like object representing keywords in a suite, a test or a keyword. Read-only and deprecated since Robot Framework 4.0. @@ -76,14 +84,15 @@ class Keywords(ItemList): "Use 'body', 'setup' or 'teardown' instead." ) - def __init__(self, parent=None, keywords=None): + def __init__(self, parent: 'TestSuite|None' = None, + keywords: 'Sequence[Keyword]|Keywords' = ()): warnings.warn(self.deprecation_message, UserWarning) ItemList.__init__(self, object, {'parent': parent}) if keywords: ItemList.extend(self, keywords) @property - def setup(self): + def setup(self) -> 'Keyword|None': return self[0] if (self and self[0].type == 'SETUP') else None @setup.setter @@ -94,51 +103,51 @@ def create_setup(self, *args, **kwargs): self.raise_deprecation_error() @property - def teardown(self): + def teardown(self) -> 'Keyword|None': return self[-1] if (self and self[-1].type == 'TEARDOWN') else None @teardown.setter - def teardown(self, kw): + def teardown(self, kw: Keyword): self.raise_deprecation_error() def create_teardown(self, *args, **kwargs): self.raise_deprecation_error() @property - def all(self): + def all(self) -> 'Keywords': """Iterates over all keywords, including setup and teardown.""" return self @property - def normal(self): + def normal(self) -> 'list[Keyword]': """Iterates over normal keywords, omitting setup and teardown.""" return [kw for kw in self if kw.type not in ('SETUP', 'TEARDOWN')] - def __setitem__(self, index, item): + def __setitem__(self, index: int, item: Keyword): self.raise_deprecation_error() def create(self, *args, **kwargs): self.raise_deprecation_error() - def append(self, item): + def append(self, item: Keyword): self.raise_deprecation_error() - def extend(self, items): + def extend(self, items: Sequence[Keyword]): self.raise_deprecation_error() - def insert(self, index, item): + def insert(self, index: int, item: Keyword): self.raise_deprecation_error() - def pop(self, *index): + def pop(self, *index: int): self.raise_deprecation_error() - def remove(self, item): + def remove(self, item: Keyword): self.raise_deprecation_error() def clear(self): self.raise_deprecation_error() - def __delitem__(self, index): + def __delitem__(self, index: int): self.raise_deprecation_error() def sort(self): @@ -148,5 +157,5 @@ def reverse(self): self.raise_deprecation_error() @classmethod - def raise_deprecation_error(cls): + def raise_deprecation_error(cls: 'Type[Keywords]'): raise AttributeError(cls.deprecation_message) From 2a57c4765ab6b80d44b77a120e0be52919793b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 6 Apr 2023 16:48:39 +0300 Subject: [PATCH 0488/1592] Minor cleanup --- src/robot/parsing/lexer/statementlexers.py | 4 ++-- src/robot/parsing/lexer/tokens.py | 4 ++-- src/robot/parsing/model/blocks.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 263785c95dd..a730bbaa830 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -352,8 +352,8 @@ class SyntaxErrorLexer(TypeAndArguments): @classmethod def handles(cls, statement: list, ctx: TestOrKeywordContext): - return statement[0].value in \ - {'BREAK', 'CONTINUE', 'END', 'ELSE', 'ELSE IF','EXCEPT', 'FINALLY', 'RETURN'} + return statement[0].value in {'ELSE', 'ELSE IF', 'EXCEPT', 'FINALLY', + 'BREAK', 'CONTINUE', 'RETURN', 'END'} def lex(self): token = self.statement[0] diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 7ae50797990..2412d399811 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -243,7 +243,7 @@ class EOS(Token): __slots__ = [] def __init__(self, lineno=-1, col_offset=-1): - Token.__init__(self, Token.EOS, '', lineno, col_offset) + super().__init__(Token.EOS, '', lineno, col_offset) @classmethod def from_token(cls, token, before=False): @@ -261,7 +261,7 @@ class END(Token): def __init__(self, lineno=-1, col_offset=-1, virtual=False): value = 'END' if not virtual else '' - Token.__init__(self, Token.END, value, lineno, col_offset) + super().__init__(Token.END, value, lineno, col_offset) @classmethod def from_token(cls, token, virtual=False): diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index fc685ee9b5a..95e92a52eb9 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -443,7 +443,7 @@ def visit_Statement(self, statement): def generic_visit(self, node): if self.statement is None: - ModelVisitor.generic_visit(self, node) + super().generic_visit(node) class LastStatementFinder(ModelVisitor): From 30ebdb626dc917e43937fbf1b57d59da6c1e83a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 6 Apr 2023 17:12:52 +0300 Subject: [PATCH 0489/1592] Typing tuning - Guard all imports in `robot.model.visitor` with `if TYPE_CHECKING` to avoid cyclic imports with modules needing to import `SuiteVisitor`. - Modules anyway needing `if TYPE_CHECKING`, and only using `SuiteVisitor` for typing, still import it conditionally. We may consider using `if TYPE_CHECKING` with all imports needed only typing purposes. - `dict[str, Any]` typing wtih `to_dict`. Also with `data` used internally to avoid errors with VScode. - Use `from .mod import X` insted of `from robot.mod import X` style in imports consisently. `visitor` is an exception because importing everything from `robot.model` in one go is just too convenient. --- src/robot/model/keyword.py | 12 ++--- src/robot/model/testcase.py | 11 ++--- src/robot/model/testsuite.py | 13 ++--- src/robot/model/visitor.py | 94 +++++++++++++++++------------------- 4 files changed, 60 insertions(+), 70 deletions(-) diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index 9b114dda1a3..26e6cdcfade 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -13,16 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Sequence, TYPE_CHECKING, Type +from typing import Any, Sequence, Type, TYPE_CHECKING import warnings from .body import Body, BodyItem from .itemlist import ItemList if TYPE_CHECKING: - from robot.model.testcase import TestCase - from robot.model.testsuite import TestSuite - from robot.model.visitor import SuiteVisitor + from .testcase import TestCase + from .testsuite import TestSuite + from .visitor import SuiteVisitor @Body.register @@ -64,8 +64,8 @@ def __str__(self) -> str: parts = list(self.assign) + [self.name] + list(self.args) return ' '.join(str(p) for p in parts) - def to_dict(self) -> dict: - data: 'dict[str,list|str]' = {'name': self.name} + def to_dict(self) -> 'dict[str, Any]': + data: 'dict[str, Any]' = {'name': self.name} if self.args: data['args'] = list(self.args) if self.assign: diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index e0b9f0a56c7..f84b5df4e5d 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -14,7 +14,7 @@ # limitations under the License. from pathlib import Path -from typing import Iterable, Mapping, Sequence, TYPE_CHECKING, Type +from typing import Any, Iterable, Mapping, Sequence, Type, TYPE_CHECKING from robot.utils import setter @@ -26,8 +26,8 @@ from .tags import Tags if TYPE_CHECKING: - from robot.model.testsuite import TestSuite - from robot.model.visitor import SuiteVisitor + from .testsuite import TestSuite + from .visitor import SuiteVisitor class TestCase(ModelObject): @@ -179,9 +179,8 @@ def visit(self, visitor: 'SuiteVisitor'): def __str__(self) -> str: return self.name - def to_dict(self) -> dict: - data = {} - data['name'] = self.name + def to_dict(self) -> 'dict[str, Any]': + data: 'dict[str, Any]' = {'name': self.name} if self.doc: data['doc'] = self.doc if self.tags: diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index e303a4432e9..428f8edab27 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -15,7 +15,7 @@ from collections.abc import Mapping from pathlib import Path -from typing import Iterator, Sequence, Type, TYPE_CHECKING +from typing import Any, Iterator, Sequence, Type from robot.utils import setter @@ -28,9 +28,7 @@ from .modelobject import ModelObject from .tagsetter import TagSetter from .testcase import TestCase, TestCases - -if TYPE_CHECKING: - from robot.model.visitor import SuiteVisitor +from .visitor import SuiteVisitor class TestSuite(ModelObject): @@ -303,16 +301,15 @@ def remove_empty_suites(self, preserve_direct_children: bool = False): """Removes all child suites not containing any tests, recursively.""" self.visit(EmptySuiteRemover(preserve_direct_children)) - def visit(self, visitor: 'SuiteVisitor'): + def visit(self, visitor: SuiteVisitor): """:mod:`Visitor interface <robot.model.visitor>` entry-point.""" visitor.visit_suite(self) def __str__(self) -> str: return self.name - def to_dict(self) -> dict: - data = {} - data['name'] = self.name + def to_dict(self) -> 'dict[str, Any]': + data: 'dict[str, Any]' = {'name': self.name} if self.doc: data['doc'] = self.doc if self.metadata: diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 144f1deb53e..0e8d30942cc 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -104,17 +104,11 @@ def visit_test(self, test: TestCase): from typing import TYPE_CHECKING -from .body import BodyItem -from .control import (Break, Continue, Error, For, If, IfBranch, Return, Try, - TryBranch, While) -from .keyword import Keyword -from .message import Message -from .testcase import TestCase - -# Avoid circular imports. if TYPE_CHECKING: + from robot.model import (Break, BodyItem, Continue, Error, For, If, IfBranch, + Keyword, Message, Return, TestCase, TestSuite, Try, + TryBranch, While) from robot.result import ForIteration, WhileIteration - from .testsuite import TestSuite class SuiteVisitor: @@ -151,7 +145,7 @@ def end_suite(self, suite: 'TestSuite'): """Called when a suite ends. Default implementation does nothing.""" pass - def visit_test(self, test: TestCase): + def visit_test(self, test: 'TestCase'): """Implements traversing through tests. Can be overridden to allow modifying the passed in ``test`` without calling @@ -165,18 +159,18 @@ def visit_test(self, test: TestCase): test.teardown.visit(self) self.end_test(test) - def start_test(self, test: TestCase): + def start_test(self, test: 'TestCase'): """Called when a test starts. Default implementation does nothing. Can return explicit ``False`` to stop visiting. """ pass - def end_test(self, test: TestCase): + def end_test(self, test: 'TestCase'): """Called when a test ends. Default implementation does nothing.""" pass - def visit_keyword(self, keyword: Keyword): + def visit_keyword(self, keyword: 'Keyword'): """Implements traversing through keywords. Can be overridden to allow modifying the passed in ``kw`` without @@ -190,7 +184,7 @@ def visit_keyword(self, keyword: Keyword): keyword.teardown.visit(self) self.end_keyword(keyword) - def start_keyword(self, keyword: Keyword): + def start_keyword(self, keyword: 'Keyword'): """Called when a keyword starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -199,14 +193,14 @@ def start_keyword(self, keyword: Keyword): """ return self.start_body_item(keyword) - def end_keyword(self, keyword: Keyword): + def end_keyword(self, keyword: 'Keyword'): """Called when a keyword ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(keyword) - def visit_for(self, for_: For): + def visit_for(self, for_: 'For'): """Implements traversing through FOR loops. Can be overridden to allow modifying the passed in ``for_`` without @@ -216,7 +210,7 @@ def visit_for(self, for_: For): for_.body.visit(self) self.end_for(for_) - def start_for(self, for_: For): + def start_for(self, for_: 'For'): """Called when a FOR loop starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -225,7 +219,7 @@ def start_for(self, for_: For): """ return self.start_body_item(for_) - def end_for(self, for_: For): + def end_for(self, for_: 'For'): """Called when a FOR loop ends. By default, calls :meth:`end_body_item` which, by default, does nothing. @@ -262,7 +256,7 @@ def end_for_iteration(self, iteration: 'ForIteration'): """ self.end_body_item(iteration) - def visit_if(self, if_: If): + def visit_if(self, if_: 'If'): """Implements traversing through IF/ELSE structures. Notice that ``if_`` does not have any data directly. Actual IF/ELSE @@ -276,7 +270,7 @@ def visit_if(self, if_: If): if_.body.visit(self) self.end_if(if_) - def start_if(self, if_: If): + def start_if(self, if_: 'If'): """Called when an IF/ELSE structure starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -285,14 +279,14 @@ def start_if(self, if_: If): """ return self.start_body_item(if_) - def end_if(self, if_: If): + def end_if(self, if_: 'If'): """Called when an IF/ELSE structure ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(if_) - def visit_if_branch(self, branch: IfBranch): + def visit_if_branch(self, branch: 'IfBranch'): """Implements traversing through single IF/ELSE branch. Can be overridden to allow modifying the passed in ``branch`` without @@ -302,7 +296,7 @@ def visit_if_branch(self, branch: IfBranch): branch.body.visit(self) self.end_if_branch(branch) - def start_if_branch(self, branch: IfBranch): + def start_if_branch(self, branch: 'IfBranch'): """Called when an IF/ELSE branch starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -311,14 +305,14 @@ def start_if_branch(self, branch: IfBranch): """ return self.start_body_item(branch) - def end_if_branch(self, branch: IfBranch): + def end_if_branch(self, branch: 'IfBranch'): """Called when an IF/ELSE branch ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(branch) - def visit_try(self, try_: Try): + def visit_try(self, try_: 'Try'): """Implements traversing through TRY/EXCEPT structures. This method is used with the TRY/EXCEPT root element. Actual TRY, EXCEPT, ELSE @@ -328,7 +322,7 @@ def visit_try(self, try_: Try): try_.body.visit(self) self.end_try(try_) - def start_try(self, try_: Try): + def start_try(self, try_: 'Try'): """Called when a TRY/EXCEPT structure starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -337,20 +331,20 @@ def start_try(self, try_: Try): """ return self.start_body_item(try_) - def end_try(self, try_: Try): + def end_try(self, try_: 'Try'): """Called when a TRY/EXCEPT structure ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(try_) - def visit_try_branch(self, branch: TryBranch): + def visit_try_branch(self, branch: 'TryBranch'): """Visits individual TRY, EXCEPT, ELSE and FINALLY branches.""" if self.start_try_branch(branch) is not False: branch.body.visit(self) self.end_try_branch(branch) - def start_try_branch(self, branch: TryBranch): + def start_try_branch(self, branch: 'TryBranch'): """Called when TRY, EXCEPT, ELSE or FINALLY branches start. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -359,14 +353,14 @@ def start_try_branch(self, branch: TryBranch): """ return self.start_body_item(branch) - def end_try_branch(self, branch: TryBranch): + def end_try_branch(self, branch: 'TryBranch'): """Called when TRY, EXCEPT, ELSE and FINALLY branches end. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(branch) - def visit_while(self, while_: While): + def visit_while(self, while_: 'While'): """Implements traversing through WHILE loops. Can be overridden to allow modifying the passed in ``while_`` without @@ -376,7 +370,7 @@ def visit_while(self, while_: While): while_.body.visit(self) self.end_while(while_) - def start_while(self, while_: While): + def start_while(self, while_: 'While'): """Called when a WHILE loop starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -385,7 +379,7 @@ def start_while(self, while_: While): """ return self.start_body_item(while_) - def end_while(self, while_: While): + def end_while(self, while_: 'While'): """Called when a WHILE loop ends. By default, calls :meth:`end_body_item` which, by default, does nothing. @@ -422,14 +416,14 @@ def end_while_iteration(self, iteration: 'WhileIteration'): """ self.end_body_item(iteration) - def visit_return(self, return_: Return): + def visit_return(self, return_: 'Return'): """Visits a RETURN elements.""" if self.start_return(return_) is not False: if hasattr(return_, 'body'): return_.body.visit(self) self.end_return(return_) - def start_return(self, return_: Return): + def start_return(self, return_: 'Return'): """Called when a RETURN element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -438,21 +432,21 @@ def start_return(self, return_: Return): """ return self.start_body_item(return_) - def end_return(self, return_: Return): + def end_return(self, return_: 'Return'): """Called when a RETURN element ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(return_) - def visit_continue(self, continue_: Continue): + def visit_continue(self, continue_: 'Continue'): """Visits CONTINUE elements.""" if self.start_continue(continue_) is not False: if hasattr(continue_, 'body'): continue_.body.visit(self) self.end_continue(continue_) - def start_continue(self, continue_: Continue): + def start_continue(self, continue_: 'Continue'): """Called when a CONTINUE element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -461,21 +455,21 @@ def start_continue(self, continue_: Continue): """ return self.start_body_item(continue_) - def end_continue(self, continue_: Continue): + def end_continue(self, continue_: 'Continue'): """Called when a CONTINUE element ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(continue_) - def visit_break(self, break_: Break): + def visit_break(self, break_: 'Break'): """Visits BREAK elements.""" if self.start_break(break_) is not False: if hasattr(break_, 'body'): break_.body.visit(self) self.end_break(break_) - def start_break(self, break_: Break): + def start_break(self, break_: 'Break'): """Called when a BREAK element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -484,14 +478,14 @@ def start_break(self, break_: Break): """ return self.start_body_item(break_) - def end_break(self, break_: Break): + def end_break(self, break_: 'Break'): """Called when a BREAK element ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(break_) - def visit_error(self, error: Error): + def visit_error(self, error: 'Error'): """Visits body items resulting from invalid syntax. Examples include syntax like ``END`` or ``ELSE`` in wrong place and @@ -502,7 +496,7 @@ def visit_error(self, error: Error): error.body.visit(self) self.end_error(error) - def start_error(self, error: Error): + def start_error(self, error: 'Error'): """Called when a ERROR element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -511,14 +505,14 @@ def start_error(self, error: Error): """ return self.start_body_item(error) - def end_error(self, error: Error): + def end_error(self, error: 'Error'): """Called when a ERROR element ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(error) - def visit_message(self, message: Message): + def visit_message(self, message: 'Message'): """Implements visiting messages. Can be overridden to allow modifying the passed in ``msg`` without @@ -527,7 +521,7 @@ def visit_message(self, message: Message): if self.start_message(message) is not False: self.end_message(message) - def start_message(self, message: Message): + def start_message(self, message: 'Message'): """Called when a message starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -536,14 +530,14 @@ def start_message(self, message: Message): """ return self.start_body_item(message) - def end_message(self, message: Message): + def end_message(self, message: 'Message'): """Called when a message ends. By default, calls :meth:`end_body_item` which, by default, does nothing. """ self.end_body_item(message) - def start_body_item(self, item: BodyItem): + def start_body_item(self, item: 'BodyItem'): """Called, by default, when keywords, messages or control structures start. More specific :meth:`start_keyword`, :meth:`start_message`, `:meth:`start_for`, @@ -555,7 +549,7 @@ def start_body_item(self, item: BodyItem): """ pass - def end_body_item(self, item: BodyItem): + def end_body_item(self, item: 'BodyItem'): """Called, by default, when keywords, messages or control structures end. More specific :meth:`end_keyword`, :meth:`end_message`, `:meth:`end_for`, From c5e5464bf6d2ea955850cc94b20f45c772abd2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 12 Apr 2023 00:22:11 +0300 Subject: [PATCH 0490/1592] Small API doc enhancements to `Statement`. Also add optional default value to newish `get_option`. --- src/robot/parsing/model/statements.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index b5698d7d754..796e8ffec79 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -78,14 +78,10 @@ def from_tokens(cls, tokens): @classmethod def from_params(cls, *args, **kwargs): - """Create statement from passed parameters. + """Create a statement from passed parameters. - Required and optional arguments should match class properties. Values are - used to create matching tokens. - - There is one notable difference for `Documentation` statement where - ``settings_header`` flag is used to determine if statement belongs to - settings header or test/keyword. + Required and optional arguments in general match class properties. + Values are used to create matching tokens. Most implementations support following general properties: @@ -100,7 +96,7 @@ def data_tokens(self): return [t for t in self.tokens if t.type not in Token.NON_DATA_TOKENS] def get_token(self, *types): - """Return a token with the given ``type``. + """Return a token with any of the given ``types``. If there are no matches, return ``None``. If there are multiple matches, return the first match. @@ -127,9 +123,15 @@ def get_values(self, *types): """Return values of tokens having any of the given ``types``.""" return tuple(t.value for t in self.tokens if t.type in types) - def get_option(self, name): + def get_option(self, name, default=None): + """Return value of a configuration option with the given ``name``. + + If the option has not been used, return ``default``. + + New in Robot Framework 6.1. + """ options = dict(opt.split('=', 1) for opt in self.get_values(Token.OPTION)) - return options.get(name) + return options.get(name, default) @property def lines(self): From 09975e73fc469752b46b8e89e06635d1371d6f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 12 Apr 2023 21:17:42 +0300 Subject: [PATCH 0491/1592] Fix usages like `python -m robot`. Regression caused by too eager simplification in 9a87af3b7414a48a8d618109851d08dc04310d9e and reported in #4734. --- src/robot/__main__.py | 5 +++-- src/robot/libdoc.py | 3 +-- src/robot/rebot.py | 3 +-- src/robot/run.py | 3 +-- src/robot/testdoc.py | 3 +-- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/robot/__main__.py b/src/robot/__main__.py index 82e33df3654..eee6bd87fb1 100755 --- a/src/robot/__main__.py +++ b/src/robot/__main__.py @@ -15,8 +15,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Allows running as a script. -if __name__ == '__main__': +import sys + +if __name__ == '__main__' and 'robot' not in sys.modules: import pythonpathsetter from robot import run_cli diff --git a/src/robot/libdoc.py b/src/robot/libdoc.py index 59d0f3d0e74..0cc95ee0c43 100755 --- a/src/robot/libdoc.py +++ b/src/robot/libdoc.py @@ -36,8 +36,7 @@ import sys from pathlib import Path -# Allows running as a script. -if __name__ == '__main__': +if __name__ == '__main__' and 'robot' not in sys.modules: import pythonpathsetter from robot.utils import Application, seq2str diff --git a/src/robot/rebot.py b/src/robot/rebot.py index 3964ea22760..c993f13fd8a 100755 --- a/src/robot/rebot.py +++ b/src/robot/rebot.py @@ -32,8 +32,7 @@ import sys -# Allows running as a script. -if __name__ == '__main__': +if __name__ == '__main__' and 'robot' not in sys.modules: import pythonpathsetter from robot.conf import RebotSettings diff --git a/src/robot/run.py b/src/robot/run.py index 1d881cf2f13..144733440db 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -32,8 +32,7 @@ import sys -# Allows running as a script. -if __name__ == '__main__': +if __name__ == '__main__' and 'robot' not in sys.modules: import pythonpathsetter from robot.conf import RobotSettings diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index 99c9ca050c6..eb73200ad7a 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -33,8 +33,7 @@ import time from pathlib import Path -# Allows running as a script. -if __name__ == '__main__': +if __name__ == '__main__' and 'robot' not in sys.modules: import pythonpathsetter from robot.conf import RobotSettings From d028fa8d7f81907b990f914140c7303fbaabb4fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 12 Apr 2023 23:51:57 +0300 Subject: [PATCH 0492/1592] Remove unnecessary spaces from test docs. Avoids breakage if/when we preserve spaces in documentation as proposed in #4729. --- atest/testdata/libdoc/resource.robot | 6 +++--- atest/testdata/running/for/for.robot | 2 +- .../standard_libraries/builtin/should_xxx_with.robot | 6 +++--- atest/testdata/variables/list_variable_items.robot | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/atest/testdata/libdoc/resource.robot b/atest/testdata/libdoc/resource.robot index 108f5f8672d..90da1af5d15 100644 --- a/atest/testdata/libdoc/resource.robot +++ b/atest/testdata/libdoc/resource.robot @@ -55,9 +55,9 @@ kw 5 [DocumeNtation] foo bar `kw`. kw 6 [Documentation] Summary line ... - ... Another line. - ... Tags: foo, bar - [Tags] foo dar + ... Another line. + ... Tags: foo, bar + [Tags] foo dar No Operation Different argument types diff --git a/atest/testdata/running/for/for.robot b/atest/testdata/running/for/for.robot index 80104a79467..bce9fe91738 100644 --- a/atest/testdata/running/for/for.robot +++ b/atest/testdata/running/for/for.robot @@ -284,7 +284,7 @@ Old :FOR syntax is not supported Escaping with backslash is not supported [Documentation] FAIL - ... No keyword with name '\\' found. If it is used inside a for loop, remove escaping backslashes and end the loop with 'END'. + ... No keyword with name '\\' found. If it is used inside a for loop, remove escaping backslashes and end the loop with 'END'. FOR ${var} IN one two \ Fail Should not be executed END diff --git a/atest/testdata/standard_libraries/builtin/should_xxx_with.robot b/atest/testdata/standard_libraries/builtin/should_xxx_with.robot index 344023b2a84..44d281c95d4 100644 --- a/atest/testdata/standard_libraries/builtin/should_xxx_with.robot +++ b/atest/testdata/standard_libraries/builtin/should_xxx_with.robot @@ -113,7 +113,7 @@ Should Not Start With without leading spaces Should Not Start With without trailing spaces [Documentation] FAIL Several failures occurred: ... - ... 1) 'test' starts with 'test' + ... 1) 'test' starts with 'test' ... ... 2) 'test value' starts with 'test' ... @@ -221,7 +221,7 @@ Should End With and do not collapse spaces ... ... 1) '\ttest\ \ ?' does not end with '\n?' ... - ... 2) repr=yes: '\t\nyötä\t' does not end with '\ Yötä' + ... 2) repr=yes: '\t\nyötä\t' does not end with '\ Yötä' [Template] Should End With \ttest\ \ ? \n? collapse_spaces=False \t\nyötä\t \ Yötä repr=yes collapse_spaces=${FALSE} @@ -232,7 +232,7 @@ Should End With and collapse spaces ... ... 1) ' test ?' does not end with 'T ?' ... - ... 2) repr=yes: ' yötä ' does not end with ' Yötä' + ... 2) repr=yes: ' yötä ' does not end with ' Yötä' [Template] Should End With \ttest\ \ ? T\n? collapse_spaces=True \t\nyötä\t \ Yötä repr=yes collapse_spaces=${TRUE} diff --git a/atest/testdata/variables/list_variable_items.robot b/atest/testdata/variables/list_variable_items.robot index 2d52a5f5bb0..321e482c02c 100644 --- a/atest/testdata/variables/list_variable_items.robot +++ b/atest/testdata/variables/list_variable_items.robot @@ -105,13 +105,13 @@ Empty index string Empty index bytes [Documentation] FAIL ... ${BYTES NAME} '\$\{BYTES}' used with invalid index ''. \ - ... To use '[]' as a literal value, it needs to be escaped like '\\[]'. + ... To use '[]' as a literal value, it needs to be escaped like '\\[]'. Log ${BYTES}[] Invalid slice list [Documentation] FAIL ... List '\${LIST}' used with invalid index '1:2:3:4'. \ - ... To use '[1:2:3:4]' as a literal value, it needs to be escaped like '\\[1:2:3:4]'. + ... To use '[1:2:3:4]' as a literal value, it needs to be escaped like '\\[1:2:3:4]'. Log ${LIST}[1:2:3:4] Invalid slice string From b89e7fcd61d407538e70b82c7d0a4c82b20d3ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 13 Apr 2023 00:16:18 +0300 Subject: [PATCH 0493/1592] UG: Enhance docs related to suite/test docs and metadata. --- .../src/Appendices/CommandLineOptions.rst | 8 +-- .../CreatingTestData/CreatingTestCases.rst | 43 ++++++-------- .../CreatingTestData/CreatingTestSuites.rst | 58 ++++++++++--------- .../ConfiguringExecution.rst | 24 ++++---- 4 files changed, 66 insertions(+), 67 deletions(-) diff --git a/doc/userguide/src/Appendices/CommandLineOptions.rst b/doc/userguide/src/Appendices/CommandLineOptions.rst index 0d5155ad2fe..2f3b269cc24 100644 --- a/doc/userguide/src/Appendices/CommandLineOptions.rst +++ b/doc/userguide/src/Appendices/CommandLineOptions.rst @@ -150,10 +150,10 @@ Command line options for post-processing outputs .. _generic automation: `Task execution`_ .. _Parse only these files: `Selecting files to parse`_ -.. _Sets the name: `Setting the name`_ -.. _Sets the documentation: `Setting the documentation`_ -.. _Sets free metadata: `Setting free metadata`_ -.. _Sets the tag(s): `Setting tags`_ +.. _Sets the name: `Setting suite name`_ +.. _Sets the documentation: `Setting suite documentation`_ +.. _Sets free metadata: `Setting free suite metadata`_ +.. _Sets the tag(s): `Setting test tags`_ .. _Selects the test cases by name: `By test names`_ .. _Selects the test suites: `By suite names`_ .. _Selects failed test suites: `Re-executing failed test suites`_ diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index eea0abc05e6..6e2a914bc1f 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -535,47 +535,42 @@ the variable does not exist, its name is left unchanged. Amount cannot be larger than ${MAX AMOUNT} # ... -The :setting:`[Documentation]` setting allows you to set a free +The :setting:`[Documentation]` setting allows setting free form documentation for a test case. That text is shown in the command line -output, as well as the resulting test logs and test reports. -It is possible to use simple `HTML formatting`_ in documentation and -variables_ can be used to make the documentation dynamic. Possible -non-existing variables are left unchanged. - -If documentation is split into multiple columns, cells in one row are -concatenated together with spaces. If documentation is `split -into multiple rows`__, the created documentation lines themselves are -`concatenated using newlines`__. Newlines are not added if a line -already ends with a newline or an `escaping backslash`__. +output and in the resulting logs and reports. +If documentation gets long, it can be `split into multiple rows`__. +It is possible to use simple `HTML formatting`_ and variables_ can +be used to make the documentation dynamic. Possible non-existing +variables are left unchanged. __ `Dividing data to several rows`_ -__ `Newlines in test data`_ -__ `Escaping`_ .. sourcecode:: robotframework *** Test Cases *** Simple - [Documentation] Simple documentation + [Documentation] Simple and short documentation. + No Operation + + Multiple lines + [Documentation] First row of the documentation. + ... + ... Documentation continues here. These rows form + ... a paragraph when shown in HTML outputs. No Operation Formatting - [Documentation] *This is bold*, _this is italic_ and here is a link: http://robotframework.org + [Documentation] + ... This list has: + ... - *bold* + ... - _italics_ + ... - link: http://robotframework.org No Operation Variables [Documentation] Executed at ${HOST} by ${USER} No Operation - Splitting - [Documentation] This documentation is split into multiple columns - No Operation - - Many lines - [Documentation] Here we have - ... an automatic newline - No Operation - It is important that test cases have clear and descriptive names, and in that case they normally do not need any documentation. If the logic of the test case needs documenting, it is often a sign that keywords diff --git a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst index 31e71952582..e42dd55b8c4 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst @@ -156,59 +156,65 @@ to a suite by using the :setting:`Name` setting in the Setting section: *** Settings *** Name Custom suite name +The name of the top-level suite `can be overridden`__ from the command line with +the :option:`--name` option. + .. note:: The :setting:`Name` setting is not compatible with the :option:`--suite` option that can be used to select tests `by suite names`_. This `will fixed`__ in Robot Framework 7.0. +__ `Setting suite name`_ __ https://github.com/robotframework/robotframework/issues/4688 Suite documentation ------------------- The documentation for a test suite is set using the :setting:`Documentation` -setting in the Setting section. It can be used in test case files -or, with higher-level suites, in test suite initialization files. Test -suite documentation has exactly the same characteristics regarding to where -it is shown and how it can be created as `test case -documentation`_. +setting in the Settings section. It can be used both in `suite files`_ +and in `suite initialization files`_. Suite documentation has exactly +the same characteristics regarding to where it is shown and how it can +be created as `test case documentation`_. For details about the syntax +see the `Documentation formatting`_ appendix. .. sourcecode:: robotframework *** Settings *** - Documentation An example test suite documentation with *some* _formatting_. - ... See test documentation for more documentation examples. + Documentation An example suite documentation with *some* _formatting_. + ... Long documentation can be split into multiple lines. + +The documentation of the top-level suite `can be overridden`__ from +the command line with the :option:`--doc` option. -Both the name and documentation of the top-level test suite can be -overridden in test execution. This can be done with the command line -options :option:`--name` and :option:`--doc`, respectively, as -explained in section `Setting metadata`_. +__ `Setting suite documentation`_ Free suite metadata ------------------- -Test suites can also have other metadata than the documentation. This metadata -is defined in the Setting section using the :setting:`Metadata` setting. Metadata -set in this manner is shown in test reports and logs. +In addition to documentation, suites can also have free metadata. This metadata +is defined as name-value pairs in the Settings section using the :setting:`Metadata` +setting. It is shown in reports and logs similarly as documentation. -The name and value for the metadata are located in the columns following -:setting:`Metadata`. The value is handled similarly as documentation, which means -that it can be split `into several cells`__ (joined together with spaces) -or `into several rows`__ (joined together with newlines), -simple `HTML formatting`_ works and even variables_ can be used. +Name of the metadata is the first argument given to the :setting:`Metadata` setting +and the remaining arguments specify its value. The value is handled similarly as +documentation, which means that it supports `HTML formatting`_ and variables_, and +that longer values can be `split into multiple rows`__. __ `Dividing data to several rows`_ -__ `Newlines in test data`_ .. sourcecode:: robotframework *** Settings *** - Metadata Version 2.0 - Metadata More Info For more information about *Robot Framework* see http://robotframework.org - Metadata Executed At ${HOST} + Metadata Version 2.0 + Metadata Robot Framework http://robotframework.org + Metadata Platform ${PLATFORM} + Metadata Longer Value + ... Longer metadata values can be split into multiple + ... rows. Also *simple* _formatting_ is supported. + +The free metadata of the top-level suite `can be set`__ from +the command line with the :option:`--metadata` option. -For top-level test suites, it is possible to set metadata also with the -:option:`--metadata` command line option. This is discussed in more -detail in section `Setting metadata`_. +__ `Setting free suite metadata`_ Suite setup and teardown ------------------------ diff --git a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst index e66f3bbf4b3..1e407217a1d 100644 --- a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst @@ -280,22 +280,20 @@ running tests. Setting metadata ---------------- -Setting the name -~~~~~~~~~~~~~~~~ +Setting suite name +~~~~~~~~~~~~~~~~~~ -When Robot Framework parses test data, `test suite names`__ are created +When Robot Framework parses test data, `suite names`__ are created from file and directory names. The name of the top-level test suite can, however, be overridden with the command line option -:option:`--name (-N)`. +:option:`--name (-N)`:: -.. note:: Prior to Robot Framework 3.1, underscores in the value were - converted to spaces. Nowadays values containing spaces need - to be escaped or quoted like, for example, `--name "My example"`. + robot --name "Custom name" tests.robot __ `Suite name`_ -Setting the documentation -~~~~~~~~~~~~~~~~~~~~~~~~~ +Setting suite documentation +~~~~~~~~~~~~~~~~~~~~~~~~~~~ In addition to `defining documentation in the test data`__, documentation of the top-level suite can be given from the command line with the @@ -319,8 +317,8 @@ Examples:: __ `Suite documentation`_ -Setting free metadata -~~~~~~~~~~~~~~~~~~~~~ +Setting free suite metadata +~~~~~~~~~~~~~~~~~~~~~~~~~~~ `Free suite metadata`_ may also be given from the command line with the option :option:`--metadata (-M)`. The argument must be in the format @@ -347,8 +345,8 @@ Examples:: Prior to Robot Framework 3.1, underscores in the value were converted to spaces same way as with the :option:`--name` option. -Setting tags -~~~~~~~~~~~~ +Setting test tags +~~~~~~~~~~~~~~~~~ The command line option :option:`--settag (-G)` can be used to set the given tag to all executed test cases. This option may be used From 3181dfc88f65c6aa62cdcb69f85afefc90fa3917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 13 Apr 2023 00:48:32 +0300 Subject: [PATCH 0494/1592] Preserve leading and internal spaces in documentation. Implements #4729. Remove backlashes used for preventing automatic newline in documentation from the constructed documentation. That avoids the backslashs accidentally forming escape sequences like `\n`. Fixes #4736. This bug was discovered and fixed when implementing the above enhancement. --- atest/robot/libdoc/resource_file.robot | 4 +- atest/robot/output/xunit.robot | 4 +- atest/robot/parsing/line_continuation.robot | 2 +- atest/robot/parsing/suite_metadata.robot | 6 +- atest/robot/parsing/suite_settings.robot | 11 +- atest/robot/parsing/test_case_settings.robot | 10 +- .../robot/parsing/user_keyword_settings.robot | 2 +- atest/testdata/parsing/suite_settings.robot | 4 + .../testdata/parsing/test_case_settings.robot | 5 + .../parsing/user_keyword_settings.robot | 2 +- .../builtin/should_be_equal.robot | 36 ++--- .../builtin/should_be_equal_as_xxx.robot | 12 +- .../Appendices/DocumentationFormatting.rst | 92 ++++++------ .../src/CreatingTestData/TestDataSyntax.rst | 2 +- doc/userguide/src/SupportingTools/Libdoc.rst | 2 +- src/robot/parsing/model/statements.py | 40 ++++-- utest/parsing/test_model.py | 135 +++++++++++++++++- 17 files changed, 260 insertions(+), 109 deletions(-) diff --git a/atest/robot/libdoc/resource_file.robot b/atest/robot/libdoc/resource_file.robot index 692f152ed4a..6105880c23f 100644 --- a/atest/robot/libdoc/resource_file.robot +++ b/atest/robot/libdoc/resource_file.robot @@ -19,7 +19,7 @@ Documentation ... ... | *TABLE* | ... | \${NONEX} | $\{CURDIR} | \${TEMPDIR} | - ... | foo | bar | + ... | foo${SPACE*6}|${SPACE*4}bar${SPACE*4}| ... tabs \t\t\t here Version @@ -85,7 +85,7 @@ Keyword Documentation ... ------------- ... ... | = first = | = second = | - ... | foo | bar | + ... | foo${SPACE*7}|${SPACE*4}bar${SPACE*5}| Keyword Doc Should Be 9 ... Summary line ... diff --git a/atest/robot/output/xunit.robot b/atest/robot/output/xunit.robot index 82e28d590a1..01d2afdae88 100644 --- a/atest/robot/output/xunit.robot +++ b/atest/robot/output/xunit.robot @@ -118,9 +118,9 @@ XUnit File Testsuite Properties From Metadata Element Attribute Should be ${property_elements}[0] name Escaping Element Attribute Should be ${property_elements}[0] value Three backslashes \\\\\\\ & \${version} Element Attribute Should be ${property_elements}[1] name Multiple columns - Element Attribute Should be ${property_elements}[1] value Value in multiple columns + Element Attribute Should be ${property_elements}[1] value Value in${SPACE*4}multiple${SPACE*4}columns Element Attribute Should be ${property_elements}[2] name multiple lines - Element Attribute Should be ${property_elements}[2] value Metadata in multiple lines\nis parsed using\nsame semantics as documentation.\n| table |\n| ! | + Element Attribute Should be ${property_elements}[2] value Metadata in multiple lines\nis parsed using\nsame semantics${SPACE*4}as${SPACE*4}documentation.\n| table |\n|${SPACE*3}!${SPACE*3}| Element Attribute Should be ${property_elements}[3] name Name Element Attribute Should be ${property_elements}[3] value Value Element Attribute Should be ${property_elements}[4] name Overridden diff --git a/atest/robot/parsing/line_continuation.robot b/atest/robot/parsing/line_continuation.robot index d90847facad..4227048d11c 100644 --- a/atest/robot/parsing/line_continuation.robot +++ b/atest/robot/parsing/line_continuation.robot @@ -50,7 +50,7 @@ Multiline test settings ${tc} = Check Test Case ${TEST NAME} @{expected} = Evaluate ['my'+str(i) for i in range(1,6)] Should Contain Tags ${tc} @{expected} - Should Be Equal ${tc.doc} One.\nTwo.\nThree.\n\nSecond paragraph. + Should Be Equal ${tc.doc} One.\nTwo.\nThree.\n\n${SPACE*32}Second paragraph. Check Log Message ${tc.setup.msgs[0]} first Check Log Message ${tc.setup.msgs[1]} ${EMPTY} Check Log Message ${tc.setup.msgs[2]} last diff --git a/atest/robot/parsing/suite_metadata.robot b/atest/robot/parsing/suite_metadata.robot index 64b3162ddd1..b0e359f485e 100644 --- a/atest/robot/parsing/suite_metadata.robot +++ b/atest/robot/parsing/suite_metadata.robot @@ -12,14 +12,14 @@ Metadata NAME Value Metadata In Multiple Columns - Multiple columns Value in multiple columns + Multiple columns Value in${SPACE*4}multiple${SPACE*4}columns Metadata In Multiple Lines Multiple lines Metadata in multiple lines ... is parsed using - ... same semantics as documentation. + ... same semantics${SPACE*4}as${SPACE*4}documentation. ... | table | - ... | ! | + ... |${SPACE*3}!${SPACE*3}| Metadata With Variables Variables Version: 1.2 diff --git a/atest/robot/parsing/suite_settings.robot b/atest/robot/parsing/suite_settings.robot index 56652789171..377d4963020 100644 --- a/atest/robot/parsing/suite_settings.robot +++ b/atest/robot/parsing/suite_settings.robot @@ -16,11 +16,14 @@ Suite Documentation ... is shortdoc on console. ... ... Documentation can have multiple rows - ... and also multiple columns. + ... and${SPACE*4}also${SPACE*4}multiple${SPACE*4}columns. + ... ... Newlines can also be added literally with "\n". + ... If a row ends with a newline + ... or backslash no automatic newline is added. ... ... | table | =header= | - ... | foo | bar | + ... | foo${SPACE*3}|${SPACE*4}bar${SPACE*3}| ... | ragged | ... ... Variables work since Robot 1.2 and doc_from_cli works too. @@ -51,11 +54,11 @@ Suite Teardown Verify Teardown ${SUITE} BuiltIn.Log Default suite teardown Invalid Setting - Error In File 0 parsing/suite_settings.robot 28 + Error In File 0 parsing/suite_settings.robot 32 ... Non-existing setting 'Invalid Setting'. Small typo should provide recommendation. - Error In File 1 parsing/suite_settings.robot 29 + Error In File 1 parsing/suite_settings.robot 33 ... SEPARATOR=\n ... Non-existing setting 'Megadata'. Did you mean: ... ${SPACE*4}Metadata diff --git a/atest/robot/parsing/test_case_settings.robot b/atest/robot/parsing/test_case_settings.robot index ac2b70790b6..c1feb657afa 100644 --- a/atest/robot/parsing/test_case_settings.robot +++ b/atest/robot/parsing/test_case_settings.robot @@ -42,17 +42,21 @@ Documentation Verify Documentation Documentation in single line and column. Documentation in multiple columns - Verify Documentation Documentation for this test case in multiple columns + Verify Documentation Documentation${SPACE*4}for this test case${SPACE*4}in multiple columns Documentation in multiple rows Verify Documentation 1st logical line ... is shortdoc. ... ... This documentation has multiple rows - ... and also multiple columns. + ... and also${SPACE*4}multiple columns. + ... + ... Newlines can also be added literally with "\n". + ... If a row ends with a newline + ... or backslash no automatic newline is added. ... ... | table | =header= | - ... | foo | bar | + ... | foo${SPACE*3}|${SPACE*4}bar${SPACE*3}| ... | ragged | Documentation with variables diff --git a/atest/robot/parsing/user_keyword_settings.robot b/atest/robot/parsing/user_keyword_settings.robot index c7e1817af09..d74604ea7cf 100644 --- a/atest/robot/parsing/user_keyword_settings.robot +++ b/atest/robot/parsing/user_keyword_settings.robot @@ -20,7 +20,7 @@ Documentation Verify Documentation Documentation for this user keyword Documentation in multiple columns - Verify Documentation Documentation for this user keyword in multiple columns + Verify Documentation Documentation${SPACE * 4}for this user keyword${SPACE*10}in multiple columns Documentation in multiple rows Verify Documentation 1st line is shortdoc. diff --git a/atest/testdata/parsing/suite_settings.robot b/atest/testdata/parsing/suite_settings.robot index 46698a6fee2..e97a01a3504 100644 --- a/atest/testdata/parsing/suite_settings.robot +++ b/atest/testdata/parsing/suite_settings.robot @@ -6,7 +6,11 @@ Documentation ${1}st logical line ... ... Documentation can have multiple rows ... and also multiple columns. +... ... Newlines can also be added literally with "\n". +... If a row ends with a newline\n +... or backslash \ +... no automatic newline is added. ... ... | table | =header= | ... | foo | bar | diff --git a/atest/testdata/parsing/test_case_settings.robot b/atest/testdata/parsing/test_case_settings.robot index 0a746f72046..7122f95460b 100644 --- a/atest/testdata/parsing/test_case_settings.robot +++ b/atest/testdata/parsing/test_case_settings.robot @@ -54,6 +54,11 @@ Documentation in multiple rows ... This documentation has multiple rows ... and also multiple columns. ... + ... Newlines can also be added literally with "\n". + ... If a row ends with a newline\n + ... or backslash \ + ... no automatic newline is added. + ... ... | table | =header= | ... | foo | bar | ... | ragged | diff --git a/atest/testdata/parsing/user_keyword_settings.robot b/atest/testdata/parsing/user_keyword_settings.robot index cb3ab92d0b1..18d35c0ebf3 100644 --- a/atest/testdata/parsing/user_keyword_settings.robot +++ b/atest/testdata/parsing/user_keyword_settings.robot @@ -110,7 +110,7 @@ Documentation No Operation Documentation in multiple columns - [Documentation] Documentation for this user keyword in multiple columns + [Documentation] Documentation for this user keyword in multiple columns No Operation Documentation in multiple rows diff --git a/atest/testdata/standard_libraries/builtin/should_be_equal.robot b/atest/testdata/standard_libraries/builtin/should_be_equal.robot index f2f595312f2..57b51879a4b 100644 --- a/atest/testdata/standard_libraries/builtin/should_be_equal.robot +++ b/atest/testdata/standard_libraries/builtin/should_be_equal.robot @@ -86,11 +86,11 @@ Multiline comparison uses diff ... --- first ... +++ second ... @@ -1,3 +1,6 @@ - ... \ foo - ... \ bar + ... foo + ... bar ... +gar ... + - ... \ dar + ... dar ... + foo\nbar\ndar\n foo\nbar\ngar\n\ndar\n\n @@ -100,11 +100,11 @@ Multiline comparison with custom message ... --- first ... +++ second ... @@ -1,3 +1,6 @@ - ... \ foo - ... \ bar + ... foo + ... bar ... +gar ... + - ... \ dar + ... dar ... + foo\nbar\ndar\n foo\nbar\ngar\n\ndar\n\n msg=Custom message of mine @@ -172,22 +172,22 @@ formatter=repr with multiline ... --- first ... +++ second ... @@ -1,3 +1,6 @@ - ... \ foo - ... \ bar + ... foo + ... bar ... +gar ... + - ... \ dar + ... dar ... + ... ... 2) Multiline strings are different: ... --- first ... +++ second ... @@ -1,3 +1,6 @@ - ... \ 'foo\\n' - ... \ 'bar\\n' + ... 'foo\\n' + ... 'bar\\n' ... +'gar\\n' ... +'\\n' - ... \ 'dar\\n' + ... 'dar\\n' ... +'\\n' foo\nbar\ndar\n foo\nbar\ngar\n\ndar\n\n foo\nbar\ndar\n foo\nbar\ngar\n\ndar\n\n formatter=repr @@ -226,28 +226,28 @@ formatter=repr/ascii with multiline and non-ASCII characters ... --- first ... +++ second ... @@ -1,3 +1,3 @@ - ... \ Å + ... Å ... -Ä ... +Ä - ... \ Ö + ... Ö ... ... 2) Multiline strings are different: ... --- first ... +++ second ... @@ -1,3 +1,3 @@ - ... \ 'Å\\n' + ... 'Å\\n' ... -'Ä\\n' ... +'Ä\\n' - ... \ 'Ö\\n' + ... 'Ö\\n' ... ... 3) Multiline strings are different: ... --- first ... +++ second ... @@ -1,3 +1,3 @@ - ... \ '\\xc5\\n' + ... '\\xc5\\n' ... -'\\xc4\\n' ... +'A\\u0308\\n' - ... \ '\\xd6\\n' + ... '\\xd6\\n' Å\nÄ\n\Ö\n Å\nA\u0308\n\Ö\n Å\nÄ\n\Ö\n Å\nA\u0308\n\Ö\n formatter=repr Å\nÄ\n\Ö\n Å\nA\u0308\n\Ö\n formatter=ascii diff --git a/atest/testdata/standard_libraries/builtin/should_be_equal_as_xxx.robot b/atest/testdata/standard_libraries/builtin/should_be_equal_as_xxx.robot index cb3cf488df7..fe98dc12adc 100644 --- a/atest/testdata/standard_libraries/builtin/should_be_equal_as_xxx.robot +++ b/atest/testdata/standard_libraries/builtin/should_be_equal_as_xxx.robot @@ -149,11 +149,11 @@ Should Be Equal As Strings multiline ... --- first ... +++ second ... @@ -1,3 +1,4 @@ - ... \ foo + ... foo ... -bar ... +bar ... +gar - ... \ dar + ... dar Should Be Equal As Strings foo\nbar\r\ndar foo\nbar\ngar\ndar Should Be Equal As Strings multiline with custom message @@ -162,11 +162,11 @@ Should Be Equal As Strings multiline with custom message ... --- first ... +++ second ... @@ -1,3 +1,4 @@ - ... \ foo + ... foo ... -bar ... +bar ... +gar - ... \ dar + ... dar Should Be Equal As Strings foo\nbar\r\ndar foo\nbar\ngar\ndar ... msg=Custom message of mine @@ -176,11 +176,11 @@ Should Be Equal As Strings repr multiline ... --- first ... +++ second ... @@ -1,3 +1,4 @@ - ... \ 'foo\\n' + ... 'foo\\n' ... -'bar\\r\\n' ... +'bar\\n' ... +'gar\\n' - ... \ 'dar' + ... 'dar' Should Be Equal As Strings foo\nbar\r\ndar foo\nbar\ngar\ndar formatter=repr Should Not Be Equal As Strings diff --git a/doc/userguide/src/Appendices/DocumentationFormatting.rst b/doc/userguide/src/Appendices/DocumentationFormatting.rst index e51fc94a4b2..ba60f075b52 100644 --- a/doc/userguide/src/Appendices/DocumentationFormatting.rst +++ b/doc/userguide/src/Appendices/DocumentationFormatting.rst @@ -19,13 +19,13 @@ __ `Documenting libraries`_ :depth: 2 :local: -Representing newlines ---------------------- +Handling whitespace in test data +-------------------------------- -Newlines in test data -~~~~~~~~~~~~~~~~~~~~~ +Newlines +~~~~~~~~ -When documenting test suites, test cases and keywords or adding metadata +When documenting test suites, test cases and user keywords or adding metadata to test suites, newlines can be added manually using `\n` `escape sequence`_. .. sourcecode:: robotframework @@ -53,56 +53,59 @@ means that the above example could be written also as follows. ... ... Second paragraph. This time ... with multiple lines. - Metadata Example list + Metadata + ... Example list ... - first item ... - second item ... - third No automatic newline is added if a line already ends with a literal newline -or if it ends with an `escaping backslash`__. If documentation or metadata -is defined in multiple columns, cells in a same row are concatenated together -with a space. Different ways to split documentation are illustrated in the -examples below where all test cases end up having the same two line -documentation. - -__ `Dividing data to several rows`_ -__ Escaping_ +or if it ends with an `escaping backslash`__: .. sourcecode:: robotframework *** Test Cases *** - Example 1 - [Documentation] First line\n Second line in multiple parts - No Operation + Ends with newline + [Documentation] Ends with a newline and\n + ... automatic newline is not added. - Example 2 - [Documentation] First line - ... Second line in multiple parts - No Operation + Ends with backslash + [Documentation] Ends with a backslash and \ + ... no newline is added. - Example 3 - [Documentation] First line\n - ... Second line in\ - ... multiple parts - No Operation - -Documentation in test libraries -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -With library documentations normal newlines are enough, and for -example the following keyword documentation would create same end result -as the test suite documentation in the previous section. +__ `Dividing data to several rows`_ +__ Escaping_ -.. sourcecode:: python +Spaces +~~~~~~ - def example_keyword(): - """First line. +Unlike elsewhere in Robot Framework data, leading spaces and consecutive internal +spaces are preserved in documentation and metadata. This makes it possible, for example, +to split `list items`__ to multiple rows and have `preformatted text`_ with spaces: - Second paragraph, this time - with multiple lines. - """ - pass +.. sourcecode:: robotframework + *** Test Cases *** + Long list item + [Documentation] + ... List: + ... - Short item. + ... - Second item is pretty long and it is split to + ... multiple rows. Leading spaces are preserved. + ... - Another short item. + + Preformatted text + [Documentation] + ... Example with consecutive internal spaces: + ... + ... | *** Test Cases *** + ... | Example + ... | Keyword + +__ lists_ + +.. note:: Preserving spaces in documentation and metadata is new in Robot Framework 6.1. + With earlier versions spaces need to be escaped with a backslash. Paragraphs ---------- @@ -355,15 +358,6 @@ The above documentation is formatted like this: <p>After block.</p> </div> -When documenting suites, tests or keywords in Robot Framework test data, -having multiple spaces requires escaping_ with a backslash to prevent -ignoring spaces. The example above would thus be written like this:: - - Doc before block: - | inside block - | \ \ \ some \ \ additional whitespace - After block. - Horizontal ruler ---------------- diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 7a6c1df6a38..812dc7a839a 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -497,7 +497,7 @@ __ `Suite documentation`_ __ `Test case documentation`_ __ `User keyword documentation`_ __ `Free suite metadata`_ -__ `Newlines in test data`_ +__ `Newlines`_ .. sourcecode:: robotframework diff --git a/doc/userguide/src/SupportingTools/Libdoc.rst b/doc/userguide/src/SupportingTools/Libdoc.rst index 3cf7c7b73a4..d8703f5aa28 100644 --- a/doc/userguide/src/SupportingTools/Libdoc.rst +++ b/doc/userguide/src/SupportingTools/Libdoc.rst @@ -407,7 +407,7 @@ Possible variables in resource files can not be documented. ... | Your Keyword | yyy | No Operation -__ `Newlines in test data`_ +__ `Newlines`_ Documentation syntax -------------------- diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 796e8ffec79..8c3029068e3 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -167,16 +167,17 @@ class DocumentationOrMetadata(Statement): @property def value(self): - return ''.join(self._get_lines_with_newlines()).rstrip() + return ''.join(self._get_lines()).rstrip() - def _get_lines_with_newlines(self): - for parts in self._get_line_parts(): - line = ' '.join(parts) - yield line - if not self._escaped_or_has_newline(line): - yield '\n' + def _get_lines(self): + base_offset = -1 + for tokens in self._get_line_tokens(): + yield from self._get_line_values(tokens, base_offset) + first = tokens[0] + if base_offset < 0 or 0 < first.col_offset < base_offset and first.value: + base_offset = first.col_offset - def _get_line_parts(self): + def _get_line_tokens(self): line = [] lineno = -1 # There are no EOLs during execution or if data has been parsed with @@ -192,12 +193,31 @@ def _get_line_parts(self): yield line line = [] if not eol: - line.append(token.value) + line.append(token) lineno = token.lineno if line: yield line - def _escaped_or_has_newline(self, line): + def _get_line_values(self, tokens, offset): + token = None + for index, token in enumerate(tokens): + if token.col_offset > offset > 0: + yield ' ' * (token.col_offset - offset) + elif index > 0: + yield ' ' + yield self._remove_trailing_backslash(token.value) + offset = token.end_col_offset + if token and not self._has_trailing_backslash_or_newline(token.value): + yield '\n' + + def _remove_trailing_backslash(self, value): + if value and value[-1] == '\\': + match = re.search(r'(\\+)$', value) + if len(match.group(1)) % 2 == 1: + value = value[:-1] + return value + + def _has_trailing_backslash_or_newline(self, line): match = re.search(r'(\\+)n?$', line) return match and len(match.group(1)) % 2 == 1 diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index be562984ef4..cc546de54c9 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1076,14 +1076,14 @@ def test_multi_part(self): Token(Token.ARGUMENT, 'world', 2, 26), Token(Token.EOL, '\n', 2, 31)] ) - self._verify_documentation(data, expected, 'Hello world') + self._verify_documentation(data, expected, 'Hello world') def test_multi_line(self): data = '''\ *** Settings *** Documentation Documentation ... in -... multiple lines and parts +... multiple lines ''' expected = Documentation( tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), @@ -1097,12 +1097,9 @@ def test_multi_line(self): Token(Token.CONTINUATION, '...', 4, 0), Token(Token.SEPARATOR, ' ', 4, 3), Token(Token.ARGUMENT, 'multiple lines', 4, 17), - Token(Token.SEPARATOR, ' ', 4, 31), - Token(Token.ARGUMENT, 'and parts', 4, 35), - Token(Token.EOL, '\n', 4, 44)] + Token(Token.EOL, '\n', 4, 31)] ) - self._verify_documentation(data, expected, - 'Documentation\nin\nmultiple lines and parts') + self._verify_documentation(data, expected, 'Documentation\nin\nmultiple lines') def test_multi_line_with_empty_lines(self): data = '''\ @@ -1126,6 +1123,130 @@ def test_multi_line_with_empty_lines(self): ) self._verify_documentation(data, expected, 'Documentation\n\nwith empty') + def test_no_automatic_newline_after_literal_newline(self): + data = '''\ +*** Settings *** +Documentation No automatic\\n +... newline +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.SEPARATOR, ' ', 2, 13), + Token(Token.ARGUMENT, 'No automatic\\n', 2, 17), + Token(Token.EOL, '\n', 2, 31), + Token(Token.CONTINUATION, '...', 3, 0), + Token(Token.SEPARATOR, ' ', 3, 3), + Token(Token.ARGUMENT, 'newline', 3, 17), + Token(Token.EOL, '\n', 3, 24)] + ) + self._verify_documentation(data, expected, 'No automatic\\nnewline') + + def test_no_automatic_newline_after_backlash(self): + data = '''\ +*** Settings *** +Documentation No automatic \\ +... newline\\\\\\ +... and remove\\ trailing\\\\ back\\slashes\\\\\\ +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.SEPARATOR, ' ', 2, 13), + Token(Token.ARGUMENT, 'No automatic \\', 2, 17), + Token(Token.EOL, '\n', 2, 31), + Token(Token.CONTINUATION, '...', 3, 0), + Token(Token.SEPARATOR, ' ', 3, 3), + Token(Token.ARGUMENT, 'newline\\\\\\', 3, 17), + Token(Token.EOL, '\n', 3, 27), + Token(Token.CONTINUATION, '...', 4, 0), + Token(Token.SEPARATOR, ' ', 4, 3), + Token(Token.ARGUMENT, 'and remove\\', 4, 17), + Token(Token.SEPARATOR, ' ', 4, 28), + Token(Token.ARGUMENT, 'trailing\\\\', 4, 32), + Token(Token.SEPARATOR, ' ', 4, 42), + Token(Token.ARGUMENT, 'back\\slashes\\\\\\', 4, 46), + Token(Token.EOL, '\n', 4, 61)] + ) + self._verify_documentation(data, expected, + 'No automatic newline\\\\' + 'and remove trailing\\\\ back\\slashes\\\\') + + def test_preserve_indentation(self): + data = '''\ +*** Settings *** +Documentation +... Example: +... +... - list with +... - two +... items +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.EOL, '\n', 2, 13), + Token(Token.CONTINUATION, '...', 3, 0), + Token(Token.SEPARATOR, ' ', 3, 3), + Token(Token.ARGUMENT, 'Example:', 3, 7), + Token(Token.EOL, '\n', 3, 15), + Token(Token.CONTINUATION, '...', 4, 0), + Token(Token.ARGUMENT, '', 4, 3), + Token(Token.EOL, '\n', 4, 3), + Token(Token.CONTINUATION, '...', 5, 0), + Token(Token.SEPARATOR, ' ', 5, 3), + Token(Token.ARGUMENT, '- list with', 5, 11), + Token(Token.EOL, '\n', 5, 22), + Token(Token.CONTINUATION, '...', 6, 0), + Token(Token.SEPARATOR, ' ', 6, 3), + Token(Token.ARGUMENT, '- two', 6, 11), + Token(Token.EOL, '\n', 6, 16), + Token(Token.CONTINUATION, '...', 7, 0), + Token(Token.SEPARATOR, ' ', 7, 3), + Token(Token.ARGUMENT, 'items', 7, 13), + Token(Token.EOL, '\n', 7, 18)] + ) + self._verify_documentation(data, expected, '''\ +Example: + + - list with + - two + items''') + + def test_preserve_indentation_with_data_on_first_doc_row(self): + data = '''\ +*** Settings *** +Documentation Example: +... +... - list with +... - two +... items +''' + expected = Documentation( + tokens=[Token(Token.DOCUMENTATION, 'Documentation', 2, 0), + Token(Token.SEPARATOR, ' ', 2, 13), + Token(Token.ARGUMENT, 'Example:', 2, 17), + Token(Token.EOL, '\n', 2, 25), + Token(Token.CONTINUATION, '...', 3, 0), + Token(Token.ARGUMENT, '', 3, 3), + Token(Token.EOL, '\n', 3, 3), + Token(Token.CONTINUATION, '...', 4, 0), + Token(Token.SEPARATOR, ' ', 4, 3), + Token(Token.ARGUMENT, '- list with', 4, 9), + Token(Token.EOL, '\n', 4, 20), + Token(Token.CONTINUATION, '...', 5, 0), + Token(Token.SEPARATOR, ' ', 5, 3), + Token(Token.ARGUMENT, '- two', 5, 9), + Token(Token.EOL, '\n', 5, 14), + Token(Token.CONTINUATION, '...', 6, 0), + Token(Token.SEPARATOR, ' ', 6, 3), + Token(Token.ARGUMENT, 'items', 6, 11), + Token(Token.EOL, '\n', 6, 16)] + ) + self._verify_documentation(data, expected, '''\ +Example: + +- list with +- two + items''') + def _verify_documentation(self, data, expected, value): # Model has both EOLs and line numbers. doc = get_model(data).sections[0].body[0] From 61c2604a48611c471d72409745d5e7904a3e21ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 13 Apr 2023 17:17:55 +0300 Subject: [PATCH 0495/1592] Run unit tests without coverage/codecov. Creating coverage reports seems to fail. Nobody has noticed and I believe it's better to not use coverage than trying to fix it. --- .github/workflows/unit_tests.yml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 2c69420f914..304cc84f45b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -36,18 +36,7 @@ jobs: python-version: ${{ matrix.python-version }} architecture: 'x64' - - name: Run unit tests with coverage + - name: Run unit tests run: | - python -m pip install coverage python -m pip install -r utest/requirements.txt - python -m coverage run --branch utest/run.py -v - - - name: Prepare HTML/XML coverage report - run: | - python -m coverage xml -i - if: always() - - - uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70 - with: - name: ${{ matrix.python-version }}-${{ matrix.os }} - if: always() + python utest/run.py -v From 2f363d67740f27bee2ed94762e4b97481d48674f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Mon, 3 Apr 2023 20:56:20 +0300 Subject: [PATCH 0496/1592] Add on_limit option to WHILE This implementation allows two values, `pass`, where the execution is continued even when the limit is exceeded and `fail`, (the default) where the loop fails when the limit is exceeded. Fixes #4562 --- ...{on_limit_message.robot => on_limit.robot} | 32 ++++- atest/testdata/running/while/on_limit.robot | 128 ++++++++++++++++++ .../running/while/on_limit_message.robot | 63 --------- .../CreatingTestData/ControlStructures.rst | 28 +++- .../ListenerInterface.rst | 2 + src/robot/model/control.py | 9 +- src/robot/output/xmllogger.py | 1 + src/robot/parsing/lexer/statementlexers.py | 3 +- src/robot/parsing/model/blocks.py | 4 + src/robot/parsing/model/statements.py | 8 +- src/robot/result/model.py | 10 +- src/robot/result/xmlelementhandlers.py | 1 + src/robot/running/bodyrunner.py | 67 ++++++--- src/robot/running/builder/transformers.py | 4 +- utest/parsing/test_model.py | 6 +- utest/result/test_resultmodel.py | 4 +- 16 files changed, 270 insertions(+), 100 deletions(-) rename atest/robot/running/while/{on_limit_message.robot => on_limit.robot} (50%) create mode 100644 atest/testdata/running/while/on_limit.robot delete mode 100644 atest/testdata/running/while/on_limit_message.robot diff --git a/atest/robot/running/while/on_limit_message.robot b/atest/robot/running/while/on_limit.robot similarity index 50% rename from atest/robot/running/while/on_limit_message.robot rename to atest/robot/running/while/on_limit.robot index 05351aacfd4..4e13375a11e 100644 --- a/atest/robot/running/while/on_limit_message.robot +++ b/atest/robot/running/while/on_limit.robot @@ -1,11 +1,38 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} running/while/on_limit_message.robot +Suite Setup Run Tests ${EMPTY} running/while/on_limit.robot Resource while.resource *** Test Cases *** +On limit pass with time limit defined + Check Test Case ${TESTNAME} + +On limit pass with iteration limit defined + Check WHILE loop PASS 5 + On limit message without limit Check Test Case ${TESTNAME} +On limit fail + Check Test Case ${TESTNAME} + +On limit pass with failures in loop + Check Test Case ${TESTNAME} + +On limit pass with continuable failure + Check Test Case ${TESTNAME} + +On limit fail with continuable failure + Check Test Case ${TESTNAME} + +Invalid on_limit + Check Test Case ${TESTNAME} + +On limit without limit defined + Check Test Case ${TESTNAME} + +On limit with invalid variable + Check Test Case ${TESTNAME} + Wrong WHILE argument Check Test Case ${TESTNAME} @@ -27,5 +54,8 @@ Nested while on limit message On limit message before limit Check Test Case ${TESTNAME} +On limit messge with invalid variable + Check Test Case ${TESTNAME} + Wrong WHILE arguments Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/while/on_limit.robot b/atest/testdata/running/while/on_limit.robot new file mode 100644 index 00000000000..e1aba584f5e --- /dev/null +++ b/atest/testdata/running/while/on_limit.robot @@ -0,0 +1,128 @@ +*** Variables *** +${variable} ${1} +${limit} 11 +${number} ${0.2} +${pass} Pass +${errorMsg} Error Message + +*** Test Cases *** +On limit pass with time limit defined + WHILE True limit=0.1s on_limit=${pass} + No Operation + Sleep 0.05 + END + +On limit pass with iteration limit defined + WHILE True limit=5 on_limit=pass + No Operation + END + +On limit fail + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 5 iterations. Use the 'limit' argument to increase or remove the limit if needed. + WHILE True limit=5 on_limit=FaIl + No Operation + END + +On limit pass with failures in loop + [Documentation] FAIL Oh no! + WHILE True limit=5 on_limit=pass + Fail Oh no! + END + +On limit pass with continuable failure + [Documentation] FAIL Third failure, this time a hard one. + WHILE limit=2 on_limit=pass + Run Keyword And Continue On Failure Fail Continuable failure! + END + Fail Third failure, this time a hard one. + +On limit fail with continuable failure + [Documentation] FAIL Several failures occurred:\n\n + ... 1) Continuable failure!\n\n + ... 2) Continuable failure!\n\n + ... 3) WHILE loop was aborted because it did not finish within the limit of 2 iterations. Use the 'limit' argument to increase or remove the limit if needed. + WHILE limit=2 on_limit=fail + Run Keyword And Continue On Failure Fail Continuable failure! + END + Fail Should not be executed! + +Invalid on_limit + [Documentation] FAIL Invalid WHILE loop 'on_limit' value 'inValid': Value must be 'PASS' or 'FAIL'. + WHILE True limit=5 on_limit=inValid + Fail Oh no! + END + +On limit without limit defined + [Documentation] FAIL WHILE on_limit option cannot be used without limit. + WHILE True on_limit=PaSS + No Operation + END + +On limit with invalid variable + [Documentation] FAIL Invalid WHILE loop 'on_limit' value '${does not exist}': Variable '${does not exist}' not found. + WHILE True limit=5 on_limit=${does not exist} + Fail Oh no! + END + +On limit message without limit + [Documentation] FAIL Error + WHILE $variable < 2 on_limit_message=Error + Log ${variable} + END + +Wrong WHILE argument + [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limit=5' and 'limit_exceed_messag=Custom error message'. + WHILE $variable < 2 limit=5 limit_exceed_messag=Custom error message + Log ${variable} + END + +On limit message + [Documentation] FAIL Custom error message + WHILE $variable < 2 limit=${limit} on_limit_message=Custom error message + Log ${variable} + END + +On limit message from variable + [Documentation] FAIL ${errorMsg} + WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} + Log ${variable} + END + +Part of on limit message from variable + [Documentation] FAIL While ${errorMsg} 2 ${number} + WHILE $variable < 2 limit=5 on_limit_message=While ${errorMsg} 2 ${number} + Log ${variable} + END + +No on limit message + WHILE $variable < 3 limit=10 on_limit_message=${errorMsg} 2 + Log ${variable} + ${variable}= Evaluate $variable + 1 + END + +Nested while on limit message + [Documentation] FAIL ${errorMsg} 2 + WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} 1 + WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} 2 + Log ${variable} + END + END + +On limit message before limit + [Documentation] FAIL Error + WHILE $variable < 2 on_limit_message=Error limit=5 + Log ${variable} + END + +On limit messge with invalid variable + [Documentation] FAIL Invalid WHILE loop 'on_limit_message': 'Variable '${nonExisting}' not found. + WHILE $variable < 2 on_limit_message=${nonExisting} limit=5 + Log ${variable} + END + + +Wrong WHILE arguments + [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limite=5' and 'limit_exceed_messag=Custom error message'. + WHILE $variable < 2 limite=5 limit_exceed_messag=Custom error message + Log ${variable} + END diff --git a/atest/testdata/running/while/on_limit_message.robot b/atest/testdata/running/while/on_limit_message.robot deleted file mode 100644 index 1798ef2a2c4..00000000000 --- a/atest/testdata/running/while/on_limit_message.robot +++ /dev/null @@ -1,63 +0,0 @@ -*** Variables *** -${variable} ${1} -${limit} 11 -${number} ${0.2} -${errorMsg} Error Message - -*** Test Cases *** -On limit message without limit - [Documentation] FAIL Error - WHILE $variable < 2 on_limit_message=Error - Log ${variable} - END - -Wrong WHILE argument - [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limit=5' and 'limit_exceed_messag=Custom error message'. - WHILE $variable < 2 limit=5 limit_exceed_messag=Custom error message - Log ${variable} - END - -On limit message - [Documentation] FAIL Custom error message - WHILE $variable < 2 limit=${limit} on_limit_message=Custom error message - Log ${variable} - END - -On limit message from variable - [Documentation] FAIL ${errorMsg} - WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} - Log ${variable} - END - -Part of on limit message from variable - [Documentation] FAIL While ${errorMsg} 2 ${number} - WHILE $variable < 2 limit=5 on_limit_message=While ${errorMsg} 2 ${number} - Log ${variable} - END - -No on limit message - WHILE $variable < 3 limit=10 on_limit_message=${errorMsg} 2 - Log ${variable} - ${variable}= Evaluate $variable + 1 - END - -Nested while on limit message - [Documentation] FAIL ${errorMsg} 2 - WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} 1 - WHILE $variable < 2 limit=5 on_limit_message=${errorMsg} 2 - Log ${variable} - END - END - -On limit message before limit - [Documentation] FAIL Error - WHILE $variable < 2 on_limit_message=Error limit=5 - Log ${variable} - END - - -Wrong WHILE arguments - [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limite=5' and 'limit_exceed_messag=Custom error message'. - WHILE $variable < 2 limite=5 limit_exceed_messag=Custom error message - Log ${variable} - END diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index 6f9fcde3b1b..bf07cd8d06b 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -641,11 +641,35 @@ Keywords in a loop are not forcefully stopped if the limit is exceeded. Instead the loop is exited similarly as if the loop condition would have become false. A major difference is that the loop status will be `FAIL` in this case. +Starting from Robot Framework 6.1, it is possible to use `on_limit` parameter to +configure the behaviour when the limit is exceeded. It supports two values `pass` +and `fail`, case insensitively. If the value is `pass`, the execution will continue +normally when the limit is reached and the status of the `WHILE` loop will be `PASS`. +The value `fail` works similarly as the default behaviour, e.g. the loop and the +test will fail if the limit is exceeded. + +.. sourcecode:: robotframework + + *** Test Cases *** + Continue when iteration limit is reached + WHILE True limit=5 on_limit=pass + Log Loop will be executed five times + END + Log This will be executed normally. + + Continue when time limit is reached + WHILE True limit=10s on_limit=pass + Log Loop will be executed for 10 seconds. + Sleep 0.5s + END + Log This will be executed normally. + + By default, the error message raised when the limit is reached is `WHILE loop was aborted because it did not finish within the limit of 0.5 seconds. Use the 'limit' argument to increase or remove the limit if -needed.`. The error message can be changed with the `on_limit_message` -configuration parameter. +needed.`. Starting from Robot Framework 6.1, the error message can be changed +with the `on_limit_message` configuration parameter. .. sourcecode:: robotframework diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 49785b05d56..7ade634d37c 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -270,6 +270,8 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | | | | | * `condition`: The looping condition. | | | | * `limit`: The maximum iteration limit. | + | | | * `on_limit`: What to do if the limit is exceeded. | + | | | Valid values are `pass` and `fail`. New in RF 6.1. | | | | * `on_limit_message`: The custom error raised when the | | | | limit of the WHILE loop is reached. New in RF 6.1. | | | | | diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 717e991df08..f27065fed7e 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -89,12 +89,13 @@ class While(BodyItem): """Represents ``WHILE`` loops.""" type = BodyItem.WHILE body_class = Body - repr_args = ('condition', 'limit', 'on_limit_message') - __slots__ = ['condition', 'limit', 'on_limit_message'] + repr_args = ('condition', 'limit', 'on_limit', 'on_limit_message') + __slots__ = ['condition', 'limit', 'on_limit', 'on_limit_message'] - def __init__(self, condition=None, limit=None, + def __init__(self, condition=None, limit=None, on_limit=None, on_limit_message=None, parent=None): self.condition = condition + self.on_limit = on_limit self.limit = limit self.on_limit_message = on_limit_message self.parent = parent @@ -113,6 +114,8 @@ def __str__(self): parts.append(self.condition) if self.limit is not None: parts.append(f'limit={self.limit}') + if self.on_limit is not None: + parts.append(f'limit={self.on_limit}') if self.on_limit_message is not None: parts.append(f'on_limit_message={self.on_limit_message}') return ' '.join(parts) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 75d33deb454..b4a4852e15e 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -149,6 +149,7 @@ def start_while(self, while_): self._writer.start('while', attrs={ 'condition': while_.condition, 'limit': while_.limit, + 'on_limit': while_.on_limit, 'on_limit_message': while_.on_limit_message }) self._writer.element('doc', while_.doc) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index a730bbaa830..14fb440312e 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -310,7 +310,8 @@ def lex(self): for token in self.statement[1:]: token.type = Token.ARGUMENT for token in reversed(self.statement): - if not token.value.startswith(('limit=', 'on_limit_message=')): + if not token.value.startswith(('limit=', 'on_limit=', + 'on_limit_message=')): break token.type = Token.OPTION diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 95e92a52eb9..1ff1706ecc1 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -348,6 +348,10 @@ def condition(self): def limit(self): return self.header.limit + @property + def on_limit(self): + return self.header.on_limit + @property def on_limit_message(self): return self.header.on_limit_message diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 8c3029068e3..4bf00dbe823 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -1049,7 +1049,7 @@ class WhileHeader(Statement): type = Token.WHILE @classmethod - def from_params(cls, condition, limit=None, on_limit_message=None, + def from_params(cls, condition, limit=None, on_limit=None, on_limit_message=None, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): tokens = [Token(Token.SEPARATOR, indent), Token(cls.type), @@ -1073,6 +1073,10 @@ def condition(self): def limit(self): return self.get_option('limit') + @property + def on_limit(self): + return self.get_option('on_limit') + @property def on_limit_message(self): return self.get_option('on_limit_message') @@ -1081,6 +1085,8 @@ def validate(self, ctx: 'ValidationContext'): values = self.get_values(Token.ARGUMENT) if len(values) > 1: self.errors += (f'WHILE cannot have more than one condition, got {seq2str(values)}.',) + if self.on_limit and not self.limit: + self.errors += ('WHILE on_limit option cannot be used without limit.',) @Statement.register diff --git a/src/robot/result/model.py b/src/robot/result/model.py index e5a9e5f0400..915002bd5c3 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -232,10 +232,10 @@ class While(model.While, StatusMixin, DeprecatedAttributesMixin): iteration_class = WhileIteration __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, condition=None, limit=None, on_limit_message=None, - parent=None, status='FAIL', starttime=None, - endtime=None, doc=''): - super().__init__(condition, limit, on_limit_message, parent) + def __init__(self, condition=None, limit=None, on_limit=None, + on_limit_message=None, parent=None, status='FAIL', + starttime=None, endtime=None, doc=''): + super().__init__(condition, limit, on_limit, on_limit_message, parent) self.status = status self.starttime = starttime self.endtime = endtime @@ -253,6 +253,8 @@ def name(self): parts.append(self.condition) if self.limit: parts.append(f'limit={self.limit}') + if self.on_limit: + parts.append(f'on_limit={self.on_limit}') if self.on_limit_message: parts.append(f'on_limit_message={self.on_limit_message}') return ' | '.join(parts) diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 5840e91b5ff..aeb4b9c2c03 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -193,6 +193,7 @@ def start(self, elem, result): return result.body.create_while( condition=elem.get('condition'), limit=elem.get('limit'), + on_limit=elem.get('on_limit'), on_limit_message=elem.get('on_limit_message') ) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index f429a38385e..58ca1b46d7a 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -376,7 +376,7 @@ def run(self, data): run = False limit = None loop_result = WhileResult(data.condition, data.limit, - data.on_limit_message, + data.on_limit, data.on_limit_message, starttime=get_timestamp()) iter_result = loop_result.body.create_iteration(starttime=get_timestamp()) if self._run: @@ -385,6 +385,7 @@ def run(self, data): elif not ctx.dry_run: try: limit = WhileLimit.create(data.limit, + data.on_limit, data.on_limit_message, ctx.variables) run = self._should_run(data.condition, ctx.variables) @@ -410,6 +411,10 @@ def run(self, data): passed.set_earlier_failures(errors) raise passed except ExecutionFailed as failed: + if isinstance(failed, LimitExceeded): + if failed.on_limit_pass: + self._context.info(failed.message) + return errors.extend(failed.get_errors()) if not failed.can_continue(ctx, self._templated): break @@ -631,18 +636,21 @@ def _run_finally(self, data, run): class WhileLimit: - def __init__(self, on_limit_message=None): + def __init__(self, on_limit=None, on_limit_message=None): + self.on_limit = on_limit self.on_limit_message = on_limit_message @classmethod - def create(cls, limit, on_limit_message, variables): + def create(cls, limit, on_limit, on_limit_message, variables): if on_limit_message: - on_limit_message = variables.replace_string( - on_limit_message) + try: + on_limit_message = variables.replace_string(on_limit_message) + except DataError as err: + raise DataError(f"Invalid WHILE loop 'on_limit_message': '{err}") + on_limit = cls.parse_on_limit(variables, on_limit) if not limit: return IterationCountLimit(DEFAULT_WHILE_LIMIT, - on_limit_message - ) + on_limit, on_limit_message) value = variables.replace_string(limit) if value.upper() == 'NONE': return NoLimit() @@ -654,23 +662,37 @@ def create(cls, limit, on_limit_message, variables): if count <= 0: raise DataError(f"Invalid WHILE loop limit: Iteration count must be " f"a positive integer, got '{count}'.") - return IterationCountLimit(count, on_limit_message) + return IterationCountLimit(count, on_limit, on_limit_message) try: secs = timestr_to_secs(value) except ValueError as err: raise DataError(f'Invalid WHILE loop limit: {err.args[0]}') else: - return DurationLimit(secs, on_limit_message) + return DurationLimit(secs, on_limit, on_limit_message) + + @classmethod + def parse_on_limit(cls, variables, on_limit): + if on_limit is None: + return None + try: + on_limit = variables.replace_string(on_limit) + if on_limit.upper() not in ['PASS', 'FAIL']: + raise DataError("Value must be 'PASS' or 'FAIL'.") + except DataError as err: + raise DataError(f"Invalid WHILE loop 'on_limit' value '{on_limit}': {err}") + else: + return on_limit.lower() def limit_exceeded(self): + on_limit_pass = self.on_limit == 'pass' if self.on_limit_message: - raise ExecutionFailed(self.on_limit_message) + raise LimitExceeded(on_limit_pass, self.on_limit_message) else: - raise ExecutionFailed(f"WHILE loop was aborted because " - f"it did not finish " - f"within the limit of {self}. " - f"Use the 'limit' argument to " - f"increase or remove the limit if needed.") + raise LimitExceeded( + on_limit_pass, + f"WHILE loop was aborted because it did not finish within the limit of {self}. " + f"Use the 'limit' argument to increase or remove the limit if needed." + ) def __enter__(self): raise NotImplementedError @@ -681,8 +703,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): class DurationLimit(WhileLimit): - def __init__(self, max_time, on_limit_message): - super().__init__(on_limit_message) + def __init__(self, max_time, on_limit, on_limit_message): + super().__init__(on_limit, on_limit_message) self.max_time = max_time self.start_time = None @@ -698,8 +720,8 @@ def __str__(self): class IterationCountLimit(WhileLimit): - def __init__(self, max_iterations, on_limit_message): - super().__init__(on_limit_message) + def __init__(self, max_iterations, on_limit, on_limit_message): + super().__init__(on_limit, on_limit_message) self.max_iterations = max_iterations self.current_iterations = 0 @@ -716,3 +738,10 @@ class NoLimit(WhileLimit): def __enter__(self): pass + + +class LimitExceeded(ExecutionFailed): + + def __init__(self, on_limit_pass, message): + super().__init__(message) + self.on_limit_pass = on_limit_pass diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index a276df286c9..8268ccd9b57 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -551,8 +551,8 @@ def __init__(self, parent): def build(self, node): error = format_error(self._get_errors(node)) self.model = self.parent.body.create_while( - node.condition, node.limit, node.on_limit_message, - lineno=node.lineno, error=error + node.condition, node.limit, node.on_limit, + node.on_limit_message, lineno=node.lineno, error=error ) for step in node.body: self.visit(step) diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index cc546de54c9..68330deaaad 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -403,7 +403,7 @@ def test_on_limit_message(self): data = ''' *** Test Cases *** Example - WHILE True limit=10s on_limit_message=Error message + WHILE True limit=10s on_limit=pass on_limit_message=Error message Log ${x} END ''' @@ -412,8 +412,8 @@ def test_on_limit_message(self): Token(Token.WHILE, 'WHILE', 3, 4), Token(Token.ARGUMENT, 'True', 3, 13), Token(Token.OPTION, 'limit=10s', 3, 21), - Token(Token.OPTION, 'on_limit_message=Error message', - 3, 34) + Token(Token.OPTION, 'on_limit=pass', 3, 34), + Token(Token.OPTION, 'on_limit_message=Error message', 3, 51) ]), body=[ KeywordCall([Token(Token.KEYWORD, 'Log', 4, 8), diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 9b581d7faf1..708726ea64c 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -329,8 +329,10 @@ def test_while_name(self): assert_equal(While('$x > 0').name, '$x > 0') assert_equal(While('True', '1 minute').name, 'True | limit=1 minute') assert_equal(While(limit='1 minute').name, 'limit=1 minute') - assert_equal(While('True', '1 s', 'Error message').name, + assert_equal(While('True', '1 s', on_limit_message='Error message').name, 'True | limit=1 s | on_limit_message=Error message') + assert_equal(While(on_limit='pass').name, + 'on_limit=pass') assert_equal(While(on_limit_message='Error message').name, 'on_limit_message=Error message') From bcb0e315628116eebee473e085a5a34ee48b4501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 17 Apr 2023 17:45:31 +0300 Subject: [PATCH 0497/1592] Languages: Add type hints. Needed, for example, when adding type hints to the parsing API (#4740). Also support language files as Path instances, not only strings. --- src/robot/conf/__init__.py | 2 +- src/robot/conf/languages.py | 76 ++++++++++++++++++++--------------- utest/api/orcish_languages.py | 2 + utest/api/test_languages.py | 43 ++++++++++++++------ 4 files changed, 77 insertions(+), 46 deletions(-) diff --git a/src/robot/conf/__init__.py b/src/robot/conf/__init__.py index f5ffc583854..d02619a7c1f 100644 --- a/src/robot/conf/__init__.py +++ b/src/robot/conf/__init__.py @@ -24,5 +24,5 @@ Instantiating them is not likely to change, though. """ -from .languages import Languages, Language +from .languages import Language, LanguageLike, Languages, LanguagesLike from .settings import RobotSettings, RebotSettings diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 7ebb3d2d297..5d447be3161 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -15,14 +15,19 @@ import inspect from itertools import chain -import os.path +from pathlib import Path +from typing import Iterable, Iterator, Union from robot.errors import DataError from robot.utils import classproperty, is_list_like, Importer, normalize +LanguageLike = Union['Language', str, Path] +LanguagesLike = Union['Languages', LanguageLike, Iterable[LanguageLike], None] + + class Languages: - """Keeps a list of languages and unifies the translations in the properties. + """Stores languages and unifies translations. Example:: @@ -33,7 +38,8 @@ class Languages: print(lang.name, lang.code) """ - def __init__(self, languages=None, add_english=True): + def __init__(self, languages: 'Iterable[LanguageLike]|LanguageLike' = (), + add_english: bool = True): """ :param languages: Initial language or list of languages. Languages can be given as language codes or names, paths or names of @@ -43,20 +49,20 @@ def __init__(self, languages=None, add_english=True): :meth:`add_language` can be used to add languages after initialization. """ - self.languages = [] - self.headers = {} - self.settings = {} - self.bdd_prefixes = set() - self.true_strings = {'True', '1'} - self.false_strings = {'False', '0', 'None', ''} + self.languages: 'list[Language]' = [] + self.headers: 'dict[str, str]' = {} + self.settings: 'dict[str, str]' = {} + self.bdd_prefixes: 'set[str]' = set() + self.true_strings: 'set[str]' = {'True', '1'} + self.false_strings: 'set[str]' = {'False', '0', 'None', ''} for lang in self._get_languages(languages, add_english): self._add_language(lang) - def reset(self, languages=None, add_english=True): + def reset(self, languages: Iterable[LanguageLike] = (), add_english: bool = True): """Resets the instance to the given languages.""" self.__init__(languages, add_english) - def add_language(self, lang): + def add_language(self, lang: LanguageLike): """Add new language. :param lang: Language to add. Can be a language code or name, name or @@ -67,16 +73,18 @@ def add_language(self, lang): Language modules are imported and :class:`Language` subclasses in them loaded. """ - try: - if isinstance(lang, Language): - languages = [lang] - else: - languages = [Language.from_name(lang)] - except ValueError as err1: + if isinstance(lang, Language): + languages = [lang] + elif isinstance(lang, Path) or Path(lang).exists(): + languages = self._import_language_module(Path(lang)) + else: try: - languages = self._import_languages(lang) - except DataError as err2: - raise DataError(f'{err1} {err2}') + languages = [Language.from_name(lang)] + except ValueError as err1: + try: + languages = self._import_language_module(lang) + except DataError as err2: + raise DataError(f'{err1} {err2}') from None for lang in languages: self._add_language(lang) @@ -97,12 +105,14 @@ def _get_languages(self, languages, add_english=True): for lang in languages: if isinstance(lang, Language): returned.append(lang) + elif isinstance(lang, Path): + returned.extend(self._import_language_module(lang)) else: normalized = normalize(lang, ignore='-') if normalized in available: returned.append(available[normalized]()) else: - returned.extend(self._import_languages(lang)) + returned.extend(self._import_language_module(lang)) return returned def _resolve_languages(self, languages, add_english=True): @@ -134,17 +144,19 @@ def _get_available_languages(self): available.pop('') return available - def _import_languages(self, lang): + def _import_language_module(self, name_or_path): def is_language(member): return (inspect.isclass(member) and issubclass(member, Language) and member is not Language) - if os.path.exists(lang): - lang = os.path.abspath(lang) - module = Importer('language file').import_module(lang) + if isinstance(name_or_path, Path): + name_or_path = name_or_path.absolute() + elif Path(name_or_path).exists(): + name_or_path = Path(name_or_path).absolute() + module = Importer('language file').import_module(name_or_path) return [value() for _, value in inspect.getmembers(module, is_language)] - def __iter__(self): + def __iter__(self) -> 'Iterator[Language]': return iter(self.languages) @@ -197,7 +209,7 @@ class Language: false_strings = [] @classmethod - def from_name(cls, name): + def from_name(cls, name) -> 'Language': """Return language class based on given `name`. Name can either be a language name (e.g. 'Finnish' or 'Brazilian Portuguese') @@ -215,7 +227,7 @@ def from_name(cls, name): raise ValueError(f"No language with name '{name}' found.") @classproperty - def code(cls): + def code(cls) -> str: """Language code like 'fi' or 'pt-BR'. Got based on the class name. If the class name is two characters (or less), @@ -232,7 +244,7 @@ def code(cls): return f'{code[:2]}-{code[2:].upper()}' @classproperty - def name(cls): + def name(cls) -> str: """Language name like 'Finnish' or 'Brazilian Portuguese'. Got from the first line of the class docstring. @@ -244,7 +256,7 @@ def name(cls): return cls.__doc__.splitlines()[0] if cls.__doc__ else '' @property - def headers(self): + def headers(self) -> 'dict[str, str]': return { self.settings_header: En.settings_header, self.variables_header: En.variables_header, @@ -255,7 +267,7 @@ def headers(self): } @property - def settings(self): + def settings(self) -> 'dict[str, str]': return { self.library_setting: En.library_setting, self.resource_setting: En.resource_setting, @@ -285,7 +297,7 @@ def settings(self): } @property - def bdd_prefixes(self): + def bdd_prefixes(self) -> 'set[str]': return set(chain(self.given_prefixes, self.when_prefixes, self.then_prefixes, self.and_prefixes, self.but_prefixes)) diff --git a/utest/api/orcish_languages.py b/utest/api/orcish_languages.py index 62b30d5b15e..3e84665a39a 100644 --- a/utest/api/orcish_languages.py +++ b/utest/api/orcish_languages.py @@ -1,9 +1,11 @@ from robot.api import Language + class OrcQui(Language): """Orcish Quiet""" settings_header="Jiivo" + class OrcLou(Language): """Orcish Loud""" settings_header="JIIVA" diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index f8f7b7f061d..35293fbd942 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -1,11 +1,11 @@ import unittest -from os.path import abspath, dirname, join +from pathlib import Path from robot.api import Language, Languages from robot.conf.languages import En, Fi, PtBr, Th from robot.errors import DataError -from robot.utils.asserts import (assert_equal, assert_not_equal, assert_true, +from robot.utils.asserts import (assert_equal, assert_not_equal, assert_raises, assert_raises_with_msg) @@ -114,6 +114,16 @@ def test_init_without_default(self): assert_equal(list(Languages(['fi'], add_english=False)), [Fi()]) assert_equal(list(Languages(['fi', PtBr()], add_english=False)), [Fi(), PtBr()]) + def test_init_with_custom_language(self): + path = Path(__file__).absolute().parent / 'orcish_languages.py' + cwd = Path('.').absolute() + for lang in (path, path.relative_to(cwd), + str(path), str(path.relative_to(cwd)), + [str(path)], [path]): + langs = Languages(lang, add_english=False) + assert_equal([("Orcish Loud", "or-CLOU"), ("Orcish Quiet", "or-CQUI")], + [(v.name, v.code) for v in langs]) + def test_reset(self): langs = Languages(['fi']) langs.reset() @@ -141,19 +151,26 @@ def test_duplicates_are_not_added(self): assert_equal(list(langs), [Fi(), En(), PtBr(), Th()]) def test_add_language_using_custom_module(self): - data = join(abspath(dirname(__file__)), 'orcish_languages.py') - langs = Languages() - langs.add_language(data) - self.assertIn(("Orcish Loud", "or-CLOU"), [(v.name, v.code) for v in langs]) - self.assertIn(("Orcish Quiet", "or-CQUI"), [(v.name, v.code) for v in langs]) + path = Path(__file__).absolute().parent / 'orcish_languages.py' + cwd = Path('.').absolute() + for lang in [path, path.relative_to(cwd), str(path), str(path.relative_to(cwd))]: + langs = Languages(add_english=False) + langs.add_language(lang) + assert_equal([("Orcish Loud", "or-CLOU"), ("Orcish Quiet", "or-CQUI")], + [(v.name, v.code) for v in langs]) def test_add_language_using_invalid_custom_module(self): - with self.assertRaises(DataError) as context: - Languages().add_language('invalid') - assert_true(context.exception.args[0].startswith( - "No language with name 'invalid' found. " - "Importing language file 'invalid' failed: " - )) + error = assert_raises(DataError, Languages().add_language, 'non_existing_a23l4j') + assert_equal(error.message.split(':')[0], + "No language with name 'non_existing_a23l4j' found. " + "Importing language file 'non_existing_a23l4j' failed") + + def test_add_language_using_invalid_custom_module_as_Path(self): + invalid = Path('non_existing_a23l4j') + assert_raises_with_msg(DataError, + f"Importing language file '{invalid.absolute()}' failed: " + f"File or directory does not exist.", + Languages().add_language, invalid) def test_add_language_using_Language_instance(self): languages = Languages(add_english=False) From 479065dfa62e4f0003088842948c76e57fb7daa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 17 Apr 2023 21:15:50 +0300 Subject: [PATCH 0498/1592] Add type hints to parsing API and some other places. get_tokens, get_model, and their resource and init file variants now have type hints (#4740). Parsing model itself still needs type hints. --- src/robot/model/namepatterns.py | 4 +- src/robot/parsing/lexer/lexer.py | 58 ++++++++++++++++------------ src/robot/parsing/lexer/tokenizer.py | 9 +++-- src/robot/parsing/lexer/tokens.py | 48 +++++++++++------------ src/robot/parsing/parser/parser.py | 30 ++++++++------ src/robot/utils/__init__.py | 2 +- src/robot/utils/filereader.py | 45 +++++++++++---------- src/robot/utils/match.py | 10 ++--- 8 files changed, 112 insertions(+), 94 deletions(-) diff --git a/src/robot/model/namepatterns.py b/src/robot/model/namepatterns.py index f025ccbea77..a9a99019c9a 100644 --- a/src/robot/model/namepatterns.py +++ b/src/robot/model/namepatterns.py @@ -13,14 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Iterable, Iterator, Sequence +from typing import Iterable, Iterator from robot.utils import MultiMatcher class NamePatterns(Iterable[str]): - def __init__(self, patterns: Sequence[str] = ()): + def __init__(self, patterns: Iterator[str] = ()): self.matcher = MultiMatcher(patterns, ignore='_') def match(self, name: str, longname: 'str|None' = None) -> bool: diff --git a/src/robot/parsing/lexer/lexer.py b/src/robot/parsing/lexer/lexer.py index 0421d756b9d..02df061bdc2 100644 --- a/src/robot/parsing/lexer/lexer.py +++ b/src/robot/parsing/lexer/lexer.py @@ -13,18 +13,23 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Iterator from itertools import chain +from robot.conf import LanguagesLike from robot.errors import DataError -from robot.utils import get_error_message, FileReader +from robot.utils import get_error_message, FileReader, Source from .blocklexers import FileLexer -from .context import InitFileContext, SuiteFileContext, ResourceFileContext +from .context import (InitFileContext, LexingContext, SuiteFileContext, + ResourceFileContext) from .tokenizer import Tokenizer from .tokens import EOS, END, Token -def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): +def get_tokens(source: Source, data_only: bool = False, + tokenize_variables: bool = False, + lang: LanguagesLike = None) -> 'Iterator[Token]': """Parses the given source to tokens. :param source: The source where to read the data. Can be a path to @@ -40,7 +45,7 @@ def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): method for details. :param lang: Additional languages to be supported during parsing. Can be a string matching any of the supported language codes or names, - an initialized :class:`~robot.conf.languages.Language` subsclass, + an initialized :class:`~robot.conf.languages.Language` subclass, a list containing such strings or instances, or a :class:`~robot.conf.languages.Languages` instance. @@ -52,7 +57,9 @@ def get_tokens(source, data_only=False, tokenize_variables=False, lang=None): return lexer.get_tokens() -def get_resource_tokens(source, data_only=False, tokenize_variables=False, lang=None): +def get_resource_tokens(source: Source, data_only: bool = False, + tokenize_variables: bool = False, + lang: LanguagesLike = None) -> 'Iterator[Token]': """Parses the given source to resource file tokens. Same as :func:`get_tokens` otherwise, but the source is considered to be @@ -63,7 +70,9 @@ def get_resource_tokens(source, data_only=False, tokenize_variables=False, lang= return lexer.get_tokens() -def get_init_tokens(source, data_only=False, tokenize_variables=False, lang=None): +def get_init_tokens(source: Source, data_only: bool = False, + tokenize_variables: bool = False, + lang: LanguagesLike = None) -> 'Iterator[Token]': """Parses the given source to init file tokens. Same as :func:`get_tokens` otherwise, but the source is considered to be @@ -77,15 +86,15 @@ def get_init_tokens(source, data_only=False, tokenize_variables=False, lang=None class Lexer: - def __init__(self, ctx, data_only=False, tokenize_variables=False): + def __init__(self, ctx: LexingContext, data_only: bool = False, + tokenize_variables: bool = False): self.lexer = FileLexer(ctx) self.data_only = data_only self.tokenize_variables = tokenize_variables - self.statements = [] + self.statements: 'list[list[Token]]' = [] - def input(self, source): - for statement in Tokenizer().tokenize(self._read(source), - self.data_only): + def input(self, source: Source): + for statement in Tokenizer().tokenize(self._read(source), self.data_only): # Store all tokens but pass only data tokens to lexer. self.statements.append(statement) if self.data_only: @@ -96,27 +105,28 @@ def input(self, source): if data: self.lexer.input(data) - def _read(self, source): + def _read(self, source: Source) -> str: try: with FileReader(source, accept_text=True) as reader: return reader.read() except Exception: raise DataError(get_error_message()) - def get_tokens(self): + def get_tokens(self) -> 'Iterator[Token]': self.lexer.lex() - statements = self.statements - if not self.data_only: + if self.data_only: + statements = self.statements + else: statements = chain.from_iterable( - self._split_trailing_commented_and_empty_lines(s) - for s in statements + self._split_trailing_commented_and_empty_lines(stmt) + for stmt in self.statements ) tokens = self._get_tokens(statements) if self.tokenize_variables: tokens = self._tokenize_variables(tokens) return tokens - def _get_tokens(self, statements): + def _get_tokens(self, statements: 'list[list[Token]]') -> 'Iterator[Token]': if self.data_only: ignored_types = {None, Token.COMMENT_HEADER, Token.COMMENT} else: @@ -143,7 +153,8 @@ def _get_tokens(self, statements): yield END.from_token(last, virtual=True) yield EOS.from_token(last) - def _split_trailing_commented_and_empty_lines(self, statement): + def _split_trailing_commented_and_empty_lines(self, statement: 'list[Token]') \ + -> 'list[list[Token]]': lines = self._split_to_lines(statement) commented_or_empty = [] for line in reversed(lines): @@ -156,7 +167,7 @@ def _split_trailing_commented_and_empty_lines(self, statement): statement = list(chain.from_iterable(lines)) return [statement] + list(reversed(commented_or_empty)) - def _split_to_lines(self, statement): + def _split_to_lines(self, statement: 'list[Token]') -> 'list[list[Token]]': lines = [] current = [] for token in statement: @@ -168,7 +179,7 @@ def _split_to_lines(self, statement): lines.append(current) return lines - def _is_commented_or_empty(self, line): + def _is_commented_or_empty(self, line: 'list[Token]') -> bool: separator_or_ignore = (Token.SEPARATOR, None) comment_or_eol = (Token.COMMENT, Token.EOL) for token in line: @@ -176,7 +187,6 @@ def _is_commented_or_empty(self, line): return token.type in comment_or_eol return False - def _tokenize_variables(self, tokens): + def _tokenize_variables(self, tokens: 'Iterator[Token]') -> 'Iterator[Token]': for token in tokens: - for t in token.tokenize_variables(): - yield t + yield from token.tokenize_variables() diff --git a/src/robot/parsing/lexer/tokenizer.py b/src/robot/parsing/lexer/tokenizer.py index a32edc40a6f..d1592628238 100644 --- a/src/robot/parsing/lexer/tokenizer.py +++ b/src/robot/parsing/lexer/tokenizer.py @@ -14,6 +14,7 @@ # limitations under the License. import re +from collections.abc import Iterator from .tokens import Token @@ -22,7 +23,7 @@ class Tokenizer: _space_splitter = re.compile(r'(\s{2,}|\t)', re.UNICODE) _pipe_splitter = re.compile(r'((?:\A|\s+)\|(?:\s+|\Z))', re.UNICODE) - def tokenize(self, data, data_only=False): + def tokenize(self, data: str, data_only: bool = False) -> 'Iterator[list[Token]]': current = [] for lineno, line in enumerate(data.splitlines(not data_only), start=1): tokens = self._tokenize_line(line, lineno, not data_only) @@ -35,7 +36,7 @@ def tokenize(self, data, data_only=False): current.extend(tokens) yield current - def _tokenize_line(self, line, lineno, include_separators=True): + def _tokenize_line(self, line: str, lineno: int, include_separators: bool): # Performance optimized code. tokens = [] append = tokens.append @@ -55,13 +56,13 @@ def _tokenize_line(self, line, lineno, include_separators=True): append(Token(Token.EOL, trailing_whitespace, lineno, offset)) return tokens - def _split_from_spaces(self, line): + def _split_from_spaces(self, line: str) -> 'Iterator[tuple[str, bool]]': is_data = True for value in self._space_splitter.split(line): yield value, is_data is_data = not is_data - def _split_from_pipes(self, line): + def _split_from_pipes(self, line) -> 'Iterator[tuple[str, bool]]': splitter = self._pipe_splitter _, separator, rest = splitter.split(line, 1) yield separator, False diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 2412d399811..d71035bb939 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Iterator + from robot.variables import VariableIterator @@ -26,16 +28,13 @@ class Token: Token types are declared as class attributes such as :attr:`SETTING_HEADER` and :attr:`EOL`. Values of these constants have changed slightly in Robot - Framework 4.0 and they may change again in the future. It is thus safer + Framework 4.0, and they may change again in the future. It is thus safer to use the constants, not their values, when types are needed. For example, use ``Token(Token.EOL)`` instead of ``Token('EOL')`` and ``token.type == Token.EOL`` instead of ``token.type == 'EOL'``. - If :attr:`value` is not given when :class:`Token` is initialized and - :attr:`type` is :attr:`IF`, :attr:`ELSE_IF`, :attr:`ELSE`, :attr:`FOR`, - :attr:`END`, :attr:`WITH_NAME` or :attr:`CONTINUATION`, the value is - automatically set to the correct marker value like ``'IF'`` or ``'ELSE IF'``. - If :attr:`type` is :attr:`EOL` in this case, the value is set to ``'\\n'``. + If :attr:`value` is not given and :attr:`type` is a special marker like + :attr:`IF` or `:attr:`EOL`, the value is set automatically. """ SETTING_HEADER = 'SETTING HEADER' @@ -155,11 +154,11 @@ class Token: TESTCASE_NAME, KEYWORD_NAME )) - __slots__ = ['type', 'value', 'lineno', 'col_offset', 'error', '_add_eos_before', '_add_eos_after'] - def __init__(self, type=None, value=None, lineno=-1, col_offset=-1, error=None): + def __init__(self, type: 'str|None' = None, value: 'str|None' = None, + lineno: int = -1, col_offset: int = -1, error: 'str|None' = None): self.type = type if value is None: value = { @@ -179,21 +178,21 @@ def __init__(self, type=None, value=None, lineno=-1, col_offset=-1, error=None): self._add_eos_after = False @property - def end_col_offset(self): + def end_col_offset(self) -> int: if self.col_offset == -1: return -1 return self.col_offset + len(self.value) - def set_error(self, error): + def set_error(self, error: str): self.type = Token.ERROR self.error = error - def tokenize_variables(self): + def tokenize_variables(self) -> 'Iterator[Token]': """Tokenizes possible variables in token value. Yields the token itself if the token does not allow variables (see :attr:`Token.ALLOW_VARIABLES`) or its value does not contain - variables. Otherwise yields variable tokens as well as tokens + variables. Otherwise, yields variable tokens as well as tokens before, after, or between variables so that they have the same type as the original token. """ @@ -220,16 +219,15 @@ def _tokenize_variables(self, variables): if remaining: yield Token(self.type, remaining, lineno, col_offset) - def __str__(self): + def __str__(self) -> str: return self.value - def __repr__(self): - type_ = self.type.replace(' ', '_') if self.type else 'None' - error = '' if not self.error else ', %r' % self.error - return 'Token(%s, %r, %s, %s%s)' % (type_, self.value, self.lineno, - self.col_offset, error) + def __repr__(self) -> str: + typ = self.type.replace(' ', '_') if self.type else 'None' + error = '' if not self.error else f', {self.error!r}' + return f'Token({typ}, {self.value!r}, {self.lineno}, {self.col_offset}{error})' - def __eq__(self, other): + def __eq__(self, other) -> bool: return (isinstance(other, Token) and self.type == other.type and self.value == other.value @@ -242,13 +240,13 @@ class EOS(Token): """Token representing end of a statement.""" __slots__ = [] - def __init__(self, lineno=-1, col_offset=-1): + def __init__(self, lineno: int = -1, col_offset: int = -1): super().__init__(Token.EOS, '', lineno, col_offset) @classmethod - def from_token(cls, token, before=False): + def from_token(cls, token: Token, before: bool = False) -> 'EOS': col_offset = token.col_offset if before else token.end_col_offset - return EOS(token.lineno, col_offset) + return cls(token.lineno, col_offset) class END(Token): @@ -259,10 +257,10 @@ class END(Token): """ __slots__ = [] - def __init__(self, lineno=-1, col_offset=-1, virtual=False): + def __init__(self, lineno: int = -1, col_offset: int = -1, virtual: bool = False): value = 'END' if not virtual else '' super().__init__(Token.END, value, lineno, col_offset) @classmethod - def from_token(cls, token, virtual=False): - return END(token.lineno, token.end_col_offset, virtual) + def from_token(cls, token: Token, virtual: bool = False) -> 'END': + return cls(token.lineno, token.end_col_offset, virtual) diff --git a/src/robot/parsing/parser/parser.py b/src/robot/parsing/parser/parser.py index 458f0d34dd4..e6ba49a6e40 100644 --- a/src/robot/parsing/parser/parser.py +++ b/src/robot/parsing/parser/parser.py @@ -13,14 +13,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from ..lexer import Token, get_tokens, get_resource_tokens, get_init_tokens -from ..model import Statement, ModelVisitor +from robot.conf import LanguagesLike +from robot.utils import Source + +from ..lexer import get_init_tokens, get_resource_tokens, get_tokens, Token +from ..model import File, ModelVisitor, Statement from .fileparser import FileParser -def get_model(source, data_only=False, curdir=None, lang=None): - """Parses the given source to a model represented as an AST. +def get_model(source: Source, data_only: bool = False, curdir: 'str|None' = None, + lang: LanguagesLike = None) -> File: + """Parses the given source into a model represented as an AST. How to use the model is explained more thoroughly in the general documentation of the :mod:`robot.parsing` module. @@ -36,11 +40,11 @@ def get_model(source, data_only=False, curdir=None, lang=None): :param curdir: Directory where the source file exists. This path is used to set the value of the built-in ``${CURDIR}`` variable during parsing. When not given, the variable is left as-is. Should only be given - only if the model will be executed afterwards. If the model is saved + only if the model will be executed afterward. If the model is saved back to disk, resolving ``${CURDIR}`` is typically not a good idea. :param lang: Additional languages to be supported during parsing. Can be a string matching any of the supported language codes or names, - an initialized :class:`~robot.conf.languages.Language` subsclass, + an initialized :class:`~robot.conf.languages.Language` subclass, a list containing such strings or instances, or a :class:`~robot.conf.languages.Languages` instance. @@ -50,19 +54,21 @@ def get_model(source, data_only=False, curdir=None, lang=None): return _get_model(get_tokens, source, data_only, curdir, lang) -def get_resource_model(source, data_only=False, curdir=None, lang=None): - """Parses the given source to a resource file model. +def get_resource_model(source: Source, data_only: bool = False, + curdir: 'str|None' = None, lang: LanguagesLike = None) -> File: + """Parses the given source into a resource file model. - Otherwise same as :func:`get_model` but the source is considered to be + Same as :func:`get_model` otherwise, but the source is considered to be a resource file. This affects, for example, what settings are valid. """ return _get_model(get_resource_tokens, source, data_only, curdir, lang) -def get_init_model(source, data_only=False, curdir=None, lang=None): - """Parses the given source to a init file model. +def get_init_model(source: Source, data_only: bool = False, curdir: 'str|None' = None, + lang: LanguagesLike = None) -> File: + """Parses the given source into an init file model. - Otherwise same as :func:`get_model` but the source is considered to be + Same as :func:`get_model` otherwise, but the source is considered to be a suite initialization file. This affects, for example, what settings are valid. """ diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index e878b7cbd5e..4f1641cdd5a 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -43,7 +43,7 @@ from .error import (get_error_message, get_error_details, ErrorDetails) from .escaping import escape, glob_escape, unescape, split_from_equals from .etreewrapper import ET, ETSource -from .filereader import FileReader +from .filereader import FileReader, Source from .frange import frange from .markuputils import html_format, html_escape, xml_escape, attribute_escape from .markupwriters import HtmlWriter, XmlWriter, NullMarkupWriter diff --git a/src/robot/utils/filereader.py b/src/robot/utils/filereader.py index ba1132a1ea9..8532c584c9a 100644 --- a/src/robot/utils/filereader.py +++ b/src/robot/utils/filereader.py @@ -13,20 +13,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -from io import StringIO -import os.path +from collections.abc import Iterator +from io import IOBase, StringIO +from pathlib import Path +from typing import Union from .robottypes import is_bytes, is_pathlike, is_string -class FileReader: - """Utility to ease reading different kind of files. +Source = Union[Path, str, IOBase] + + +class FileReader: # FIXME: Rename to SourceReader + """Utility to ease reading different kind of source files. Supports different sources where to read the data: - The source can be a path to a file, either as a string or as a - ``pathlib.Path`` instance in Python 3. The file itself must be - UTF-8 encoded. + ``pathlib.Path`` instance. The file itself must be UTF-8 encoded. - Alternatively the source can be an already opened file object, including a StringIO or BytesIO object. The file can contain either @@ -39,10 +43,10 @@ class FileReader: BOM removed. """ - def __init__(self, source, accept_text=False): - self.file, self.name, self._opened = self._get_file(source, accept_text) + def __init__(self, source: Source, accept_text: bool = False): + self.file, self._opened = self._get_file(source, accept_text) - def _get_file(self, source, accept_text): + def _get_file(self, source: Source, accept_text: bool) -> 'tuple[IOBase, bool]': path = self._get_path(source, accept_text) if path: file = open(path, 'rb') @@ -53,10 +57,9 @@ def _get_file(self, source, accept_text): else: file = source opened = False - name = getattr(file, 'name', '<in-memory file>') - return file, name, opened + return file, opened - def _get_path(self, source, accept_text): + def _get_path(self, source: Source, accept_text: bool): if is_pathlike(source): return str(source) if not is_string(source): @@ -65,10 +68,15 @@ def _get_path(self, source, accept_text): return source if '\n' in source: return None - if os.path.isabs(source) or os.path.exists(source): + path = Path(source) + if path.is_absolute() or path.exists(): return source return None + @property + def name(self) -> str: + return getattr(self.file, 'name', '<in-memory file>') + def __enter__(self): return self @@ -76,16 +84,16 @@ def __exit__(self, *exc_info): if self._opened: self.file.close() - def read(self): + def read(self) -> str: return self._decode(self.file.read()) - def readlines(self): + def readlines(self) -> 'Iterator[str]': first_line = True for line in self.file.readlines(): yield self._decode(line, remove_bom=first_line) first_line = False - def _decode(self, content, remove_bom=True): + def _decode(self, content: 'str|bytes', remove_bom: bool = True) -> str: if is_bytes(content): content = content.decode('UTF-8') if remove_bom and content.startswith('\ufeff'): @@ -93,8 +101,3 @@ def _decode(self, content, remove_bom=True): if '\r\n' in content: content = content.replace('\r\n', '\n') return content - - def _is_binary_file(self): - mode = getattr(self.file, 'mode', '') - encoding = getattr(self.file, 'encoding', 'ascii').lower() - return 'r' in mode and encoding == 'ascii' diff --git a/src/robot/utils/match.py b/src/robot/utils/match.py index d941e438843..0a42f35f9c0 100644 --- a/src/robot/utils/match.py +++ b/src/robot/utils/match.py @@ -55,18 +55,18 @@ def __bool__(self) -> bool: class MultiMatcher(Iterable[Matcher]): - def __init__(self, patterns: Sequence[str] = (), ignore: Sequence[str] = (), + def __init__(self, patterns: Iterable[str] = (), ignore: Sequence[str] = (), caseless: bool = True, spaceless: bool = True, match_if_no_patterns: bool = False, regexp: bool = False): self.matchers = [Matcher(pattern, ignore, caseless, spaceless, regexp) - for pattern in self._ensure_list(patterns)] + for pattern in self._ensure_iterable(patterns)] self.match_if_no_patterns = match_if_no_patterns - def _ensure_list(self, patterns): + def _ensure_iterable(self, patterns): if patterns is None: - return [] + return () if is_string(patterns): - return [patterns] + return (patterns,) return patterns def match(self, string: str) -> bool: From e82b1546fe880b95961cf7577185e07bce60087f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 17 Apr 2023 21:18:34 +0300 Subject: [PATCH 0499/1592] Performance optimization for tokenizing data rows. Only affects parsing with `data_only=True`. --- src/robot/parsing/lexer/tokenizer.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/robot/parsing/lexer/tokenizer.py b/src/robot/parsing/lexer/tokenizer.py index d1592628238..e16bfd8873c 100644 --- a/src/robot/parsing/lexer/tokenizer.py +++ b/src/robot/parsing/lexer/tokenizer.py @@ -73,7 +73,8 @@ def _split_from_pipes(self, line) -> 'Iterator[tuple[str, bool]]': yield rest, True def _cleanup_tokens(self, tokens, data_only): - has_data, continues = self._handle_comments_and_continuation(tokens) + has_data, has_comments, continues \ + = self._handle_comments_and_continuation(tokens) self._remove_trailing_empty(tokens) if continues: self._remove_leading_empty(tokens) @@ -82,19 +83,19 @@ def _cleanup_tokens(self, tokens, data_only): starts_new = False else: starts_new = has_data - if data_only: - tokens = self._remove_non_data(tokens) + if data_only and (has_comments or continues): + tokens = [t for t in tokens if t.type is None] return tokens, starts_new def _handle_comments_and_continuation(self, tokens): has_data = False - continues = False commented = False - for token in tokens: + continues = False + for index, token in enumerate(tokens): if token.type is None: # lstrip needed to strip possible leading space from first token. # Other leading/trailing spaces have been consumed as separators. - value = token.value.lstrip() + value = token.value if index else token.value.lstrip() if commented: token.type = Token.COMMENT elif value: @@ -107,7 +108,7 @@ def _handle_comments_and_continuation(self, tokens): continues = True else: has_data = True - return has_data, continues + return has_data, commented, continues def _remove_trailing_empty(self, tokens): for token in reversed(tokens): @@ -133,6 +134,3 @@ def _find_continuation(self, tokens): for token in tokens: if token.type == Token.CONTINUATION: return token - - def _remove_non_data(self, tokens): - return [t for t in tokens if t.type is None] From 903575197e6b42c1142a541f4594ad428fb32973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 17 Apr 2023 22:50:50 +0300 Subject: [PATCH 0500/1592] Refactor building executable suite. Main motivation is making it easier to add support for custom parsers (#1283). Includes lot of type hints. --- src/robot/parsing/__init__.py | 2 +- src/robot/parsing/suitestructure.py | 87 +++++++-------- src/robot/running/builder/builders.py | 146 ++++++++++++++------------ src/robot/running/builder/parsers.py | 81 +++++++------- 4 files changed, 157 insertions(+), 159 deletions(-) diff --git a/src/robot/parsing/__init__.py b/src/robot/parsing/__init__.py index ff5930ac743..556089341c2 100644 --- a/src/robot/parsing/__init__.py +++ b/src/robot/parsing/__init__.py @@ -24,4 +24,4 @@ from .lexer import get_tokens, get_resource_tokens, get_init_tokens, Token from .model import File, ModelTransformer, ModelVisitor from .parser import get_model, get_resource_model, get_init_model -from .suitestructure import SuiteStructureBuilder, SuiteStructureVisitor +from .suitestructure import SuiteStructure, SuiteStructureBuilder, SuiteStructureVisitor diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 356156ea3c0..7467f503dbb 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -13,37 +13,36 @@ # See the License for the specific language governing permissions and # limitations under the License. -from os.path import normpath from pathlib import Path -from typing import List +from typing import Iterable from robot.errors import DataError from robot.model import SuiteNamePatterns from robot.output import LOGGER -from robot.utils import get_error_message, seq2str +from robot.utils import get_error_message class SuiteStructure: def __init__(self, source: Path = None, init_file: Path = None, - children: List['SuiteStructure'] = None): + children: 'list[SuiteStructure]|None' = None): self.source = source self.init_file = init_file self.children = children @property - def extension(self): + def extension(self) -> 'str|None': source = self.source if self.is_file else self.init_file return source.suffix[1:].lower() if source else None @property - def is_file(self): + def is_file(self) -> bool: return self.children is None def add(self, child: 'SuiteStructure'): self.children.append(child) - def visit(self, visitor): + def visit(self, visitor: 'SuiteStructureVisitor'): if self.children is None: visitor.visit_file(self) else: @@ -52,19 +51,19 @@ def visit(self, visitor): class SuiteStructureVisitor: - def visit_file(self, structure): + def visit_file(self, structure: SuiteStructure): pass - def visit_directory(self, structure): + def visit_directory(self, structure: SuiteStructure): self.start_directory(structure) for child in structure.children: child.visit(self) self.end_directory(structure) - def start_directory(self, structure): + def start_directory(self, structure: SuiteStructure): pass - def end_directory(self, structure): + def end_directory(self, structure: SuiteStructure): pass @@ -72,10 +71,12 @@ class SuiteStructureBuilder: ignored_prefixes = ('_', '.') ignored_dirs = ('CVS',) - def __init__(self, included_extensions=('.robot', '.rbt'), included_suites=None): - self.included_extensions = included_extensions - self.included_suites = None if not included_suites else \ - SuiteNamePatterns(self._create_included_suites(included_suites)) + def __init__(self, extensions: Iterable[str] = ('.robot', '.rbt'), + included_suites: Iterable[str] = ()): + self.extensions = {'.' + ext.lstrip('.').lower() for ext in extensions} + self.included_suites = SuiteNamePatterns( + self._create_included_suites(included_suites) + ) def _create_included_suites(self, included_suites): for suite in included_suites: @@ -84,78 +85,64 @@ def _create_included_suites(self, included_suites): suite = suite.split('.', 1)[1] yield suite - def build(self, paths): - paths = list(self._normalize_paths(paths)) + def build(self, *paths: Path) -> SuiteStructure: if len(paths) == 1: return self._build(paths[0], self.included_suites) return self._build_multi_source(paths) - def _normalize_paths(self, paths): - if not paths: - raise DataError('One or more source paths required.') - # Cannot use `Path.resolve()` here because it resolves all symlinks which - # isn't desired. `Path` doesn't have any methods for normalizing paths - # so need to use `os.path.normpath()`. Also that _may_ resolve symlinks, - # but we need to do it for backwards compatibility. - paths = [Path(normpath(p)).absolute() for p in paths] - non_existing = [p for p in paths if not p.exists()] - if non_existing: - raise DataError(f"Parsing {seq2str(non_existing)} failed: " - f"File or directory to execute does not exist.") - return paths - - def _build(self, path, included_suites): + def _build(self, path: Path, included_suites: SuiteNamePatterns) -> SuiteStructure: if path.is_file(): return SuiteStructure(path) return self._build_directory(path, included_suites) - def _build_directory(self, dir_path, included_suites): - structure = SuiteStructure(dir_path, children=[]) + def _build_directory(self, path: Path, + included_suites: SuiteNamePatterns) -> SuiteStructure: + structure = SuiteStructure(path, children=[]) # If a directory is included, also its children are included. - if self._is_suite_included(dir_path.name, included_suites): - included_suites = None - for path in self._list_dir(dir_path): - if self._is_init_file(path): + if self._is_suite_included(path.name, included_suites): + included_suites = SuiteNamePatterns() + for item in self._list_dir(path): + if self._is_init_file(item): if structure.init_file: - LOGGER.error(f"Ignoring second test suite init file '{path}'.") + LOGGER.error(f"Ignoring second test suite init file '{item}'.") else: - structure.init_file = path - elif self._is_included(path, included_suites): - structure.add(self._build(path, included_suites)) + structure.init_file = item + elif self._is_included(item, included_suites): + structure.add(self._build(item, included_suites)) else: - LOGGER.info(f"Ignoring file or directory '{path}'.") + LOGGER.info(f"Ignoring file or directory '{item}'.") return structure - def _is_suite_included(self, name, included_suites): + def _is_suite_included(self, name: str, included_suites: SuiteNamePatterns) -> bool: if not included_suites: return True if '__' in name: name = name.split('__', 1)[1] or name return included_suites.match(name) - def _list_dir(self, path): + def _list_dir(self, path: Path) -> 'list[Path]': try: return sorted(path.iterdir(), key=lambda p: p.name.lower()) except OSError: raise DataError(f"Reading directory '{path}' failed: {get_error_message()}") - def _is_init_file(self, path: Path): + def _is_init_file(self, path: Path) -> bool: return (path.stem.lower() == '__init__' - and path.suffix.lower() in self.included_extensions + and path.suffix.lower() in self.extensions and path.is_file()) - def _is_included(self, path: Path, included_suites): + def _is_included(self, path: Path, included_suites: SuiteNamePatterns) -> bool: if path.name.startswith(self.ignored_prefixes): return False if path.is_dir(): return path.name not in self.ignored_dirs if not path.is_file(): return False - if path.suffix.lower() not in self.included_extensions: + if path.suffix.lower() not in self.extensions: return False return self._is_suite_included(path.stem, included_suites) - def _build_multi_source(self, paths: List[Path]): + def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: structure = SuiteStructure(children=[]) for path in paths: if self._is_init_file(path): diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 75e8369513d..de52004c513 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -13,13 +13,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +from os.path import normpath from pathlib import Path +from typing import Sequence +from robot.conf import LanguagesLike from robot.errors import DataError from robot.output import LOGGER -from robot.parsing import SuiteStructureBuilder, SuiteStructureVisitor +from robot.parsing import SuiteStructure, SuiteStructureBuilder, SuiteStructureVisitor +from robot.utils import seq2str -from .parsers import JsonParser, RobotParser, NoInitFileDirectoryParser, RestParser +from ..model import ResourceFile, TestSuite +from .parsers import (JsonParser, NoInitFileDirectoryParser, Parser, RestParser, + RobotParser) from .settings import Defaults @@ -47,21 +53,23 @@ class TestSuiteBuilder: classmethod that uses this class internally. """ - def __init__(self, included_suites=None, included_extensions=('.robot', '.rbt'), - rpa=None, lang=None, allow_empty_suite=False, process_curdir=True): + def __init__(self, included_suites: Sequence[str] = (), + included_extensions: Sequence[str] = ('.robot', '.rbt'), + rpa: 'bool|None' = None, lang: LanguagesLike = None, + allow_empty_suite: bool = False, process_curdir: bool = True): """ :param include_suites: - List of suite names to include. If ``None`` or an empty list, all - suites are included. Same as using `--suite` on the command line. + List of suite names to include. If not given, all suites are included. + Same as using `--suite` on the command line. :param included_extensions: List of extensions of files to parse. Same as `--extension`. - :param rpa: Explicit test execution mode. ``True`` for RPA and + :param rpa: Explicit execution mode. ``True`` for RPA and ``False`` for test automation. By default, mode is got from data file headers and possible conflicting headers cause an error. Same as `--rpa` or `--norpa`. :param lang: Additional languages to be supported during parsing. Can be a string matching any of the supported language codes or names, - an initialized :class:`~robot.conf.languages.Language` subsclass, + an initialized :class:`~robot.conf.languages.Language` subclass, a list containing such strings or instances, or a :class:`~robot.conf.languages.Languages` instance. :param allow_empty_suite: @@ -72,85 +80,89 @@ def __init__(self, included_suites=None, included_extensions=('.robot', '.rbt'), resolved already at parsing time by default, but that can be changed by giving this argument ``False`` value. """ + robot_parser = RobotParser(lang, process_curdir) + rest_parser = RestParser(lang, process_curdir) + json_parser = JsonParser() + self.standard_parsers = { + 'robot': robot_parser, + 'rst': rest_parser, + 'rest': rest_parser, + 'rbt': json_parser, + 'json': json_parser + } + self.included_suites = tuple(included_suites or ()) + self.included_extensions = tuple(included_extensions) self.rpa = rpa - self.lang = lang - self.included_suites = included_suites - self.included_extensions = included_extensions self.allow_empty_suite = allow_empty_suite - self.process_curdir = process_curdir - def build(self, *paths): + def build(self, *paths: 'Path|str'): """ :param paths: Paths to test data files or directories. :return: :class:`~robot.running.model.TestSuite` instance. """ + paths = self._normalize_paths(paths) + parsers = self._get_parsers(self.included_extensions, paths) structure = SuiteStructureBuilder(self.included_extensions, - self.included_suites).build(paths) - parser = SuiteStructureParser(self.included_extensions, - self.rpa, self.lang, self.process_curdir) - suite = parser.parse(structure) + self.included_suites).build(*paths) + suite = SuiteStructureParser(parsers, self.rpa).parse(structure) if not self.included_suites and not self.allow_empty_suite: - self._validate_test_counts(suite, multisource=len(paths) > 1) + self._validate_not_empty(suite, multi_source=len(paths) > 1) suite.remove_empty_suites(preserve_direct_children=len(paths) > 1) return suite - def _validate_test_counts(self, suite, multisource=False): - def validate(suite): - if not suite.has_tests: - raise DataError(f"Suite '{suite.name}' contains no tests or tasks.") - if not multisource: - validate(suite) - else: - for s in suite.suites: - validate(s) + def _normalize_paths(self, paths: 'tuple[Path|str]') -> 'tuple[Path]': + if not paths: + raise DataError('One or more source paths required.') + # Cannot use `Path.resolve()` here because it resolves all symlinks which + # isn't desired. `Path` doesn't have any methods for normalizing paths + # so need to use `os.path.normpath()`. Also that _may_ resolve symlinks, + # but we need to do it for backwards compatibility. + paths = tuple(Path(normpath(p)).absolute() for p in paths) + non_existing = [p for p in paths if not p.exists()] + if non_existing: + raise DataError(f"Parsing {seq2str(non_existing)} failed: " + f"File or directory to execute does not exist.") + return paths + + def _get_parsers(self, extensions: 'tuple[str]', paths: 'tuple[Path]'): + parsers = {None: NoInitFileDirectoryParser()} + robot_parser = self.standard_parsers['robot'] + for ext in extensions + tuple(p.suffix for p in paths if p.is_file()): + ext = ext.lstrip('.').lower() + parsers[ext] = self.standard_parsers.get(ext, robot_parser) + return parsers + + def _validate_not_empty(self, suite: TestSuite, multi_source: bool = False): + if multi_source: + for child in suite.suites: + self._validate_not_empty(child) + elif not suite.has_tests: + raise DataError(f"Suite '{suite.name}' contains no tests or tasks.") class SuiteStructureParser(SuiteStructureVisitor): - def __init__(self, included_extensions, rpa=None, lang=None, process_curdir=True): + def __init__(self, parsers: 'dict[str, Parser]', rpa: 'bool|None' = None): + self.parsers = parsers self.rpa = rpa self._rpa_given = rpa is not None - self.suite = None - self._stack = [] - self.parsers = self._get_parsers(included_extensions, lang, process_curdir) - - def _get_parsers(self, extensions, lang, process_curdir): - robot_parser = RobotParser(lang, process_curdir) - rest_parser = RestParser(lang, process_curdir) - json_parser = JsonParser() - parsers = { - None: NoInitFileDirectoryParser(), - 'robot': robot_parser, - 'rst': rest_parser, - 'rest': rest_parser, - 'rbt': json_parser, - 'json': json_parser - } - for ext in extensions: - if ext not in parsers: - parsers[ext] = robot_parser - return parsers - - def _get_parser(self, extension): - try: - return self.parsers[extension] - except KeyError: - return self.parsers['robot'] + self.suite: 'TestSuite|None' = None + self._stack: 'list[tuple[TestSuite, Defaults]]' = [] - def parse(self, structure): + def parse(self, structure: SuiteStructure) -> TestSuite: structure.visit(self) self.suite.rpa = self.rpa return self.suite - def visit_file(self, structure): + def visit_file(self, structure: SuiteStructure): LOGGER.info(f"Parsing file '{structure.source}'.") suite, _ = self._build_suite(structure) - if self._stack: - self._stack[-1][0].suites.append(suite) - else: + if self.suite is None: self.suite = suite + else: + self._stack[-1][0].suites.append(suite) - def start_directory(self, structure): + def start_directory(self, structure: SuiteStructure): if structure.source: LOGGER.info(f"Parsing directory '{structure.source}'.") suite, defaults = self._build_suite(structure) @@ -160,16 +172,16 @@ def start_directory(self, structure): self._stack[-1][0].suites.append(suite) self._stack.append((suite, defaults)) - def end_directory(self, structure): + def end_directory(self, structure: SuiteStructure): suite, _ = self._stack.pop() if suite.rpa is None and suite.suites: suite.rpa = suite.suites[0].rpa - def _build_suite(self, structure): + def _build_suite(self, structure: SuiteStructure) -> 'tuple[TestSuite, Defaults]': parent_defaults = self._stack[-1][-1] if self._stack else None source = structure.source defaults = Defaults(parent_defaults) - parser = self._get_parser(structure.extension) + parser = self.parsers[structure.extension] try: if structure.is_file: suite = parser.parse_suite_file(source, defaults) @@ -184,7 +196,7 @@ def _build_suite(self, structure): raise DataError(f"Parsing '{source}' failed: {err.message}") return suite, defaults - def _validate_execution_mode(self, suite): + def _validate_execution_mode(self, suite: TestSuite): if self._rpa_given: suite.rpa = self.rpa elif suite.rpa is None: @@ -201,11 +213,11 @@ def _validate_execution_mode(self, suite): class ResourceFileBuilder: - def __init__(self, lang=None, process_curdir=True): + def __init__(self, lang: LanguagesLike = None, process_curdir: bool = True): self.lang = lang self.process_curdir = process_curdir - def build(self, source: Path): + def build(self, source: Path) -> ResourceFile: if not isinstance(source, Path): source = Path(source) LOGGER.info(f"Parsing resource file '{source}'.") @@ -217,7 +229,7 @@ def build(self, source: Path): LOGGER.warn(f"Imported resource file '{source}' is empty.") return resource - def _parse(self, source): + def _parse(self, source: Path) -> ResourceFile: if source.suffix.lower() in ('.rst', '.rest'): return RestParser(self.lang, self.process_curdir).parse_resource_file(source) return RobotParser(self.lang, self.process_curdir).parse_resource_file(source) diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 6c7a86617fd..6a3b564f833 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -13,9 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC from pathlib import Path -from robot.parsing import get_init_model, get_model, get_resource_model +from robot.conf import Languages +from robot.parsing import File, get_init_model, get_model, get_resource_model from robot.utils import FileReader, read_rest_data from .settings import Defaults @@ -23,55 +25,52 @@ from ..model import ResourceFile, TestSuite -class BaseParser: +class Parser(ABC): - def parse_init_file(self, source: Path, defaults: Defaults = None): - raise NotImplementedError + def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: + raise TypeError(f'{type(self).__name__} does not support suite files') - def parse_suite_file(self, source: Path, defaults: Defaults = None): - raise NotImplementedError + def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + raise TypeError(f'{type(self).__name__} does not support initialization files') - def parse_resource_file(self, source: Path): - raise NotImplementedError + def parse_resource_file(self, source: Path) -> ResourceFile: + raise TypeError(f'{type(self).__name__} does not support resource files') -class RobotParser(BaseParser): +class RobotParser(Parser): - def __init__(self, lang=None, process_curdir=True): + def __init__(self, lang: Languages = None, process_curdir: bool = True): self.lang = lang self.process_curdir = process_curdir - def parse_init_file(self, source, defaults=None): - directory = source.parent - name = TestSuite.name_from_source(directory) - suite = TestSuite(name=name, source=directory) - return self._build(suite, source, defaults, get_model=get_init_model) + def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: + model = get_model(self._get_source(source), data_only=True, + curdir=self._get_curdir(source), lang=self.lang) + suite = TestSuite(name=TestSuite.name_from_source(source), source=source) + SuiteBuilder(suite, defaults).build(model) + return suite - def parse_suite_file(self, source, defaults=None): - name = TestSuite.name_from_source(source) - suite = TestSuite(name=name, source=source) - return self._build(suite, source, defaults) + def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + model = get_init_model(self._get_source(source), data_only=True, + curdir=self._get_curdir(source), lang=self.lang) + directory = source.parent + suite = TestSuite(name=TestSuite.name_from_source(directory), source=directory) + SuiteBuilder(suite, defaults).build(model) + return suite - def parse_model(self, model, defaults=None): + def parse_model(self, model: File) -> TestSuite: source = model.source - name = TestSuite.name_from_source(source) - suite = TestSuite(name=name, source=source) - return self._build(suite, source, defaults, model) - - def _build(self, suite, source, defaults, model=None, get_model=get_model): - if model is None: - model = get_model(self._get_source(source), data_only=True, - curdir=self._get_curdir(source), lang=self.lang) - SuiteBuilder(suite, defaults).build(model) + suite = TestSuite(name=TestSuite.name_from_source(source), source=source) + SuiteBuilder(suite).build(model) return suite - def _get_curdir(self, source): + def _get_curdir(self, source: Path) -> 'str|None': return str(source.parent).replace('\\', '\\\\') if self.process_curdir else None - def _get_source(self, source): + def _get_source(self, source: Path) -> 'Path|str': return source - def parse_resource_file(self, source): + def parse_resource_file(self, source: Path) -> ResourceFile: model = get_resource_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) resource = ResourceFile(source=source) @@ -81,25 +80,25 @@ def parse_resource_file(self, source): class RestParser(RobotParser): - def _get_source(self, source): + def _get_source(self, source: Path) -> str: with FileReader(source) as reader: return read_rest_data(reader) -class JsonParser(BaseParser): +class JsonParser(Parser): - def parse_suite_file(self, source: Path, defaults: Defaults = None): + def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: return TestSuite.from_json(source) - def parse_init_file(self, source: Path, defaults: Defaults = None): + def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: return TestSuite.from_json(source) - def parse_resource_file(self, source: Path): + # FIXME: Resource imports don't otherwise support JSON yet! + def parse_resource_file(self, source: Path) -> ResourceFile: return ResourceFile.from_json(source) -class NoInitFileDirectoryParser(BaseParser): +class NoInitFileDirectoryParser(Parser): - def parse_init_file(self, source, defaults=None): - name = TestSuite.name_from_source(source) - return TestSuite(name=name, source=source) + def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + return TestSuite(name=TestSuite.name_from_source(source), source=source) From d973fb86b34f7290ccc2377889415698d527f3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 21 Apr 2023 14:22:02 +0300 Subject: [PATCH 0501/1592] Initial custom parser implementation (#1283). The code works and the code itself and the new API also look pretty good for me. There is, however, plenty of work still: - Acceptance tests. My initial tests work well, but they neeed to be converted to proper acceptance tests. - Documentation in source (--help, API docs, ...). I added initial API docs but there are some holes and --help text is completely missing. I added FIXMEs to places where more docs are needed. - User Guide documentation. - `Defaults` API. We pass `Defaults` as the second argument to `parse` and `parse_init`, but that class itself doesn't have too good API. It needs to be enhanced and documented (incl. types) properly. The class could possibly also get a better name and it needs to be exposes directly via `robot.running`. Although the feature is still work-in-progress, I commit it now to make it possible for others to test the new API. It can the be still enhanced based on feedback. --- src/robot/api/interfaces.py | 52 ++++++++++++++++++++++-- src/robot/conf/settings.py | 8 +++- src/robot/run.py | 6 ++- src/robot/running/builder/builders.py | 56 +++++++++++++++++++------- src/robot/running/builder/parsers.py | 58 +++++++++++++++++++++++++-- 5 files changed, 154 insertions(+), 26 deletions(-) diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 8ac095f2eb5..00b78f8b3c3 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Optional base classes for libraries and listeners. +"""Optional base classes for libraries and other extensions. Module contents: @@ -21,6 +21,7 @@ - :class:`HybridLibrary` for libraries using the `hybrid library API`__. - :class:`ListenerV2` for `listener interface version 2`__. - :class:`ListenerV3` for `listener interface version 3`__. +- :class:`Parser` for `custom parsers`__. - Type definitions used by the aforementioned classes. Main benefit of using these base classes is that editors can provide automatic @@ -40,11 +41,13 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#hybrid-library-api __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-2 __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-3 +__ FIXME: PARSER: Link to UG docs. """ import sys from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Tuple, Union +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union # Need to use version check and not try/except to support Mypy's stubgen. if sys.version_info >= (3, 8): from typing import TypedDict @@ -57,6 +60,12 @@ from robot import result, running from robot.model import Message +from robot.running import TestSuite +# FIXME: PARSER: +# - Expose `Defaults` via `robot.running`. +# - Consider better class name. +# - Enhance its API (incl. docs and types). +from robot.running.builder.settings import Defaults # Type aliases used by DynamicLibrary and HybridLibrary. @@ -507,7 +516,7 @@ def close(self): class ListenerV3: - """Optional base class for listeners using the listener API v2.""" + """Optional base class for listeners using the listener API v3.""" ROBOT_LISTENER_API_VERSION = 3 def start_suite(self, data: running.TestSuite, result: result.TestSuite): @@ -560,3 +569,40 @@ def close(self): With library listeners called when the library goes out of scope. """ + + +class Parser(ABC): + """Optional base class for custom parsers. + + Parsers do not need to explicitly extend this class and in simple cases + it is possible to implement them as modules. Regardless how a parser is + implemented, it must have :attr:`extension` attribute and :meth:`parse` + method. The :meth:`parse_init` method is optional and only needed if + a parser supports parsing suite initialization files. + + The mandatory :attr:`extension` attribute specifies what file extension or + extensions a parser supports. It can be set either as a class or instance + attribute, and it can be either a string or a list/tuple of strings. The + attribute can also be named ``EXTENSION``, which typically works better + when a parser is implemented as a module. + + The support for custom parsers is new in Robot Framework 6.1. + """ + extension: Union[str, Sequence[str]] + + @abstractmethod + def parse(self, source: Path, defaults: Defaults) -> TestSuite: + """Mandatory method for parsing suite files. + + FIXME: PARSER: Better documentation (incl. parameter docs). + """ + raise NotImplementedError + + def parse_init(self, source: Path, defaults: Defaults) -> TestSuite: + """Optional method for parsing suite initialization files. + + FIXME: PARSER: Better documentation (incl. parameter docs). + + If not implemented, possible initialization files cause an error. + """ + raise NotImplementedError diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index 3f08a586a04..40ed8b85b10 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -53,8 +53,7 @@ class _BaseSettings: 'TimestampOutputs' : ('timestampoutputs', False), 'LogTitle' : ('logtitle', None), 'ReportTitle' : ('reporttitle', None), - 'ReportBackground' : ('reportbackground', - ('#9e9', '#f66', '#fed84f')), + 'ReportBackground' : ('reportbackground', ('#9e9', '#f66', '#fed84f')), 'SuiteStatLevel' : ('suitestatlevel', -1), 'TagStatInclude' : ('tagstatinclude', []), 'TagStatExclude' : ('tagstatexclude', []), @@ -470,6 +469,7 @@ class RobotSettings(_BaseSettings): 'RunEmptySuite' : ('runemptysuite', False), 'Variables' : ('variable', []), 'VariableFiles' : ('variablefile', []), + 'Parsers' : ('parser', []), 'PreRunModifiers' : ('prerunmodifier', []), 'Listeners' : ('listener', []), 'ConsoleType' : ('console', 'verbose'), @@ -629,6 +629,10 @@ def max_error_lines(self): def max_assign_length(self): return self['MaxAssignLength'] + @property + def parsers(self): + return self['Parsers'] + @property def pre_run_modifiers(self): return self['PreRunModifiers'] diff --git a/src/robot/run.py b/src/robot/run.py index 144733440db..9becc0bd6a1 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -311,6 +311,7 @@ The seed must be an integer. Examples: --randomize all --randomize tests:1234 + --parser parser FIXME: PARSER: Documentation --prerunmodifier class * Class to programmatically modify the suite structure before execution. --prerebotmodifier class * Class to programmatically modify the result @@ -413,8 +414,8 @@ class RobotFramework(Application): def __init__(self): - Application.__init__(self, USAGE, arg_limits=(1,), env_options='ROBOT_OPTIONS', - logger=LOGGER) + super().__init__(USAGE, arg_limits=(1,), env_options='ROBOT_OPTIONS', + logger=LOGGER) def main(self, datasources, **options): try: @@ -429,6 +430,7 @@ def main(self, datasources, **options): sys.path = settings.pythonpath + sys.path builder = TestSuiteBuilder(settings.suite_names, included_extensions=settings.extension, + custom_parsers=settings.parsers, rpa=settings.rpa, lang=settings.languages, allow_empty_suite=settings.run_empty_suite) diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index de52004c513..8ca8dfdb198 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from itertools import chain from os.path import normpath from pathlib import Path from typing import Sequence @@ -21,11 +22,11 @@ from robot.errors import DataError from robot.output import LOGGER from robot.parsing import SuiteStructure, SuiteStructureBuilder, SuiteStructureVisitor -from robot.utils import seq2str +from robot.utils import Importer, seq2str, split_args_from_name_or_path from ..model import ResourceFile, TestSuite -from .parsers import (JsonParser, NoInitFileDirectoryParser, Parser, RestParser, - RobotParser) +from .parsers import (CustomParser, JsonParser, NoInitFileDirectoryParser, Parser, + RestParser, RobotParser) from .settings import Defaults @@ -55,6 +56,7 @@ class TestSuiteBuilder: def __init__(self, included_suites: Sequence[str] = (), included_extensions: Sequence[str] = ('.robot', '.rbt'), + custom_parsers: Sequence[str] = (), rpa: 'bool|None' = None, lang: LanguagesLike = None, allow_empty_suite: bool = False, process_curdir: bool = True): """ @@ -63,6 +65,8 @@ def __init__(self, included_suites: Sequence[str] = (), Same as using `--suite` on the command line. :param included_extensions: List of extensions of files to parse. Same as `--extension`. + :param custom_parsers: + FIXME: PARSER: Documentation. :param rpa: Explicit execution mode. ``True`` for RPA and ``False`` for test automation. By default, mode is got from data file headers and possible conflicting headers cause an error. @@ -80,20 +84,39 @@ def __init__(self, included_suites: Sequence[str] = (), resolved already at parsing time by default, but that can be changed by giving this argument ``False`` value. """ + self.standard_parsers = self._get_standard_parsers(lang, process_curdir) + self.custom_parsers = self._get_custom_parsers(custom_parsers) + self.included_suites = tuple(included_suites or ()) + self.included_extensions = tuple(included_extensions or ()) + self.rpa = rpa + self.allow_empty_suite = allow_empty_suite + + def _get_standard_parsers(self, lang: LanguagesLike, + process_curdir: bool) -> 'dict[str, Parser]': robot_parser = RobotParser(lang, process_curdir) rest_parser = RestParser(lang, process_curdir) json_parser = JsonParser() - self.standard_parsers = { + return { 'robot': robot_parser, 'rst': rest_parser, 'rest': rest_parser, 'rbt': json_parser, 'json': json_parser } - self.included_suites = tuple(included_suites or ()) - self.included_extensions = tuple(included_extensions) - self.rpa = rpa - self.allow_empty_suite = allow_empty_suite + + def _get_custom_parsers(self, names: Sequence[str]) -> 'dict[str, CustomParser]': + parsers = {} + importer = Importer('parser', LOGGER) + for name in names: + name, args = split_args_from_name_or_path(name) + imported = importer.import_class_or_module(name, args) + try: + parser = CustomParser(imported) + except TypeError as err: + raise DataError(f"Importing parser '{name}' failed: {err}") + for ext in parser.extensions: + parsers[ext] = parser + return parsers def build(self, *paths: 'Path|str'): """ @@ -101,10 +124,11 @@ def build(self, *paths: 'Path|str'): :return: :class:`~robot.running.model.TestSuite` instance. """ paths = self._normalize_paths(paths) - parsers = self._get_parsers(self.included_extensions, paths) - structure = SuiteStructureBuilder(self.included_extensions, + extensions = chain(self.included_extensions, self.custom_parsers) + structure = SuiteStructureBuilder(extensions, self.included_suites).build(*paths) - suite = SuiteStructureParser(parsers, self.rpa).parse(structure) + suite = SuiteStructureParser(self._get_parsers(paths), + self.rpa).parse(structure) if not self.included_suites and not self.allow_empty_suite: self._validate_not_empty(suite, multi_source=len(paths) > 1) suite.remove_empty_suites(preserve_direct_children=len(paths) > 1) @@ -124,12 +148,14 @@ def _normalize_paths(self, paths: 'tuple[Path|str]') -> 'tuple[Path]': f"File or directory to execute does not exist.") return paths - def _get_parsers(self, extensions: 'tuple[str]', paths: 'tuple[Path]'): - parsers = {None: NoInitFileDirectoryParser()} + def _get_parsers(self, paths: 'tuple[Path]'): + parsers = {None: NoInitFileDirectoryParser(), **self.custom_parsers} robot_parser = self.standard_parsers['robot'] - for ext in extensions + tuple(p.suffix for p in paths if p.is_file()): + for ext in chain(self.included_extensions, + [p.suffix for p in paths if p.is_file()]): ext = ext.lstrip('.').lower() - parsers[ext] = self.standard_parsers.get(ext, robot_parser) + if ext not in parsers: + parsers[ext] = self.standard_parsers.get(ext, robot_parser) return parsers def _validate_not_empty(self, suite: TestSuite, multi_source: bool = False): diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 6a3b564f833..f75adbf97da 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -17,8 +17,9 @@ from pathlib import Path from robot.conf import Languages +from robot.errors import DataError from robot.parsing import File, get_init_model, get_model, get_resource_model -from robot.utils import FileReader, read_rest_data +from robot.utils import FileReader, get_error_message, read_rest_data from .settings import Defaults from .transformers import ResourceBuilder, SuiteBuilder @@ -27,14 +28,18 @@ class Parser(ABC): + @property + def name(self) -> str: + return type(self).__name__ + def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: - raise TypeError(f'{type(self).__name__} does not support suite files') + raise DataError(f"'{self.name}' does not support parsing suite files.") def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: - raise TypeError(f'{type(self).__name__} does not support initialization files') + raise DataError(f"'{self.name}' does not support parsing initialization files.") def parse_resource_file(self, source: Path) -> ResourceFile: - raise TypeError(f'{type(self).__name__} does not support resource files') + raise DataError(f"'{self.name}' does not support parsing resource files.") class RobotParser(Parser): @@ -102,3 +107,48 @@ class NoInitFileDirectoryParser(Parser): def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: return TestSuite(name=TestSuite.name_from_source(source), source=source) + + +class CustomParser(Parser): + + def __init__(self, parser): + self.parser = parser + if not callable(getattr(parser, 'parse', None)): + raise TypeError(f"'{self.name}' does not have mandatory 'parse' method.") + if not self.extensions: + raise TypeError(f"'{self.name}' does not have mandatory 'EXTENSION' " + f"or 'extension' attribute set.") + + @property + def name(self) -> str: + return type(self.parser).__name__ + + @property + def extensions(self) -> 'tuple[str]': + ext = (getattr(self.parser, 'EXTENSION', ()) + or getattr(self.parser, 'extension', ())) + extensions = [ext] if isinstance(ext, str) else tuple(ext) + return tuple(ext.lower().lstrip('.') for ext in extensions) + + def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: + return self._parse(self.parser.parse, source, defaults) + + def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + parse_init = getattr(self.parser, 'parse_init', None) + try: + return self._parse(parse_init, source, defaults) + except NotImplementedError: + return super().parse_init_file(source, defaults) # Raises DataError + + def _parse(self, method, *args) -> TestSuite: + if not method: + raise NotImplementedError + try: + suite = method(*args) + if not isinstance(suite, TestSuite): + raise TypeError(f"Return value should be 'robot.running.TestSuite', " + f"got '{type(suite).__name__}'.") + except Exception: + raise DataError(f"Calling '{self.name}.{method.__name__}()' failed: " + f"{get_error_message()}") + return suite From 81133525c7d41ae5f504bda77e606e37a264c1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 21 Apr 2023 15:12:16 +0300 Subject: [PATCH 0502/1592] Windows fix. Apparently `Path.is_absolute` and/or `Path.exists` can fail with OSError with Python < 3.10 on Windows. The error didn't occur earlier when we used `os.path` instead. --- src/robot/utils/filereader.py | 8 +++++--- utest/utils/test_filereader.py | 9 +++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/robot/utils/filereader.py b/src/robot/utils/filereader.py index 8532c584c9a..a6d2757abbe 100644 --- a/src/robot/utils/filereader.py +++ b/src/robot/utils/filereader.py @@ -69,9 +69,11 @@ def _get_path(self, source: Source, accept_text: bool): if '\n' in source: return None path = Path(source) - if path.is_absolute() or path.exists(): - return source - return None + try: + is_path = path.is_absolute() or path.exists() + except OSError: + is_path = False + return source if is_path else None @property def name(self) -> str: diff --git a/utest/utils/test_filereader.py b/utest/utils/test_filereader.py index 35a200b5bcf..73e485010e2 100644 --- a/utest/utils/test_filereader.py +++ b/utest/utils/test_filereader.py @@ -94,12 +94,17 @@ def test_bytesio(self): assert_reader(reader, '<in-memory file>') assert_open(f) - def test_accept_text(self): + def test_text(self): with FileReader(STRING, accept_text=True) as reader: assert_reader(reader, '<in-memory file>') assert_closed(reader.file) - def test_no_accept_text(self): + def test_text_with_special_chars(self): + for text in '!"#¤%&/()=?', '*** Test Cases ***', '': + with FileReader(text, accept_text=True) as reader: + assert_equal(reader.read(), text) + + def test_text_when_text_is_not_accepted(self): assert_raises(IOError, FileReader, STRING) def test_readlines(self): From ac6617bfb74d50597d74d25afdbfc81aa653f20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 21 Apr 2023 15:58:43 +0300 Subject: [PATCH 0503/1592] More pathlib.Path Windows workarounds --- src/robot/conf/languages.py | 10 ++++++++-- src/robot/utils/filereader.py | 2 +- utest/utils/test_filereader.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 5d447be3161..a5defbf09d0 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -75,7 +75,7 @@ def add_language(self, lang: LanguageLike): """ if isinstance(lang, Language): languages = [lang] - elif isinstance(lang, Path) or Path(lang).exists(): + elif isinstance(lang, Path) or self._exists(Path(lang)): languages = self._import_language_module(Path(lang)) else: try: @@ -88,6 +88,12 @@ def add_language(self, lang: LanguageLike): for lang in languages: self._add_language(lang) + def _exists(self, path): + try: + return path.exists() + except OSError: # Can happen on Windows w/ Python < 3.10. + return False + def _add_language(self, lang): if lang in self.languages: return @@ -151,7 +157,7 @@ def is_language(member): and member is not Language) if isinstance(name_or_path, Path): name_or_path = name_or_path.absolute() - elif Path(name_or_path).exists(): + elif self._exists(Path(name_or_path)): name_or_path = Path(name_or_path).absolute() module = Importer('language file').import_module(name_or_path) return [value() for _, value in inspect.getmembers(module, is_language)] diff --git a/src/robot/utils/filereader.py b/src/robot/utils/filereader.py index a6d2757abbe..6bcd540a2e1 100644 --- a/src/robot/utils/filereader.py +++ b/src/robot/utils/filereader.py @@ -71,7 +71,7 @@ def _get_path(self, source: Source, accept_text: bool): path = Path(source) try: is_path = path.is_absolute() or path.exists() - except OSError: + except OSError: # Can happen on Windows w/ Python < 3.10. is_path = False return source if is_path else None diff --git a/utest/utils/test_filereader.py b/utest/utils/test_filereader.py index 73e485010e2..03aa8f774e6 100644 --- a/utest/utils/test_filereader.py +++ b/utest/utils/test_filereader.py @@ -100,7 +100,7 @@ def test_text(self): assert_closed(reader.file) def test_text_with_special_chars(self): - for text in '!"#¤%&/()=?', '*** Test Cases ***', '': + for text in '!"#¤%&/()=?', '*** Test Cases ***', 'in:va:lid': with FileReader(text, accept_text=True) as reader: assert_equal(reader.read(), text) From 900fe60dfe7332bd7855be85410d67f513c4cd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 24 Apr 2023 21:00:43 +0300 Subject: [PATCH 0504/1592] Enhance validating name and body of tests and keywords. - Report invalid tasks properly as tasks, not as tests, during parsing. - Validate user keyword name during parsing. - Validate name and body also during execution to catch errors when model is built programmatically. --- ...dotted_exitonfailure_empty_test_stderr.txt | 5 +- atest/robot/core/empty_tc_and_uk.robot | 29 +++-- .../testdata/core/empty_testcase_and_uk.robot | 12 +-- atest/testdata/core/keyword_teardown.robot | 2 +- src/robot/parsing/model/blocks.py | 14 ++- src/robot/parsing/model/statements.py | 9 +- src/robot/running/suiterunner.py | 10 +- utest/parsing/test_model.py | 101 +++++++++++++++++- utest/running/test_running.py | 12 +++ 9 files changed, 162 insertions(+), 32 deletions(-) diff --git a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt index 5fd7a2bc103..c61830b9230 100644 --- a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt +++ b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt @@ -1,2 +1,3 @@ -[[] ERROR ] Error in file '*' on line 45: Creating keyword 'Empty UK' failed: User keyword 'Empty UK' contains no keywords. -[[] ERROR ] Error in file '*' on line 47: Creating keyword 'Empty UK With Settings' failed: User keyword 'Empty UK With Settings' contains no keywords. +[[] ERROR ] Error in file '*' on line 41: Creating keyword '' failed: User keyword name cannot be empty. +[[] ERROR ] Error in file '*' on line 45: Creating keyword 'Empty UK' failed: User keyword cannot be empty. +[[] ERROR ] Error in file '*' on line 47: Creating keyword 'Empty UK With Settings' failed: User keyword cannot be empty. diff --git a/atest/robot/core/empty_tc_and_uk.robot b/atest/robot/core/empty_tc_and_uk.robot index 3c8c4cb22f3..fcfe5decbf3 100644 --- a/atest/robot/core/empty_tc_and_uk.robot +++ b/atest/robot/core/empty_tc_and_uk.robot @@ -1,32 +1,39 @@ *** Settings *** -Documentation Empty test cases and user keywords -Suite Setup Run Tests ${EMPTY} core/empty_testcase_and_uk.robot +Suite Setup Run Tests ${EMPTY} core/empty_testcase_and_uk.robot Resource atest_resource.robot *** Test Cases *** Test Case Without Name - Check Test Case ${EMPTY} + Check Test Case ${EMPTY} Empty Test Case - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Empty Test Case With Setup And Teardown - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} + +User Keyword Without Name + Error In File 0 core/empty_testcase_and_uk.robot 41 + ... Creating keyword '' failed: User keyword name cannot be empty. Empty User Keyword - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} + Error In File 1 core/empty_testcase_and_uk.robot 45 + ... Creating keyword 'Empty UK' failed: User keyword cannot be empty. User Keyword With Only Non-Empty [Return] Works - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} User Keyword With Empty [Return] Does Not Work - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Empty User Keyword With Other Settings Than [Return] - Check Test Case ${TESTNAME} + Error In File 2 core/empty_testcase_and_uk.robot 47 + ... Creating keyword 'Empty UK With Settings' failed: User keyword cannot be empty. + Check Test Case ${TESTNAME} Non-Empty And Empty User Keyword - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Non-Empty UK Using Empty UK - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} diff --git a/atest/testdata/core/empty_testcase_and_uk.robot b/atest/testdata/core/empty_testcase_and_uk.robot index e18a6c85613..c2a9a7b9e7e 100644 --- a/atest/testdata/core/empty_testcase_and_uk.robot +++ b/atest/testdata/core/empty_testcase_and_uk.robot @@ -6,15 +6,15 @@ ${TEST OR TASK} Test Fail Should not be executed Empty Test Case - [Documentation] FAIL ${TEST OR TASK} contains no keywords. + [Documentation] FAIL ${TEST OR TASK} cannot be empty. Empty Test Case With Setup And Teardown - [Documentation] FAIL ${TEST OR TASK} contains no keywords. + [Documentation] FAIL ${TEST OR TASK} cannot be empty. [Setup] Fail Should not be executed [Teardown] Fail Should not be executed Empty User Keyword - [Documentation] FAIL User keyword 'Empty UK' contains no keywords. + [Documentation] FAIL User keyword cannot be empty. Empty UK User Keyword With Only Non-Empty [Return] Works @@ -24,17 +24,17 @@ User Keyword With Empty [Return] Does Not Work UK With Empty Return Empty User Keyword With Other Settings Than [Return] - [Documentation] FAIL User keyword 'Empty UK With Settings' contains no keywords. + [Documentation] FAIL User keyword cannot be empty. Empty UK With Settings argument Non-Empty And Empty User Keyword - [Documentation] FAIL User keyword 'Empty UK' contains no keywords. + [Documentation] FAIL User keyword cannot be empty. UK Empty Uk Fail We should not be here Non-Empty UK Using Empty UK - [Documentation] FAIL User keyword 'Empty UK' contains no keywords. + [Documentation] FAIL User keyword cannot be empty. Non Empty UK Using Empty UK *** Keywords *** diff --git a/atest/testdata/core/keyword_teardown.robot b/atest/testdata/core/keyword_teardown.robot index b324fefd0ac..2c2eb268f8a 100644 --- a/atest/testdata/core/keyword_teardown.robot +++ b/atest/testdata/core/keyword_teardown.robot @@ -41,7 +41,7 @@ Non-ASCII Failure in Keyword Teardown Non-ASCII Failure in Keyword Teardown Keyword cannot have only teardown - [Documentation] FAIL User keyword 'Keyword cannot have only teardown' contains no keywords. + [Documentation] FAIL User keyword cannot be empty. Keyword cannot have only teardown Replacing Variables in Keyword Teardown Fails diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 1ff1706ecc1..8354380d8ea 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -16,7 +16,7 @@ import ast from contextlib import contextmanager -from robot.utils import file_writer, is_pathlike, is_string +from robot.utils import file_writer, is_pathlike, is_string, test_or_task from .statements import (Break, Continue, Error, KeywordCall, ReturnSetting, ReturnStatement, Statement, TemplateArguments) @@ -134,8 +134,7 @@ def name(self): def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): - # FIXME: Tasks! - self.errors += ('Test contains no keywords.',) + self.errors += (test_or_task('{Test} cannot be empty.', ctx.tasks),) class Keyword(HeaderAndBody): @@ -147,7 +146,7 @@ def name(self): def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): if not any(isinstance(node, ReturnSetting) for node in self.body): - self.errors += (f"User keyword '{self.name}' contains no keywords.",) + self.errors += ("User keyword cannot be empty.",) class If(HeaderAndBody): @@ -416,6 +415,13 @@ def block(self, node: Block): def parent_block(self): return self.blocks[-1] if self.blocks else None + @property + def tasks(self): + for parent in self.blocks: + if isinstance(parent, TestCaseSection): + return parent.tasks + return False + @property def in_keyword(self): return any(isinstance(b, Keyword) for b in self.blocks) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 4bf00dbe823..9e7adcc7c84 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -19,7 +19,8 @@ from robot.conf import Language from robot.running.arguments import UserKeywordArgumentParser -from robot.utils import is_list_like, normalize_whitespace, seq2str, split_from_equals +from robot.utils import (is_list_like, normalize_whitespace, seq2str, split_from_equals, + test_or_task) from robot.variables import is_scalar_assign, is_dict_variable, search_variable from ..lexer import Token @@ -626,7 +627,7 @@ def name(self): def validate(self, ctx: 'ValidationContext'): if not self.name: - self.errors += ('Test name cannot be empty.',) + self.errors += (test_or_task('{Test} name cannot be empty.', ctx.tasks),) @Statement.register @@ -644,6 +645,10 @@ def from_params(cls, name, eol=EOL): def name(self): return self.get_value(Token.KEYWORD_NAME) + def validate(self, ctx: 'ValidationContext'): + if not self.name: + self.errors += ('User keyword name cannot be empty.',) + @Statement.register class Setup(Fixture): diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index 1652dfc712c..e055c00b613 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -145,9 +145,15 @@ def visit_test(self, test): self._add_exit_combine() result.tags.add('robot:exit') if status.passed: + if not test.error: + if not test.name: + test.error = 'Test name cannot be empty.' + elif not test.body: + test.error = 'Test cannot be empty.' if test.error: - error = test.error if not settings.rpa else test.error.replace('Test', 'Task') - status.test_failed(error) + if settings.rpa: + test.error = test.error.replace('Test', 'Task') + status.test_failed(test.error) elif test.tags.robot('skip'): status.test_skipped( test_or_task("{Test} skipped using 'robot:skip' tag.", diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 68330deaaad..fb4ab5d2509 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -940,7 +940,64 @@ def test_invalid(self): get_and_assert_model(data, expected, depth=0) -class TestKeyword(unittest.TestCase): +class TestTestCase(unittest.TestCase): + + def test_empty_test(self): + data = ''' +*** Test Cases *** +Empty + [Documentation] Settings aren't enough. +''' + expected = TestCase( + header=TestCaseName( + tokens=[Token(Token.TESTCASE_NAME, 'Empty', 2, 0)] + ), + body=[ + Documentation( + tokens=[Token(Token.DOCUMENTATION, '[Documentation]', 3, 4), + Token(Token.ARGUMENT, "Settings aren't enough.", 3, 23)] + ), + ], + errors=('Test cannot be empty.',) + ) + get_and_assert_model(data, expected, depth=1) + + def test_empty_test_name(self): + data = ''' +*** Test Cases *** + Keyword +''' + expected = TestCase( + header=TestCaseName( + tokens=[Token(Token.TESTCASE_NAME, '', 2, 0)], + errors=('Test name cannot be empty.',) + ), + body=[KeywordCall(tokens=[Token(Token.KEYWORD, 'Keyword', 2, 4)])] + ) + get_and_assert_model(data, expected, depth=1) + + def test_invalid_task(self): + data = ''' +*** Tasks *** + [Documentation] Empty name and body. +''' + expected = TestCase( + header=TestCaseName( + tokens=[Token(Token.TESTCASE_NAME, '', 2, 0)], + errors=('Task name cannot be empty.',) + ), + body=[ + Documentation( + tokens=[Token(Token.DOCUMENTATION, '[Documentation]', 2, 4), + Token(Token.ARGUMENT, 'Empty name and body.', 2, 23)] + ), + ], + errors=('Task cannot be empty.',) + ) + get_and_assert_model(data, expected, depth=1) + + +class TestUserKeyword(unittest.TestCase): def test_invalid_arg_spec(self): data = ''' @@ -948,6 +1005,7 @@ def test_invalid_arg_spec(self): Invalid [Arguments] ooops ${optional}=default ${required} ... @{too} @{many} &{notlast} ${x} + Keyword ''' expected = Keyword( header=KeywordName( @@ -967,9 +1025,44 @@ def test_invalid_arg_spec(self): 'Non-default argument after default arguments.', 'Cannot have multiple varargs.', 'Only last argument can be kwargs.') - ) + ), + KeywordCall( + tokens=[Token(Token.KEYWORD, 'Keyword', 5, 4)]) ], - errors=("User keyword 'Invalid' contains no keywords.",) + ) + get_and_assert_model(data, expected, depth=1) + + def test_empty(self): + data = ''' +*** Keywords *** +Empty + [Arguments] ${ok} +''' + expected = Keyword( + header=KeywordName( + tokens=[Token(Token.KEYWORD_NAME, 'Empty', 2, 0)] + ), + body=[ + Arguments( + tokens=[Token(Token.ARGUMENTS, '[Arguments]', 3, 4), + Token(Token.ARGUMENT, '${ok}', 3, 19)] + ), + ], + errors=('User keyword cannot be empty.',) + ) + get_and_assert_model(data, expected, depth=1) + + def test_empty_name(self): + data = ''' +*** Keywords *** + Keyword +''' + expected = Keyword( + header=KeywordName( + tokens=[Token(Token.KEYWORD_NAME, '', 2, 0)], + errors=('User keyword name cannot be empty.',) + ), + body=[KeywordCall(tokens=[Token(Token.KEYWORD, 'Keyword', 2, 4)])] ) get_and_assert_model(data, expected, depth=1) @@ -1483,7 +1576,7 @@ def visit_Statement(self, node): TestCase(TestCaseName([ Token('TESTCASE NAME', 'EXAMPLE', 2, 0), Token('EOL', '\n', 2, 7) - ]), errors= ('Test contains no keywords.',)), + ]), errors= ('Test cannot be empty.',)), TestCase(TestCaseName([ Token('TESTCASE NAME', 'Added'), Token('EOL', '\n') diff --git a/utest/running/test_running.py b/utest/running/test_running.py index b33f6786154..bc577d24641 100644 --- a/utest/running/test_running.py +++ b/utest/running/test_running.py @@ -107,6 +107,18 @@ def test_variables(self): assert_test(result.tests[0], 'T1', 'FAIL', msg='Error message') assert_test(result.tests[1], 'T2', 'FAIL', ('added tag',), 'Error') + def test_test_cannot_be_empty(self): + suite = TestSuite() + suite.tests.create(name='Empty') + result = run(suite) + assert_test(result.tests[0], 'Empty', 'FAIL', msg='Test cannot be empty.') + + def test_name_cannot_be_empty(self): + suite = TestSuite() + suite.tests.create().body.create_keyword('Not executed') + result = run(suite) + assert_test(result.tests[0], '', 'FAIL', msg='Test name cannot be empty.') + def test_modifiers_are_not_used(self): # These options are valid but not used. Modifiers can be passed to # suite.visit() explicitly if needed. From 53fb15aedb2f19ef3035097531583e26ddf44809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 24 Apr 2023 21:48:05 +0300 Subject: [PATCH 0505/1592] Acceptance tests for custom parsers (#1283) Also smallish code changes. --- atest/robot/parsing/custom_parsers.robot | 103 ++++++++++++++++++ atest/testdata/parsing/custom/CustomParser.py | 36 ++++++ atest/testdata/parsing/custom/__init__.init | 0 atest/testdata/parsing/custom/custom.py | 20 ++++ atest/testdata/parsing/custom/more.custom | 2 + atest/testdata/parsing/custom/tests.custom | 7 ++ atest/testdata/parsing/custom/tests.robot | 3 + src/robot/running/builder/parsers.py | 27 ++--- 8 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 atest/robot/parsing/custom_parsers.robot create mode 100644 atest/testdata/parsing/custom/CustomParser.py create mode 100644 atest/testdata/parsing/custom/__init__.init create mode 100644 atest/testdata/parsing/custom/custom.py create mode 100644 atest/testdata/parsing/custom/more.custom create mode 100644 atest/testdata/parsing/custom/tests.custom create mode 100644 atest/testdata/parsing/custom/tests.robot diff --git a/atest/robot/parsing/custom_parsers.robot b/atest/robot/parsing/custom_parsers.robot new file mode 100644 index 00000000000..181b419883b --- /dev/null +++ b/atest/robot/parsing/custom_parsers.robot @@ -0,0 +1,103 @@ +*** Settings *** +Resource atest_resource.robot + +*** Variables *** +${DIR} ${DATADIR}/parsing/custom + +*** Test Cases *** +Single file + [Documentation] Also tests parser implemented as a module. + Run Tests --parser ${DIR}/custom.py ${DIR}/tests.custom + Validate Suite ${SUITE} Tests ${DIR}/tests.custom + ... Passing=PASS + ... Failing=FAIL:Error message + ... Empty=FAIL:Test cannot be empty. + +Directory + [Documentation] Also tests parser implemented as a class. + Run Tests --parser ${DIR}/CustomParser.py ${DIR} + Validate Directory Suite Custom custom=False + +Directory with init + Run Tests --parser ${DIR}/CustomParser.py:init=True ${DIR} + Validate Directory Suite 📁 custom=True + +Override Robot parser + Run Tests --parser ${DIR}/CustomParser.py:.robot ${DIR}/tests.robot + Validate Suite ${SUITE} Tests ${DIR}/tests.robot + ... Test in Robot file=PASS + Run Tests --parser ${DIR}/CustomParser.py:ROBOT ${DIR} + Validate Suite ${SUITE} Custom ${DIR} custom=False + ... Test in Robot file=PASS + Validate Suite ${SUITE.suites[0]} Tests ${DIR}/tests.robot + ... Test in Robot file=PASS + +Directory with init when parser does not support inits + Parsing Should Fail init + ... Parsing '${DIR}' failed: + ... 'CustomParser' does not support parsing initialization files. + +Incompatible parser + Parsing Should Fail parse=False + ... Importing parser '${DIR}/CustomParser.py' failed: + ... 'CustomParser' does not have mandatory 'parse' method. + Parsing Should Fail extension= + ... Importing parser '${DIR}/CustomParser.py' failed: + ... 'CustomParser' does not have mandatory 'EXTENSION' or 'extension' attribute. + +Failing parser + Parsing Should Fail fail=True + ... Parsing '${DIR}${/}more.custom' failed: + ... Calling 'CustomParser.parse()' failed: + ... TypeError: Ooops! + Parsing Should Fail fail=True:init=True + ... Parsing '${DIR}' failed: + ... Calling 'CustomParser.parse_init()' failed: + ... TypeError: Ooops in init! + +Bad return value + Parsing Should Fail bad_return=True + ... Parsing '${DIR}${/}more.custom' failed: + ... Calling 'CustomParser.parse()' failed: + ... TypeError: Return value should be 'robot.running.TestSuite', got 'string'. + Parsing Should Fail bad_return=True:init=True + ... Parsing '${DIR}' failed: + ... Calling 'CustomParser.parse_init()' failed: + ... TypeError: Return value should be 'robot.running.TestSuite', got 'integer'. + +*** Keywords *** +Validate Suite + [Arguments] ${suite} ${name} ${source} ${custom}=True &{tests} + ${source} = Normalize Path ${source} + Should Be Equal ${suite.name} ${name} + Should Be Equal As Strings ${suite.source} ${source} + IF ${custom} + Should Be Equal ${suite.metadata}[Parser] Custom + ELSE + Should Not Contain ${suite.metadata} Parser + END + Should Contain Tests ${suite} &{tests} + +Validate Directory Suite + [Arguments] ${name} ${custom}=True + Validate Suite ${SUITE} ${name} ${DIR} ${custom} + ... Passing=PASS + ... Failing=FAIL:Error message + ... Empty=FAIL:Test cannot be empty. + ... Test in Robot file=PASS + ... Yet another test=PASS + Validate Suite ${SUITE.suites[0]} More ${DIR}/more.custom + ... Yet another test=PASS + Validate Suite ${SUITE.suites[1]} Tests ${DIR}/tests.custom + ... Passing=PASS + ... Failing=FAIL:Error message + ... Empty=FAIL:Test cannot be empty. + Validate Suite ${SUITE.suites[2]} Tests ${DIR}/tests.robot custom=False + ... Test in Robot file=PASS + +Parsing should fail + [Arguments] ${config} @{error} + ${result} = Run Tests --parser ${DIR}/CustomParser.py:${config} ${DIR} output=None + ${error} = Catenate @{error} + Should Be Equal ${result.rc} ${252} + Should Be Equal ${result.stderr} [ ERROR ] ${error}${USAGETIP} diff --git a/atest/testdata/parsing/custom/CustomParser.py b/atest/testdata/parsing/custom/CustomParser.py new file mode 100644 index 00000000000..6b0ff964d6a --- /dev/null +++ b/atest/testdata/parsing/custom/CustomParser.py @@ -0,0 +1,36 @@ +from pathlib import Path + +from robot.api import TestSuite +from robot.api.interfaces import Defaults, Parser + +import custom + + +class CustomParser(Parser): + + def __init__(self, extension='custom', parse=True, init=False, fail=False, + bad_return=False): + print(extension) + self.extension = extension.split(',') if extension else None + if not parse: + self.parse = None + if init: + self.extension.append('init') + else: + self.parse_init = None + self.fail = fail + self.bad_return = bad_return + + def parse(self, source: Path, defaults: Defaults) -> TestSuite: + if self.fail: + raise TypeError('Ooops!') + if self.bad_return: + return 'bad' + return custom.parse(source, defaults) + + def parse_init(self, source: Path, defaults: Defaults) -> TestSuite: + if self.fail: + raise TypeError('Ooops in init!') + if self.bad_return: + return 42 + return TestSuite(name='📁', source=source.parent, metadata={'Parser': 'Custom'}) diff --git a/atest/testdata/parsing/custom/__init__.init b/atest/testdata/parsing/custom/__init__.init new file mode 100644 index 00000000000..e69de29bb2d diff --git a/atest/testdata/parsing/custom/custom.py b/atest/testdata/parsing/custom/custom.py new file mode 100644 index 00000000000..c258d5c8277 --- /dev/null +++ b/atest/testdata/parsing/custom/custom.py @@ -0,0 +1,20 @@ +import re + +from robot.api import TestSuite + + +EXTENSION = 'CUSTOM' +extension = 'ignored' + + +def parse(source, defaults): + suite = TestSuite(source=source, metadata={'Parser': 'Custom'}) + for line in source.read_text().splitlines(): + if not line or line[0] in ('*', '#'): + continue + if line[0] != ' ': + suite.tests.create(name=line) + else: + name, *args = re.split(r'\s{2,}', line.strip()) + suite.tests[-1].body.create_keyword(name, args) + return suite diff --git a/atest/testdata/parsing/custom/more.custom b/atest/testdata/parsing/custom/more.custom new file mode 100644 index 00000000000..e7ea89e76f6 --- /dev/null +++ b/atest/testdata/parsing/custom/more.custom @@ -0,0 +1,2 @@ +Yet another test + No operation diff --git a/atest/testdata/parsing/custom/tests.custom b/atest/testdata/parsing/custom/tests.custom new file mode 100644 index 00000000000..811b96dbf13 --- /dev/null +++ b/atest/testdata/parsing/custom/tests.custom @@ -0,0 +1,7 @@ +Passing + No operation + +Failing + Fail Error message + +Empty diff --git a/atest/testdata/parsing/custom/tests.robot b/atest/testdata/parsing/custom/tests.robot new file mode 100644 index 00000000000..25662a016ab --- /dev/null +++ b/atest/testdata/parsing/custom/tests.robot @@ -0,0 +1,3 @@ +*** Test Cases *** +Test in Robot file + No Operation diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index f75adbf97da..b07ac9fbf03 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -16,10 +16,10 @@ from abc import ABC from pathlib import Path -from robot.conf import Languages +from robot.conf import LanguagesLike from robot.errors import DataError from robot.parsing import File, get_init_model, get_model, get_resource_model -from robot.utils import FileReader, get_error_message, read_rest_data +from robot.utils import FileReader, get_error_message, read_rest_data, type_name from .settings import Defaults from .transformers import ResourceBuilder, SuiteBuilder @@ -44,7 +44,7 @@ def parse_resource_file(self, source: Path) -> ResourceFile: class RobotParser(Parser): - def __init__(self, lang: Languages = None, process_curdir: bool = True): + def __init__(self, lang: LanguagesLike = None, process_curdir: bool = True): self.lang = lang self.process_curdir = process_curdir @@ -113,21 +113,21 @@ class CustomParser(Parser): def __init__(self, parser): self.parser = parser - if not callable(getattr(parser, 'parse', None)): + if not getattr(parser, 'parse', None): raise TypeError(f"'{self.name}' does not have mandatory 'parse' method.") if not self.extensions: raise TypeError(f"'{self.name}' does not have mandatory 'EXTENSION' " - f"or 'extension' attribute set.") + f"or 'extension' attribute.") @property def name(self) -> str: - return type(self.parser).__name__ + return type_name(self.parser) @property def extensions(self) -> 'tuple[str]': - ext = (getattr(self.parser, 'EXTENSION', ()) - or getattr(self.parser, 'extension', ())) - extensions = [ext] if isinstance(ext, str) else tuple(ext) + ext = (getattr(self.parser, 'EXTENSION', None) + or getattr(self.parser, 'extension', None)) + extensions = [ext] if isinstance(ext, str) else list(ext or ()) return tuple(ext.lower().lstrip('.') for ext in extensions) def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: @@ -136,19 +136,20 @@ def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: parse_init = getattr(self.parser, 'parse_init', None) try: - return self._parse(parse_init, source, defaults) + return self._parse(parse_init, source, defaults, init=True) except NotImplementedError: return super().parse_init_file(source, defaults) # Raises DataError - def _parse(self, method, *args) -> TestSuite: + def _parse(self, method, *args, init=False) -> TestSuite: if not method: raise NotImplementedError try: suite = method(*args) if not isinstance(suite, TestSuite): raise TypeError(f"Return value should be 'robot.running.TestSuite', " - f"got '{type(suite).__name__}'.") + f"got '{type_name(suite)}'.") except Exception: - raise DataError(f"Calling '{self.name}.{method.__name__}()' failed: " + method_name = 'parse' if not init else 'parse_init' + raise DataError(f"Calling '{self.name}.{method_name}()' failed: " f"{get_error_message()}") return suite From a95087852a09d4df57d78ebdcaa94e518e5709bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 24 Apr 2023 23:14:47 +0300 Subject: [PATCH 0506/1592] Refactor, mostly f-strings --- src/robot/libraries/Process.py | 57 +++++++++++++++------------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/src/robot/libraries/Process.py b/src/robot/libraries/Process.py index a9961a78607..5ecbb34cff4 100644 --- a/src/robot/libraries/Process.py +++ b/src/robot/libraries/Process.py @@ -19,12 +19,12 @@ import time from tempfile import TemporaryFile -from robot.utils import (abspath, cmdline2list, ConnectionCache, console_decode, - console_encode, is_list_like, is_pathlike, is_string, - is_truthy, NormalizedDict, secs_to_timestr, system_decode, - system_encode, timestr_to_secs, WINDOWS) -from robot.version import get_version from robot.api import logger +from robot.utils import (cmdline2list, ConnectionCache, console_decode, console_encode, + is_list_like, is_pathlike, is_string, is_truthy, + NormalizedDict, secs_to_timestr, system_decode, system_encode, + timestr_to_secs, WINDOWS) +from robot.version import get_version class Process: @@ -413,8 +413,8 @@ def start_process(self, command, *arguments, **configuration): def _log_start(self, command, config): if is_list_like(command): command = self.join_command_line(command) - logger.info('Starting process:\n%s' % system_decode(command)) - logger.debug('Process configuration:\n%s' % config) + logger.info(f'Starting process:\n{system_decode(command)}') + logger.debug(f'Process configuration:\n{config}') def is_process_running(self, handle=None): """Checks is the process running or not. @@ -501,8 +501,7 @@ def wait_for_process(self, handle=None, timeout=None, on_timeout='continue'): timeout = self._get_timeout(timeout) if timeout > 0: if not self._process_is_stopped(process, timeout): - logger.info('Process did not complete in %s.' - % secs_to_timestr(timeout)) + logger.info(f'Process did not complete in {secs_to_timestr(timeout)}.') return self._manage_process_timeout(handle, on_timeout.lower()) return self._wait(process) @@ -640,7 +639,7 @@ def send_signal_to_process(self, signal, handle=None, group=False): raise RuntimeError('This keyword does not work on Windows.') process = self._processes[handle] signum = self._get_signal_number(signal) - logger.info('Sending signal %s (%d).' % (signal, signum)) + logger.info(f'Sending signal {signal} ({signum}).') if is_truthy(group) and hasattr(os, 'killpg'): os.killpg(process.pid, signum) elif hasattr(process, 'send_signal'): @@ -660,7 +659,7 @@ def _convert_signal_name_to_number(self, name): return getattr(signal_module, name if name.startswith('SIG') else 'SIG' + name) except AttributeError: - raise RuntimeError("Unsupported signal '%s'." % name) + raise RuntimeError(f"Unsupported signal '{name}'.") def get_process_id(self, handle=None): """Returns the process ID (pid) of the process as an integer. @@ -876,14 +875,14 @@ def _get_and_read_standard_streams(self, process): return [stdin, stdout, stderr] def __str__(self): - return '<result object with rc %d>' % self.rc + return f'<result object with rc {self.rc}>' class ProcessConfiguration: def __init__(self, cwd=None, shell=False, stdout=None, stderr=None, stdin='PIPE', output_encoding='CONSOLE', alias=None, env=None, **rest): - self.cwd = os.path.normpath(cwd) if cwd else abspath('.') + self.cwd = os.path.normpath(cwd) if cwd else os.path.abspath('.') self.shell = is_truthy(shell) self.alias = alias self.output_encoding = output_encoding @@ -943,11 +942,11 @@ def _get_initial_env(self, env, extra): return None def _add_to_env(self, env, extra): - for key in extra: - if not key.startswith('env:'): - raise RuntimeError("Keyword argument '%s' is not supported by " - "this keyword." % key) - env[system_encode(key[4:])] = system_encode(extra[key]) + for name in extra: + if not name.startswith('env:'): + raise RuntimeError(f"Keyword argument '{name}' is not supported by " + f"this keyword.") + env[system_encode(name[4:])] = system_encode(extra[name]) def get_command(self, command, arguments): command = [system_encode(item) for item in [command] + arguments] @@ -986,20 +985,14 @@ def result_config(self): 'output_encoding': self.output_encoding} def __str__(self): - return """\ -cwd: %s -shell: %s -stdout: %s -stderr: %s -stdin: %s -alias: %s -env: %s""" % (self.cwd, - self.shell, - self._stream_name(self.stdout_stream), - self._stream_name(self.stderr_stream), - self._stream_name(self.stdin_stream), - self.alias, - self.env) + return f'''\ +cwd: {self.cwd} +shell: {self.shell} +stdout: {self._stream_name(self.stdout_stream)} +stderr: {self._stream_name(self.stderr_stream)} +stdin: {self._stream_name(self.stdin_stream)} +alias: {self.alias} +env: {self.env}''' def _stream_name(self, stream): if hasattr(stream, 'name'): From 65131fc2a613ccc970539251c10b5eb420be99f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 24 Apr 2023 23:41:47 +0300 Subject: [PATCH 0507/1592] Support Path with Split/Join Command Line. Fixes #4749. --- .../standard_libraries/process/commandline.robot | 6 ++++++ .../standard_libraries/process/commandline.robot | 12 +++++++++++- src/robot/libraries/Process.py | 14 ++++++++------ src/robot/utils/argumentparser.py | 3 +++ 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/atest/robot/standard_libraries/process/commandline.robot b/atest/robot/standard_libraries/process/commandline.robot index 7639dd02984..36d06d9d330 100644 --- a/atest/robot/standard_libraries/process/commandline.robot +++ b/atest/robot/standard_libraries/process/commandline.robot @@ -15,6 +15,9 @@ Split command line with unbalanced quotes Split command line with escaping Check Test Case ${TESTNAME} +Split command line with pathlib.Path + Check Test Case ${TESTNAME} + Join command line basics Check Test Case ${TESTNAME} @@ -23,3 +26,6 @@ Join command line with internal quotes Join command line with escaping Check Test Case ${TESTNAME} + +Join command line with non-strings + Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/process/commandline.robot b/atest/testdata/standard_libraries/process/commandline.robot index 6ee9ab3d442..059949157e7 100644 --- a/atest/testdata/standard_libraries/process/commandline.robot +++ b/atest/testdata/standard_libraries/process/commandline.robot @@ -64,6 +64,10 @@ Split command line with escaping \\\\\\"\\\\ \\"\\ escaping=True "\\\\\\"\\\\" \\"\\ escaping=True +Split command line with pathlib.Path + [Template] Split command line should succeed + ${{pathlib.Path($TEMPDIR)}} ${TEMPDIR} + Join command line basics [Template] Join command line should succeed FOR ${i} IN RANGE ${BASICS} @@ -82,6 +86,11 @@ Join command line with escaping \\\\\\" \\" \\\\\\\\\\" \\\\" +Join command line with non-strings + [Template] Join command line should succeed + ${TEMPDIR} ${{pathlib.Path($TEMPDIR)}} + -n 42 ${TEMPDIR} -n ${42} ${{pathlib.Path($TEMPDIR)}} + *** Keywords *** Split command line should succeed [Arguments] ${input} @{expected} &{config} @@ -90,7 +99,8 @@ Split command line should succeed Split command line should fail [Arguments] ${input} ${error}=No closing quotation - Run keyword and expect error ValueError: Parsing '${input}' failed: ${error} + Run keyword and expect error + ... ValueError: Parsing '${input}' failed: ${error} ... Split command line ${input} Join command line should succeed diff --git a/src/robot/libraries/Process.py b/src/robot/libraries/Process.py index 5ecbb34cff4..0518d567c90 100644 --- a/src/robot/libraries/Process.py +++ b/src/robot/libraries/Process.py @@ -768,10 +768,12 @@ def split_command_line(self, args, escaping=False): """Splits command line string into a list of arguments. String is split from spaces, but argument surrounded in quotes may - contain spaces in them. If ``escaping`` is given a true value, then - backslash is treated as an escape character. It can escape unquoted - spaces, quotes inside quotes, and so on, but it also requires using - double backslashes when using Windows paths. + contain spaces in them. + + If ``escaping`` is given a true value, then backslash is treated as + an escape character. It can escape unquoted spaces, quotes inside + quotes, and so on, but it also requires using doubling backslashes + in Windows paths and elsewhere. Examples: | @{cmd} = | Split Command Line | --option "value with spaces" | @@ -786,7 +788,7 @@ def join_command_line(self, *args): arguments containing spaces are surrounded with quotes, and possible quotes are escaped with a backslash. - If this keyword is given only one argument and that is a list like + If this keyword is given only one argument and that is a list-like object, then the values of that list are joined instead. Example: @@ -795,7 +797,7 @@ def join_command_line(self, *args): """ if len(args) == 1 and is_list_like(args[0]): args = args[0] - return subprocess.list2cmdline(args) + return subprocess.list2cmdline(str(a) for a in args) class ExecutionResult: diff --git a/src/robot/utils/argumentparser.py b/src/robot/utils/argumentparser.py index b39505437cf..59d22171bbd 100644 --- a/src/robot/utils/argumentparser.py +++ b/src/robot/utils/argumentparser.py @@ -21,6 +21,7 @@ import sys import string import warnings +from pathlib import Path from robot.errors import DataError, Information, FrameworkError from robot.version import get_full_version @@ -32,6 +33,8 @@ def cmdline2list(args, escaping=False): + if isinstance(args, Path): + return [str(args)] lexer = shlex.shlex(args, posix=True) if is_falsy(escaping): lexer.escape = '' From c2460f01d452605cd622f8d8e7a4d8dcba7b96b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 24 Apr 2023 23:57:54 +0300 Subject: [PATCH 0508/1592] Custom parser test fixes. - Fix compatibility with Python < 3.8. - Fix Windows compatibility. --- atest/robot/parsing/custom_parsers.robot | 8 ++++---- atest/testdata/parsing/custom/CustomParser.py | 4 ++-- src/robot/api/interfaces.py | 5 +---- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/atest/robot/parsing/custom_parsers.robot b/atest/robot/parsing/custom_parsers.robot index 181b419883b..c9c47d9647a 100644 --- a/atest/robot/parsing/custom_parsers.robot +++ b/atest/robot/parsing/custom_parsers.robot @@ -2,7 +2,7 @@ Resource atest_resource.robot *** Variables *** -${DIR} ${DATADIR}/parsing/custom +${DIR} ${{pathlib.Path(r'${DATADIR}/parsing/custom')}} *** Test Cases *** Single file @@ -39,10 +39,10 @@ Directory with init when parser does not support inits Incompatible parser Parsing Should Fail parse=False - ... Importing parser '${DIR}/CustomParser.py' failed: + ... Importing parser '${DIR}${/}CustomParser.py' failed: ... 'CustomParser' does not have mandatory 'parse' method. Parsing Should Fail extension= - ... Importing parser '${DIR}/CustomParser.py' failed: + ... Importing parser '${DIR}${/}CustomParser.py' failed: ... 'CustomParser' does not have mandatory 'EXTENSION' or 'extension' attribute. Failing parser @@ -70,7 +70,7 @@ Validate Suite [Arguments] ${suite} ${name} ${source} ${custom}=True &{tests} ${source} = Normalize Path ${source} Should Be Equal ${suite.name} ${name} - Should Be Equal As Strings ${suite.source} ${source} + Should Be Equal As Strings ${suite.source} ${source} IF ${custom} Should Be Equal ${suite.metadata}[Parser] Custom ELSE diff --git a/atest/testdata/parsing/custom/CustomParser.py b/atest/testdata/parsing/custom/CustomParser.py index 6b0ff964d6a..64c19ec1b5d 100644 --- a/atest/testdata/parsing/custom/CustomParser.py +++ b/atest/testdata/parsing/custom/CustomParser.py @@ -1,12 +1,12 @@ from pathlib import Path from robot.api import TestSuite -from robot.api.interfaces import Defaults, Parser +from robot.running.builder.settings import Defaults import custom -class CustomParser(Parser): +class CustomParser: def __init__(self, extension='custom', parse=True, init=False, fail=False, bad_return=False): diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 00b78f8b3c3..3d0a736429b 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -32,10 +32,7 @@ .. note:: These classes are not exposed via the top level :mod:`robot.api` package and need to imported via :mod:`robot.api.interfaces`. -.. note:: Using :class:`ListenerV2` and :class:`ListenerV3` requires Python 3.8 - or newer. - -New in Robot Framework 6.1. +New in Robot Framework 6.1. Requires Python 3.8 or newer. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dynamic-library-api __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#hybrid-library-api From 585ca53abc4fd73dbc36afd4baf8eb357d286aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 25 Apr 2023 13:52:48 +0300 Subject: [PATCH 0509/1592] Refactor passing test defaults to parsers. Motivation is making the API better for custom parsers (#1283). Part of that is adding types and documentation to TestDefaults. --- atest/robot/parsing/custom_parsers.robot | 6 +- atest/robot/parsing/test_case_settings.robot | 2 +- atest/testdata/parsing/custom/CustomParser.py | 9 +- src/robot/api/interfaces.py | 23 +- src/robot/model/tags.py | 5 +- src/robot/model/testsuite.py | 3 +- src/robot/parsing/suitestructure.py | 6 +- src/robot/running/__init__.py | 2 +- src/robot/running/builder/__init__.py | 1 + src/robot/running/builder/builders.py | 40 ++-- src/robot/running/builder/parsers.py | 26 +-- src/robot/running/builder/settings.py | 199 ++++++++++++------ src/robot/running/builder/transformers.py | 103 +++++---- 13 files changed, 252 insertions(+), 173 deletions(-) diff --git a/atest/robot/parsing/custom_parsers.robot b/atest/robot/parsing/custom_parsers.robot index c9c47d9647a..18981db7a3a 100644 --- a/atest/robot/parsing/custom_parsers.robot +++ b/atest/robot/parsing/custom_parsers.robot @@ -34,7 +34,7 @@ Override Robot parser Directory with init when parser does not support inits Parsing Should Fail init - ... Parsing '${DIR}' failed: + ... Parsing '${DIR}${/}__init__.init' failed: ... 'CustomParser' does not support parsing initialization files. Incompatible parser @@ -51,7 +51,7 @@ Failing parser ... Calling 'CustomParser.parse()' failed: ... TypeError: Ooops! Parsing Should Fail fail=True:init=True - ... Parsing '${DIR}' failed: + ... Parsing '${DIR}${/}__init__.init' failed: ... Calling 'CustomParser.parse_init()' failed: ... TypeError: Ooops in init! @@ -61,7 +61,7 @@ Bad return value ... Calling 'CustomParser.parse()' failed: ... TypeError: Return value should be 'robot.running.TestSuite', got 'string'. Parsing Should Fail bad_return=True:init=True - ... Parsing '${DIR}' failed: + ... Parsing '${DIR}${/}__init__.init' failed: ... Calling 'CustomParser.parse_init()' failed: ... TypeError: Return value should be 'robot.running.TestSuite', got 'integer'. diff --git a/atest/robot/parsing/test_case_settings.robot b/atest/robot/parsing/test_case_settings.robot index c1feb657afa..2ad222c2a9c 100644 --- a/atest/robot/parsing/test_case_settings.robot +++ b/atest/robot/parsing/test_case_settings.robot @@ -91,7 +91,7 @@ Empty and NONE tags are ignored Duplicate tags are ignored and first used format has precedence [Documentation] Case, space and underscore insensitive - Verify Tags FORCE-1 Test_1 test 2 + Verify Tags force-1 Test_1 test 2 Tags in multiple rows Verify Tags force-1 test-0 test-1 test-2 test-3 test-4 test-5 diff --git a/atest/testdata/parsing/custom/CustomParser.py b/atest/testdata/parsing/custom/CustomParser.py index 64c19ec1b5d..564c2ac20c0 100644 --- a/atest/testdata/parsing/custom/CustomParser.py +++ b/atest/testdata/parsing/custom/CustomParser.py @@ -1,16 +1,15 @@ from pathlib import Path from robot.api import TestSuite -from robot.running.builder.settings import Defaults +from robot.api.interfaces import Parser, TestDefaults import custom -class CustomParser: +class CustomParser(Parser): def __init__(self, extension='custom', parse=True, init=False, fail=False, bad_return=False): - print(extension) self.extension = extension.split(',') if extension else None if not parse: self.parse = None @@ -21,14 +20,14 @@ def __init__(self, extension='custom', parse=True, init=False, fail=False, self.fail = fail self.bad_return = bad_return - def parse(self, source: Path, defaults: Defaults) -> TestSuite: + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: if self.fail: raise TypeError('Ooops!') if self.bad_return: return 'bad' return custom.parse(source, defaults) - def parse_init(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: if self.fail: raise TypeError('Ooops in init!') if self.bad_return: diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 3d0a736429b..07d483a68ed 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -32,13 +32,17 @@ .. note:: These classes are not exposed via the top level :mod:`robot.api` package and need to imported via :mod:`robot.api.interfaces`. -New in Robot Framework 6.1. Requires Python 3.8 or newer. +.. note:: Using this module requires having the typing_extensions__ module + installed with Python 3.6 and 3.7. + +New in Robot Framework 6.1. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dynamic-library-api __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#hybrid-library-api __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-2 __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-3 __ FIXME: PARSER: Link to UG docs. +__ https://pypi.org/project/typing-extensions/ """ import sys @@ -49,7 +53,11 @@ if sys.version_info >= (3, 8): from typing import TypedDict else: - TypedDict = dict + try: + from typing_extensions import TypedDict + except ImportError: + raise ImportError("Using the 'robot.api.interfaces' module requires having " + "the 'typing_extensions' module installed with Python < 3.8.") if sys.version_info >= (3, 10): from types import UnionType else: @@ -57,12 +65,7 @@ from robot import result, running from robot.model import Message -from robot.running import TestSuite -# FIXME: PARSER: -# - Expose `Defaults` via `robot.running`. -# - Consider better class name. -# - Enhance its API (incl. docs and types). -from robot.running.builder.settings import Defaults +from robot.running import TestDefaults, TestSuite # Type aliases used by DynamicLibrary and HybridLibrary. @@ -588,14 +591,14 @@ class Parser(ABC): extension: Union[str, Sequence[str]] @abstractmethod - def parse(self, source: Path, defaults: Defaults) -> TestSuite: + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: """Mandatory method for parsing suite files. FIXME: PARSER: Better documentation (incl. parameter docs). """ raise NotImplementedError - def parse_init(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: """Optional method for parsing suite initialization files. FIXME: PARSER: Better documentation (incl. parameter docs). diff --git a/src/robot/model/tags.py b/src/robot/model/tags.py index 096ce30656d..00eb5d29f08 100644 --- a/src/robot/model/tags.py +++ b/src/robot/model/tags.py @@ -23,7 +23,10 @@ class Tags(Sequence[str]): __slots__ = ['_tags', '_reserved'] def __init__(self, tags: Sequence[str] = ()): - self._tags, self._reserved = self._init_tags(tags) + if isinstance(tags, Tags): + self._tags, self._reserved = tags._tags, tags._reserved + else: + self._tags, self._reserved = self._init_tags(tags) def robot(self, name: str) -> bool: """Check do tags contain a reserved tag in format `robot:<name>`. diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 428f8edab27..723b16b52ac 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -40,8 +40,7 @@ class TestSuite(ModelObject): test_class = TestCase #: Internal usage only. fixture_class = Keyword #: Internal usage only. repr_args = ('name',) - __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', - '_my_visitors'] + __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', '_my_visitors'] def __init__(self, name: str = '', doc: str = '', metadata: 'Mapping|None' = None, source: 'Path|str|None' = None, rpa: bool = False, diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 7467f503dbb..6b3572334c7 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -24,7 +24,7 @@ class SuiteStructure: - def __init__(self, source: Path = None, init_file: Path = None, + def __init__(self, source: 'Path|None' = None, init_file: 'Path|None' = None, children: 'list[SuiteStructure]|None' = None): self.source = source self.init_file = init_file @@ -39,6 +39,10 @@ def extension(self) -> 'str|None': def is_file(self) -> bool: return self.children is None + @property + def is_multi_source(self) -> bool: + return self.source is None + def add(self, child: 'SuiteStructure'): self.children.append(child) diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index 23d35f657af..18d1be21d77 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -102,7 +102,7 @@ """ from .arguments import ArgInfo, ArgumentSpec, TypeConverter, TypeInfo -from .builder import ResourceFileBuilder, TestSuiteBuilder +from .builder import ResourceFileBuilder, TestDefaults, TestSuiteBuilder from .context import EXECUTION_CONTEXTS from .model import (Break, Continue, Error, For, If, IfBranch, Keyword, Return, TestCase, TestSuite, Try, TryBranch, While) diff --git a/src/robot/running/builder/__init__.py b/src/robot/running/builder/__init__.py index cfe3cdf8ea1..19192b3554f 100644 --- a/src/robot/running/builder/__init__.py +++ b/src/robot/running/builder/__init__.py @@ -15,3 +15,4 @@ from .builders import TestSuiteBuilder, ResourceFileBuilder from .parsers import RobotParser +from .settings import TestDefaults diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 8ca8dfdb198..0c3a6707386 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -27,7 +27,7 @@ from ..model import ResourceFile, TestSuite from .parsers import (CustomParser, JsonParser, NoInitFileDirectoryParser, Parser, RestParser, RobotParser) -from .settings import Defaults +from .settings import TestDefaults class TestSuiteBuilder: @@ -173,7 +173,11 @@ def __init__(self, parsers: 'dict[str, Parser]', rpa: 'bool|None' = None): self.rpa = rpa self._rpa_given = rpa is not None self.suite: 'TestSuite|None' = None - self._stack: 'list[tuple[TestSuite, Defaults]]' = [] + self._stack: 'list[tuple[TestSuite, TestDefaults]]' = [] + + @property + def parent_defaults(self) -> 'TestDefaults|None': + return self._stack[-1][-1] if self._stack else None def parse(self, structure: SuiteStructure) -> TestSuite: structure.visit(self) @@ -182,7 +186,7 @@ def parse(self, structure: SuiteStructure) -> TestSuite: def visit_file(self, structure: SuiteStructure): LOGGER.info(f"Parsing file '{structure.source}'.") - suite, _ = self._build_suite(structure) + suite = self._build_suite_file(structure) if self.suite is None: self.suite = suite else: @@ -191,7 +195,7 @@ def visit_file(self, structure: SuiteStructure): def start_directory(self, structure: SuiteStructure): if structure.source: LOGGER.info(f"Parsing directory '{structure.source}'.") - suite, defaults = self._build_suite(structure) + suite, defaults = self._build_suite_directory(structure) if self.suite is None: self.suite = suite else: @@ -203,23 +207,29 @@ def end_directory(self, structure: SuiteStructure): if suite.rpa is None and suite.suites: suite.rpa = suite.suites[0].rpa - def _build_suite(self, structure: SuiteStructure) -> 'tuple[TestSuite, Defaults]': - parent_defaults = self._stack[-1][-1] if self._stack else None + def _build_suite_file(self, structure: SuiteStructure): source = structure.source - defaults = Defaults(parent_defaults) + defaults = self.parent_defaults or TestDefaults() parser = self.parsers[structure.extension] try: - if structure.is_file: - suite = parser.parse_suite_file(source, defaults) - if not suite.tests: - LOGGER.info(f"Data source '{source}' has no tests or tasks.") - else: - suite = parser.parse_init_file(structure.init_file or source, defaults) - if not source: - suite.config(name='', source=None) + suite = parser.parse_suite_file(source, defaults) + if not suite.tests: + LOGGER.info(f"Data source '{source}' has no tests or tasks.") self._validate_execution_mode(suite) except DataError as err: raise DataError(f"Parsing '{source}' failed: {err.message}") + return suite + + def _build_suite_directory(self, structure: SuiteStructure): + source = structure.init_file or structure.source + defaults = TestDefaults(self.parent_defaults) + parser = self.parsers[structure.extension] + try: + suite = parser.parse_init_file(source, defaults) + if structure.is_multi_source: + suite.config(name='', source=None) + except DataError as err: + raise DataError(f"Parsing '{source}' failed: {err.message}") return suite, defaults def _validate_execution_mode(self, suite: TestSuite): diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index b07ac9fbf03..c907d876d94 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -21,7 +21,7 @@ from robot.parsing import File, get_init_model, get_model, get_resource_model from robot.utils import FileReader, get_error_message, read_rest_data, type_name -from .settings import Defaults +from .settings import FileSettings, InitFileSettings, TestDefaults from .transformers import ResourceBuilder, SuiteBuilder from ..model import ResourceFile, TestSuite @@ -32,10 +32,10 @@ class Parser(ABC): def name(self) -> str: return type(self).__name__ - def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_suite_file(self, source: Path, defaults: TestDefaults) -> TestSuite: raise DataError(f"'{self.name}' does not support parsing suite files.") - def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: raise DataError(f"'{self.name}' does not support parsing initialization files.") def parse_resource_file(self, source: Path) -> ResourceFile: @@ -48,25 +48,25 @@ def __init__(self, lang: LanguagesLike = None, process_curdir: bool = True): self.lang = lang self.process_curdir = process_curdir - def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_suite_file(self, source: Path, defaults: TestDefaults) -> TestSuite: model = get_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) suite = TestSuite(name=TestSuite.name_from_source(source), source=source) - SuiteBuilder(suite, defaults).build(model) + SuiteBuilder(suite, FileSettings(defaults)).build(model) return suite - def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: model = get_init_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) directory = source.parent suite = TestSuite(name=TestSuite.name_from_source(directory), source=directory) - SuiteBuilder(suite, defaults).build(model) + SuiteBuilder(suite, InitFileSettings(defaults)).build(model) return suite def parse_model(self, model: File) -> TestSuite: source = model.source suite = TestSuite(name=TestSuite.name_from_source(source), source=source) - SuiteBuilder(suite).build(model) + SuiteBuilder(suite, FileSettings()).build(model) return suite def _get_curdir(self, source: Path) -> 'str|None': @@ -92,10 +92,10 @@ def _get_source(self, source: Path) -> str: class JsonParser(Parser): - def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_suite_file(self, source: Path, defaults: TestDefaults) -> TestSuite: return TestSuite.from_json(source) - def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: return TestSuite.from_json(source) # FIXME: Resource imports don't otherwise support JSON yet! @@ -105,7 +105,7 @@ def parse_resource_file(self, source: Path) -> ResourceFile: class NoInitFileDirectoryParser(Parser): - def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: return TestSuite(name=TestSuite.name_from_source(source), source=source) @@ -130,10 +130,10 @@ def extensions(self) -> 'tuple[str]': extensions = [ext] if isinstance(ext, str) else list(ext or ()) return tuple(ext.lower().lstrip('.') for ext in extensions) - def parse_suite_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_suite_file(self, source: Path, defaults: TestDefaults) -> TestSuite: return self._parse(self.parser.parse, source, defaults) - def parse_init_file(self, source: Path, defaults: Defaults) -> TestSuite: + def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: parse_init = getattr(self.parser, 'parse_init', None) try: return self._parse(parse_init, source, defaults, init=True) diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index 20b84e853be..42a6e47d8c7 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -13,56 +13,92 @@ # See the License for the specific language governing permissions and # limitations under the License. -NOTSET = object() +from collections.abc import Sequence +try: + from typing import TypedDict +except ImportError: + try: + from typing_extensions import TypedDict + except ImportError: + TypedDict = dict +from robot.model import Tags -class Defaults: +from ..model import Keyword - def __init__(self, parent=None): + +class KeywordDict(TypedDict): + """Dictionary to create setup or teardown from. + + :attr:`args` and :attr:`lineno` are optional. + """ + # `args` and `lineno` are not marked optional, because that would be hard + # until we require Python 3.8 and ugly until Python 3.11. + name: str + args: 'Sequence[str]' + lineno: int + + +class TestDefaults: + """Represents default values for test related settings set in init files. + + Parsers parsing suite files can read defaults and parsers parsing init + files can set them. + """ + + def __init__(self, parent: 'TestDefaults|None' = None): self.parent = parent - self._setup = {} - self._teardown = {} - self._force_tags = () - self.default_tags = () - self.keyword_tags = () - self.template = None - self._timeout = None + self.setup = None + self.teardown = None + self.tags = () + self.timeout = None @property - def setup(self): + def setup(self) -> 'Keyword|None': + """Setup as a `Keyword` object or `None` when not set. + + Can be set also using a dictionary. + """ if self._setup: return self._setup if self.parent: return self.parent.setup - return {} + return None @setup.setter - def setup(self, setup): + def setup(self, setup: 'Keyword|KeywordDict|None'): + if isinstance(setup, dict): + setup = Keyword.from_dict(setup) self._setup = setup @property - def teardown(self): + def teardown(self) -> 'Keyword|None': + """Teardown as a `Keyword` object or `None` when not set. + + Can be set also using a dictionary. + """ if self._teardown: return self._teardown if self.parent: return self.parent.teardown - return {} + return None @teardown.setter - def teardown(self, teardown): + def teardown(self, teardown: 'Keyword|KeywordDict|None'): + if isinstance(teardown, dict): + teardown = Keyword.from_dict(teardown) self._teardown = teardown @property - def force_tags(self): - parent_force_tags = self.parent.force_tags if self.parent else () - return self._force_tags + parent_force_tags + def tags(self) -> Tags: + return self._tags + self.parent.tags if self.parent else self._tags - @force_tags.setter - def force_tags(self, force_tags): - self._force_tags = force_tags + @tags.setter + def tags(self, tags: 'Sequence[str]'): + self._tags = Tags(tags) @property - def timeout(self): + def timeout(self) -> 'str|None': if self._timeout: return self._timeout if self.parent: @@ -70,68 +106,93 @@ def timeout(self): return None @timeout.setter - def timeout(self, timeout): + def timeout(self, timeout: 'str|None'): self._timeout = timeout -class TestSettings: +class FileSettings: - def __init__(self, defaults): - self.defaults = defaults - self._setup = NOTSET - self._teardown = NOTSET - self._timeout = NOTSET - self._template = NOTSET - self._tags = NOTSET + def __init__(self, test_defaults: 'TestDefaults|None' = None): + self.test_defaults = test_defaults or TestDefaults() + self._test_setup = None + self._test_teardown = None + self._test_tags = Tags() + self._test_timeout = None + self._test_template = None + self._default_tags = Tags() + self._keyword_tags = Tags() @property - def setup(self): - if self._setup is NOTSET: - return self.defaults.setup - return self._setup + def test_setup(self) -> 'Keyword|None': + return self._test_setup or self.test_defaults.setup - @setup.setter - def setup(self, setup): - self._setup = setup + @test_setup.setter + def test_setup(self, setup: KeywordDict): + self._test_setup = Keyword.from_dict(setup) @property - def teardown(self): - if self._teardown is NOTSET: - return self.defaults.teardown - return self._teardown + def test_teardown(self) -> 'Keyword|None': + return self._test_teardown or self.test_defaults.teardown - @teardown.setter - def teardown(self, teardown): - self._teardown = teardown + @test_teardown.setter + def test_teardown(self, teardown: KeywordDict): + self._test_teardown = Keyword.from_dict(teardown) @property - def timeout(self): - if self._timeout is NOTSET: - return self.defaults.timeout - return self._timeout + def test_tags(self) -> Tags: + return self._test_tags + self.test_defaults.tags - @timeout.setter - def timeout(self, timeout): - self._timeout = timeout + @test_tags.setter + def test_tags(self, tags: 'Sequence[str]'): + self._test_tags = Tags(tags) @property - def template(self): - if self._template is NOTSET: - return self.defaults.template - return self._template + def test_timeout(self) -> 'str|None': + return self._test_timeout or self.test_defaults.timeout - @template.setter - def template(self, template): - self._template = template + @test_timeout.setter + def test_timeout(self, timeout: str): + self._test_timeout = timeout @property - def tags(self): - if self._tags is NOTSET: - tags = self.defaults.default_tags - else: - tags = self._tags - return tags + self.defaults.force_tags + def test_template(self) -> 'str|None': + return self._test_template - @tags.setter - def tags(self, tags): - self._tags = tags + @test_template.setter + def test_template(self, template: str): + self._test_template = template + + @property + def default_tags(self) -> Tags: + return self._default_tags + + @default_tags.setter + def default_tags(self, tags: 'Sequence[str]'): + self._default_tags = Tags(tags) + + @property + def keyword_tags(self) -> Tags: + return self._keyword_tags + + @keyword_tags.setter + def keyword_tags(self, tags: 'Sequence[str]'): + self._keyword_tags = Tags(tags) + + +class InitFileSettings(FileSettings): + + @FileSettings.test_setup.setter + def test_setup(self, setup: KeywordDict): + self.test_defaults.setup = setup + + @FileSettings.test_teardown.setter + def test_teardown(self, teardown: KeywordDict): + self.test_defaults.teardown = teardown + + @FileSettings.test_tags.setter + def test_tags(self, tags: 'Sequence[str]'): + self.test_defaults.tags = tags + + @FileSettings.test_timeout.setter + def test_timeout(self, timeout: str): + self.test_defaults.timeout = timeout diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 8268ccd9b57..38bfe93ca1a 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -20,15 +20,15 @@ from robot.parsing import File, Token from robot.variables import VariableIterator -from .settings import Defaults, TestSettings +from .settings import FileSettings from ..model import ResourceFile, TestSuite class SettingsBuilder(NodeVisitor): - def __init__(self, suite: TestSuite, defaults: Defaults): + def __init__(self, suite: TestSuite, settings: FileSettings): self.suite = suite - self.defaults = defaults + self.settings = settings def visit_Documentation(self, node): self.suite.doc = node.value @@ -48,29 +48,27 @@ def visit_SuiteTeardown(self, node): lineno=node.lineno) def visit_TestSetup(self, node): - self.defaults.setup = { - 'name': node.name, 'args': node.args, 'lineno': node.lineno - } + self.settings.test_setup = dict(name=node.name, args=node.args, + lineno=node.lineno) def visit_TestTeardown(self, node): - self.defaults.teardown = { - 'name': node.name, 'args': node.args, 'lineno': node.lineno - } + self.settings.test_teardown = dict(name=node.name, args=node.args, + lineno=node.lineno) def visit_TestTimeout(self, node): - self.defaults.timeout = node.value + self.settings.test_timeout = node.value def visit_DefaultTags(self, node): - self.defaults.default_tags = node.values + self.settings.default_tags = node.values def visit_ForceTags(self, node): - self.defaults.force_tags = node.values + self.settings.test_tags = node.values def visit_KeywordTags(self, node): - self.defaults.keyword_tags = node.values + self.settings.keyword_tags = node.values def visit_TestTemplate(self, node): - self.defaults.template = node.value + self.settings.test_template = node.value def visit_LibraryImport(self, node): self.suite.resource.imports.library(node.name, node.args, node.alias, node.lineno) @@ -93,14 +91,14 @@ def visit_KeywordSection(self, node): class SuiteBuilder(NodeVisitor): - def __init__(self, suite: TestSuite, defaults: Defaults = None): + def __init__(self, suite: TestSuite, settings: FileSettings): self.suite = suite - self.defaults = defaults or Defaults() + self.settings = settings self.rpa = None def build(self, model: File): ErrorReporter(model.source).visit(model) - SettingsBuilder(self.suite, self.defaults).visit(model) + SettingsBuilder(self.suite, self.settings).visit(model) self.visit(model) if self.rpa is not None: self.suite.rpa = self.rpa @@ -122,17 +120,17 @@ def visit_TestCaseSection(self, node): self.generic_visit(node) def visit_TestCase(self, node): - TestCaseBuilder(self.suite, self.defaults).visit(node) + TestCaseBuilder(self.suite, self.settings).visit(node) def visit_Keyword(self, node): - KeywordBuilder(self.suite.resource, self.defaults).visit(node) + KeywordBuilder(self.suite.resource, self.settings).visit(node) class ResourceBuilder(NodeVisitor): def __init__(self, resource: ResourceFile): self.resource = resource - self.defaults = Defaults() + self.settings = FileSettings() def build(self, model: File): ErrorReporter(model.source, raise_on_invalid_header=True).visit(model) @@ -142,7 +140,7 @@ def visit_Documentation(self, node): self.resource.doc = node.value def visit_KeywordTags(self, node): - self.defaults.keyword_tags = node.values + self.settings.keyword_tags = node.values def visit_LibraryImport(self, node): self.resource.imports.library(node.name, node.args, node.alias, node.lineno) @@ -160,34 +158,39 @@ def visit_Variable(self, node): error=format_error(node.errors)) def visit_Keyword(self, node): - KeywordBuilder(self.resource, self.defaults).visit(node) + KeywordBuilder(self.resource, self.settings).visit(node) class TestCaseBuilder(NodeVisitor): - def __init__(self, suite: TestSuite, defaults: Defaults): + def __init__(self, suite: TestSuite, settings: FileSettings): self.suite = suite - self.settings = TestSettings(defaults) + self.settings = settings self.test = None + self.tags = None def visit_TestCase(self, node): - self.test = self.suite.tests.create(name=node.name, lineno=node.lineno, - error=format_error(node.errors + node.header.errors)) + error = format_error(node.errors + node.header.errors) + settings = self.settings + self.test = self.suite.tests.create(name=node.name, + lineno=node.lineno, + tags=settings.test_tags, + timeout=settings.test_timeout, + template=settings.test_template, + error=error) + if settings.test_setup: + self.test.setup.config(name=settings.test_setup.name, + args=settings.test_setup.args, + lineno=settings.test_setup.lineno) + if settings.test_teardown: + self.test.teardown.config(name=settings.test_teardown.name, + args=settings.test_teardown.args, + lineno=settings.test_teardown.lineno) self.generic_visit(node) - self._set_settings(self.test, self.settings) - - def _set_settings(self, test, settings): - if settings.setup: - test.setup.config(**settings.setup) - if settings.teardown: - test.teardown.config(**settings.teardown) - if settings.timeout: - test.timeout = settings.timeout - if settings.tags: - test.tags = settings.tags - if settings.template: - test.template = settings.template - self._set_template(test, settings.template) + tags = self.tags if self.tags is not None else settings.default_tags + self.test.tags.add(tags) + if self.test.template: + self._set_template(self.test, self.test.template) def _set_template(self, parent, template): for item in parent.body: @@ -231,24 +234,20 @@ def visit_Documentation(self, node): self.test.doc = node.value def visit_Setup(self, node): - self.settings.setup = { - 'name': node.name, 'args': node.args, 'lineno': node.lineno - } + self.test.setup.config(name=node.name, args=node.args, lineno=node.lineno) def visit_Teardown(self, node): - self.settings.teardown = { - 'name': node.name, 'args': node.args, 'lineno': node.lineno - } + self.test.teardown.config(name=node.name, args=node.args, lineno=node.lineno) def visit_Timeout(self, node): - self.settings.timeout = node.value + self.test.timeout = node.value def visit_Tags(self, node): deprecate_tags_starting_with_hyphen(node, self.suite.source) - self.settings.tags = node.values + self.tags = node.values def visit_Template(self, node): - self.settings.template = node.value + self.test.template = node.value def visit_KeywordCall(self, node): self.test.body.create_keyword(name=node.keyword, args=node.args, @@ -273,15 +272,15 @@ def visit_Error(self, node): class KeywordBuilder(NodeVisitor): - def __init__(self, resource: ResourceFile, defaults: Defaults): + def __init__(self, resource: ResourceFile, settings: FileSettings): self.resource = resource - self.defaults = defaults + self.settings = settings self.kw = None def visit_Keyword(self, node): error = format_error(node.errors + node.header.errors) self.kw = self.resource.keywords.create(name=node.name, - tags=self.defaults.keyword_tags, + tags=self.settings.keyword_tags, lineno=node.lineno, error=error) self.generic_visit(node) From 88011990287451b9ea535072d3d498e1a6434a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 25 Apr 2023 22:42:16 +0300 Subject: [PATCH 0510/1592] Test custom parsers using TestDefaults (#1283) --- atest/robot/parsing/custom_parsers.robot | 19 +++++++++++++++++-- atest/testdata/parsing/custom/CustomParser.py | 12 +++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/atest/robot/parsing/custom_parsers.robot b/atest/robot/parsing/custom_parsers.robot index 18981db7a3a..1de2526f75e 100644 --- a/atest/robot/parsing/custom_parsers.robot +++ b/atest/robot/parsing/custom_parsers.robot @@ -20,7 +20,7 @@ Directory Directory with init Run Tests --parser ${DIR}/CustomParser.py:init=True ${DIR} - Validate Directory Suite 📁 custom=True + Validate Directory Suite 📁 custom=True init=True Override Robot parser Run Tests --parser ${DIR}/CustomParser.py:.robot ${DIR}/tests.robot @@ -79,7 +79,7 @@ Validate Suite Should Contain Tests ${suite} &{tests} Validate Directory Suite - [Arguments] ${name} ${custom}=True + [Arguments] ${name} ${custom}=True ${init}=False Validate Suite ${SUITE} ${name} ${DIR} ${custom} ... Passing=PASS ... Failing=FAIL:Error message @@ -94,6 +94,21 @@ Validate Directory Suite ... Empty=FAIL:Test cannot be empty. Validate Suite ${SUITE.suites[2]} Tests ${DIR}/tests.robot custom=False ... Test in Robot file=PASS + FOR ${test} IN @{SUITE.all_tests} + IF ${init} + Should Contain Tags ${test} tag from init + Should Be Equal ${test.timeout} 42 seconds + IF '${test.name}' != 'Empty' + Check Log Message ${test.setup.msgs[0]} setup from init + Check Log Message ${test.teardown.msgs[0]} teardown from init + END + ELSE + Should Not Be True ${test.tags} + Should Not Be True ${test.timeout} + Should Not Be True ${test.setup} + Should Not Be True ${test.teardown} + END + END Parsing should fail [Arguments] ${config} @{error} diff --git a/atest/testdata/parsing/custom/CustomParser.py b/atest/testdata/parsing/custom/CustomParser.py index 564c2ac20c0..a6b55488351 100644 --- a/atest/testdata/parsing/custom/CustomParser.py +++ b/atest/testdata/parsing/custom/CustomParser.py @@ -25,11 +25,21 @@ def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: raise TypeError('Ooops!') if self.bad_return: return 'bad' - return custom.parse(source, defaults) + suite = custom.parse(source, None) + for test in suite.tests: + test.tags += defaults.tags + test.setup = defaults.setup + test.teardown = defaults.teardown + test.timeout = defaults.timeout + return suite def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: if self.fail: raise TypeError('Ooops in init!') if self.bad_return: return 42 + defaults.tags = ['tag from init'] + defaults.setup = {'name': 'Log', 'args': ['setup from init']} + defaults.teardown = {'name': 'Log', 'args': ['teardown from init']} + defaults.timeout = '42s' return TestSuite(name='📁', source=source.parent, metadata={'Parser': 'Custom'}) From b35e709cd107eeae77f93b89d2990d3b61f95a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 25 Apr 2023 23:43:39 +0300 Subject: [PATCH 0511/1592] regen --- doc/api/autodoc/robot.htmldata.common.rst | 7 +++++++ doc/api/autodoc/robot.htmldata.lib.rst | 7 +++++++ doc/api/autodoc/robot.htmldata.libdoc.rst | 7 +++++++ doc/api/autodoc/robot.htmldata.rebot.rst | 7 +++++++ doc/api/autodoc/robot.htmldata.rst | 12 ++++++++++++ doc/api/autodoc/robot.htmldata.testdoc.rst | 7 +++++++ 6 files changed, 47 insertions(+) create mode 100644 doc/api/autodoc/robot.htmldata.common.rst create mode 100644 doc/api/autodoc/robot.htmldata.lib.rst create mode 100644 doc/api/autodoc/robot.htmldata.libdoc.rst create mode 100644 doc/api/autodoc/robot.htmldata.rebot.rst create mode 100644 doc/api/autodoc/robot.htmldata.testdoc.rst diff --git a/doc/api/autodoc/robot.htmldata.common.rst b/doc/api/autodoc/robot.htmldata.common.rst new file mode 100644 index 00000000000..d46bf1441eb --- /dev/null +++ b/doc/api/autodoc/robot.htmldata.common.rst @@ -0,0 +1,7 @@ +robot.htmldata.common package +============================= + +.. automodule:: robot.htmldata.common + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/autodoc/robot.htmldata.lib.rst b/doc/api/autodoc/robot.htmldata.lib.rst new file mode 100644 index 00000000000..dbf724f013b --- /dev/null +++ b/doc/api/autodoc/robot.htmldata.lib.rst @@ -0,0 +1,7 @@ +robot.htmldata.lib package +========================== + +.. automodule:: robot.htmldata.lib + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/autodoc/robot.htmldata.libdoc.rst b/doc/api/autodoc/robot.htmldata.libdoc.rst new file mode 100644 index 00000000000..927410e9e12 --- /dev/null +++ b/doc/api/autodoc/robot.htmldata.libdoc.rst @@ -0,0 +1,7 @@ +robot.htmldata.libdoc package +============================= + +.. automodule:: robot.htmldata.libdoc + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/autodoc/robot.htmldata.rebot.rst b/doc/api/autodoc/robot.htmldata.rebot.rst new file mode 100644 index 00000000000..ab68b9e2bda --- /dev/null +++ b/doc/api/autodoc/robot.htmldata.rebot.rst @@ -0,0 +1,7 @@ +robot.htmldata.rebot package +============================ + +.. automodule:: robot.htmldata.rebot + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/autodoc/robot.htmldata.rst b/doc/api/autodoc/robot.htmldata.rst index 53f0daac1a4..980399cfc80 100644 --- a/doc/api/autodoc/robot.htmldata.rst +++ b/doc/api/autodoc/robot.htmldata.rst @@ -6,6 +6,18 @@ robot.htmldata package :undoc-members: :show-inheritance: +Subpackages +----------- + +.. toctree:: + :maxdepth: 2 + + robot.htmldata.common + robot.htmldata.lib + robot.htmldata.libdoc + robot.htmldata.rebot + robot.htmldata.testdoc + Submodules ---------- diff --git a/doc/api/autodoc/robot.htmldata.testdoc.rst b/doc/api/autodoc/robot.htmldata.testdoc.rst new file mode 100644 index 00000000000..0c3f1234e32 --- /dev/null +++ b/doc/api/autodoc/robot.htmldata.testdoc.rst @@ -0,0 +1,7 @@ +robot.htmldata.testdoc package +============================== + +.. automodule:: robot.htmldata.testdoc + :members: + :undoc-members: + :show-inheritance: From 168ade43a2c7a3ba29b3f1a343c3b0cd00214cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 25 Apr 2023 23:43:48 +0300 Subject: [PATCH 0512/1592] Make TestDefauls optional with custom parsers. Also add/enhance API docs. Part of #1283. --- atest/testdata/parsing/custom/CustomParser.py | 2 +- atest/testdata/parsing/custom/custom.py | 2 +- src/robot/api/__init__.py | 2 +- src/robot/api/interfaces.py | 17 +++++++++++++---- src/robot/running/builder/parsers.py | 6 ++++-- src/robot/running/builder/settings.py | 6 ++++-- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/atest/testdata/parsing/custom/CustomParser.py b/atest/testdata/parsing/custom/CustomParser.py index a6b55488351..e675b1e9fe9 100644 --- a/atest/testdata/parsing/custom/CustomParser.py +++ b/atest/testdata/parsing/custom/CustomParser.py @@ -25,7 +25,7 @@ def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: raise TypeError('Ooops!') if self.bad_return: return 'bad' - suite = custom.parse(source, None) + suite = custom.parse(source) for test in suite.tests: test.tags += defaults.tags test.setup = defaults.setup diff --git a/atest/testdata/parsing/custom/custom.py b/atest/testdata/parsing/custom/custom.py index c258d5c8277..8f90ae11b7d 100644 --- a/atest/testdata/parsing/custom/custom.py +++ b/atest/testdata/parsing/custom/custom.py @@ -7,7 +7,7 @@ extension = 'ignored' -def parse(source, defaults): +def parse(source): suite = TestSuite(source=source, metadata={'Parser': 'Custom'}) for line in source.read_text().splitlines(): if not line or line[0] in ('*', '#'): diff --git a/src/robot/api/__init__.py b/src/robot/api/__init__.py index 047bd377268..47ef4c87c10 100644 --- a/src/robot/api/__init__.py +++ b/src/robot/api/__init__.py @@ -31,7 +31,7 @@ via :mod:`robot.api` like ``from robot.api import SkipExecution``. * :mod:`.interfaces` module containing optional base classes that can be used - when creating libraries or listeners. New in Robot Framework 6.1. + when creating libraries and other extensions. New in Robot Framework 6.1. * :mod:`.parsing` module exposing the parsing APIs. This module is new in Robot Framework 4.0. Various parsing related functions and classes were exposed diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 07d483a68ed..8ef94da2085 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -582,7 +582,7 @@ class Parser(ABC): The mandatory :attr:`extension` attribute specifies what file extension or extensions a parser supports. It can be set either as a class or instance - attribute, and it can be either a string or a list/tuple of strings. The + attribute, and it can be either a string or a sequence of strings. The attribute can also be named ``EXTENSION``, which typically works better when a parser is implemented as a module. @@ -594,15 +594,24 @@ class Parser(ABC): def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: """Mandatory method for parsing suite files. - FIXME: PARSER: Better documentation (incl. parameter docs). + :param source: Path to the file to parse. + :param defaults: Default values set for test in init files. + + The ``defaults`` argument is optional. It is possible to implement + this method also so that it accepts only ``source``. """ raise NotImplementedError def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: """Optional method for parsing suite initialization files. - FIXME: PARSER: Better documentation (incl. parameter docs). + :param source: Path to the file to parse. + :param defaults: Default values to used with tests in child suites. + + The ``defaults`` argument is optional. It is possible to implement + this method also so that it accepts only ``source``. - If not implemented, possible initialization files cause an error. + If this method is not implemented, possible initialization files cause + an error. """ raise NotImplementedError diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index c907d876d94..4db672c6669 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -14,6 +14,7 @@ # limitations under the License. from abc import ABC +from inspect import signature from pathlib import Path from robot.conf import LanguagesLike @@ -140,11 +141,12 @@ def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: except NotImplementedError: return super().parse_init_file(source, defaults) # Raises DataError - def _parse(self, method, *args, init=False) -> TestSuite: + def _parse(self, method, source, defaults, init=False) -> TestSuite: if not method: raise NotImplementedError + accepts_defaults = len(signature(method).parameters) == 2 try: - suite = method(*args) + suite = method(source, defaults) if accepts_defaults else method(source) if not isinstance(suite, TestSuite): raise TypeError(f"Return value should be 'robot.running.TestSuite', " f"got '{type_name(suite)}'.") diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index 42a6e47d8c7..fe65a37920e 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -55,7 +55,7 @@ def __init__(self, parent: 'TestDefaults|None' = None): @property def setup(self) -> 'Keyword|None': - """Setup as a `Keyword` object or `None` when not set. + """Default setup as a ``Keyword`` object or ``None`` when not set. Can be set also using a dictionary. """ @@ -73,7 +73,7 @@ def setup(self, setup: 'Keyword|KeywordDict|None'): @property def teardown(self) -> 'Keyword|None': - """Teardown as a `Keyword` object or `None` when not set. + """Default teardown as a ``Keyword`` object or ``None`` when not set. Can be set also using a dictionary. """ @@ -91,6 +91,7 @@ def teardown(self, teardown: 'Keyword|KeywordDict|None'): @property def tags(self) -> Tags: + """Default tags. Can be set also as a sequence.""" return self._tags + self.parent.tags if self.parent else self._tags @tags.setter @@ -99,6 +100,7 @@ def tags(self, tags: 'Sequence[str]'): @property def timeout(self) -> 'str|None': + """Default timeout.""" if self._timeout: return self._timeout if self.parent: From c72f777bd44367c991c780ecc8b28b683229324d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 26 Apr 2023 00:52:54 +0300 Subject: [PATCH 0513/1592] WHILE loop fixes. - Fix limit in teardown. Fixes #4744. - Fix continuable failures with `on_limit=pass`. #4562 - Nicer formatting of limit max time in error. --- atest/robot/running/while/on_limit.robot | 2 +- atest/robot/running/while/while_limit.robot | 9 +++ atest/testdata/running/while/on_limit.robot | 39 ++++++++---- .../testdata/running/while/while_limit.robot | 62 ++++++++++++++++--- src/robot/running/bodyrunner.py | 16 ++--- 5 files changed, 99 insertions(+), 29 deletions(-) diff --git a/atest/robot/running/while/on_limit.robot b/atest/robot/running/while/on_limit.robot index 4e13375a11e..e64304395d6 100644 --- a/atest/robot/running/while/on_limit.robot +++ b/atest/robot/running/while/on_limit.robot @@ -54,7 +54,7 @@ Nested while on limit message On limit message before limit Check Test Case ${TESTNAME} -On limit messge with invalid variable +On limit message with invalid variable Check Test Case ${TESTNAME} Wrong WHILE arguments diff --git a/atest/robot/running/while/while_limit.robot b/atest/robot/running/while/while_limit.robot index b00b40c1c4c..0918cc2a75e 100644 --- a/atest/robot/running/while/while_limit.robot +++ b/atest/robot/running/while/while_limit.robot @@ -32,6 +32,15 @@ Limit can be disabled No Condition With Limit Check Test Case ${TESTNAME} +Limit exceeds in teardown + Check Test Case ${TESTNAME} + +Limit exceeds after failures in teardown + Check Test Case ${TESTNAME} + +Continue after limit in teardown + Check Test Case ${TESTNAME} + Invalid limit invalid suffix Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/while/on_limit.robot b/atest/testdata/running/while/on_limit.robot index e1aba584f5e..5006272ab87 100644 --- a/atest/testdata/running/while/on_limit.robot +++ b/atest/testdata/running/while/on_limit.robot @@ -4,6 +4,7 @@ ${limit} 11 ${number} ${0.2} ${pass} Pass ${errorMsg} Error Message +${USE LIMIT} Use the 'limit' argument to increase or remove the limit if needed. *** Test Cases *** On limit pass with time limit defined @@ -18,7 +19,7 @@ On limit pass with iteration limit defined END On limit fail - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 5 iterations. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 5 iterations. ${USE LIMIT} WHILE True limit=5 on_limit=FaIl No Operation END @@ -30,21 +31,34 @@ On limit pass with failures in loop END On limit pass with continuable failure - [Documentation] FAIL Third failure, this time a hard one. + [Documentation] FAIL Several failures occurred: + ... + ... 1) Continuable failure! + ... + ... 2) Continuable failure! + ... + ... 3) One more failure! + [Tags] robot:continue-on-failure WHILE limit=2 on_limit=pass - Run Keyword And Continue On Failure Fail Continuable failure! + Fail Continuable failure! END - Fail Third failure, this time a hard one. + Fail One more failure! On limit fail with continuable failure - [Documentation] FAIL Several failures occurred:\n\n - ... 1) Continuable failure!\n\n - ... 2) Continuable failure!\n\n - ... 3) WHILE loop was aborted because it did not finish within the limit of 2 iterations. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL Several failures occurred: + ... + ... 1) Continuable failure! + ... + ... 2) Continuable failure! + ... + ... 3) WHILE loop was aborted because it did not finish within the limit of 2 iterations. ${USE LIMIT} + ... + ... 4) One more failure! + [Tags] robot:continue-on-failure WHILE limit=2 on_limit=fail - Run Keyword And Continue On Failure Fail Continuable failure! + Fail Continuable failure! END - Fail Should not be executed! + Fail One more failure! Invalid on_limit [Documentation] FAIL Invalid WHILE loop 'on_limit' value 'inValid': Value must be 'PASS' or 'FAIL'. @@ -59,7 +73,7 @@ On limit without limit defined END On limit with invalid variable - [Documentation] FAIL Invalid WHILE loop 'on_limit' value '${does not exist}': Variable '${does not exist}' not found. + [Documentation] FAIL Invalid WHILE loop 'on_limit' value '\${does not exist}': Variable '\${does not exist}' not found. WHILE True limit=5 on_limit=${does not exist} Fail Oh no! END @@ -114,13 +128,12 @@ On limit message before limit Log ${variable} END -On limit messge with invalid variable +On limit message with invalid variable [Documentation] FAIL Invalid WHILE loop 'on_limit_message': 'Variable '${nonExisting}' not found. WHILE $variable < 2 on_limit_message=${nonExisting} limit=5 Log ${variable} END - Wrong WHILE arguments [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limite=5' and 'limit_exceed_messag=Custom error message'. WHILE $variable < 2 limite=5 limit_exceed_messag=Custom error message diff --git a/atest/testdata/running/while/while_limit.robot b/atest/testdata/running/while/while_limit.robot index 7ff329e745e..216c6f9adbf 100644 --- a/atest/testdata/running/while/while_limit.robot +++ b/atest/testdata/running/while/while_limit.robot @@ -2,46 +2,47 @@ ${variable} ${1} ${limit} 11 ${number} ${0.2} +${USE LIMIT} Use the 'limit' argument to increase or remove the limit if needed. *** Test Cases *** Default limit is 10000 iterations - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 10000 iterations. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 10000 iterations. ${USE LIMIT} WHILE $variable < 2 Log ${variable} END Limit with iteration count - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 5 iterations. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 5 iterations. ${USE LIMIT} WHILE $variable < 2 limit=5 Log ${variable} END Limit with iteration count with spaces - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 30 iterations. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 30 iterations. ${USE LIMIT} WHILE $variable < 2 limit=3 0 Log ${variable} END Limit with iteration count with underscore - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 10 iterations. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 10 iterations. ${USE LIMIT} WHILE $variable < 2 limit=1_0 Log ${variable} END Limit as timestr - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 0.1 seconds. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 100 milliseconds. ${USE LIMIT} WHILE $variable < 2 limit=0.1s Log ${variable} END Limit from variable - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 11 iterations. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 11 iterations. ${USE LIMIT} WHILE $variable < 2 limit=${limit} Log ${variable} END Part of limit from variable - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 0.2 seconds. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 200 milliseconds. ${USE LIMIT} WHILE $variable < 2 limit=${number} s Log ${variable} END @@ -53,11 +54,39 @@ Limit can be disabled END No condition with limit - [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 2 iterations. Use the 'limit' argument to increase or remove the limit if needed. + [Documentation] FAIL WHILE loop was aborted because it did not finish within the limit of 2 iterations. ${USE LIMIT} WHILE limit=2 Log Hello END +Limit exceeds in teardown + [Documentation] FAIL + ... Teardown failed: + ... Several failures occurred: + ... + ... 1) WHILE loop was aborted because it did not finish within the limit of 42 milliseconds. ${USE LIMIT} + ... + ... 2) Failing after WHILE + No Operation + [Teardown] Limit exceeds + +Limit exceeds after failures in teardown + [Documentation] FAIL + ... Teardown failed: + ... Several failures occurred: + ... + ... 1) Hello! + ... + ... 2) Hello! + ... + ... 3) WHILE loop was aborted because it did not finish within the limit of 2 iterations. ${USE LIMIT} + No Operation + [Teardown] Limit exceeds after failures + +Continue after limit in teardown + No Operation + [Teardown] Continue after limit + Invalid limit invalid suffix [Documentation] FAIL Invalid WHILE loop limit: Invalid time string '1 times'. WHILE $variable < 2 limit=1 times @@ -81,3 +110,20 @@ Invalid values after limit WHILE $variable < 2 limit=-1x invalid values Log ${variable} END + +*** Keywords *** +Limit exceeds + WHILE limit=0.042s + Log Hello! + END + Fail Failing after WHILE + +Limit exceeds after failures + WHILE limit=2 + Fail Hello! + END + +Continue after limit + WHILE limit=0.042s on_limit=pass + Log Hello! + END diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 58ca1b46d7a..8316ba7aa29 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -26,8 +26,8 @@ TryBranch as TryBranchResult) from robot.output import librarylogger as logger from robot.utils import (cut_assign_value, frange, get_error_message, get_timestamp, - is_list_like, is_number, plural_or_not as s, seq2str, - split_from_equals, type_name, Matcher, timestr_to_secs) + is_list_like, is_number, plural_or_not as s, secs_to_timestr, + seq2str, split_from_equals, type_name, Matcher, timestr_to_secs) from robot.variables import is_dict_variable, evaluate_expression from .statusreporter import StatusReporter @@ -410,11 +410,13 @@ def run(self, data): except ExecutionPassed as passed: passed.set_earlier_failures(errors) raise passed + except LimitExceeded as exceeded: + if exceeded.on_limit_pass: + self._context.info(exceeded.message) + else: + errors.append(exceeded) + break except ExecutionFailed as failed: - if isinstance(failed, LimitExceeded): - if failed.on_limit_pass: - self._context.info(failed.message) - return errors.extend(failed.get_errors()) if not failed.can_continue(ctx, self._templated): break @@ -715,7 +717,7 @@ def __enter__(self): self.limit_exceeded() def __str__(self): - return f'{self.max_time} seconds' + return secs_to_timestr(self.max_time) class IterationCountLimit(WhileLimit): From 669b62fb2a6091a433b055c83270f1a01fa01f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 26 Apr 2023 10:12:44 +0300 Subject: [PATCH 0514/1592] Custom parser tuning (#1283) - Add missing support to use multiple parsers. - Add --help text. Also make related --help texts uniform. --- atest/robot/parsing/custom_parsers.robot | 14 ++++++++----- src/robot/run.py | 25 +++++++++++++----------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/atest/robot/parsing/custom_parsers.robot b/atest/robot/parsing/custom_parsers.robot index 1de2526f75e..ce0ba787d75 100644 --- a/atest/robot/parsing/custom_parsers.robot +++ b/atest/robot/parsing/custom_parsers.robot @@ -16,11 +16,11 @@ Single file Directory [Documentation] Also tests parser implemented as a class. Run Tests --parser ${DIR}/CustomParser.py ${DIR} - Validate Directory Suite Custom custom=False + Validate Directory Suite Directory with init Run Tests --parser ${DIR}/CustomParser.py:init=True ${DIR} - Validate Directory Suite 📁 custom=True init=True + Validate Directory Suite init=True Override Robot parser Run Tests --parser ${DIR}/CustomParser.py:.robot ${DIR}/tests.robot @@ -32,6 +32,10 @@ Override Robot parser Validate Suite ${SUITE.suites[0]} Tests ${DIR}/tests.robot ... Test in Robot file=PASS +Multiple parsers + Run Tests --parser ${DIR}/CustomParser.py:ROBOT --PARSER ${DIR}/custom.py ${DIR} + Validate Directory Suite custom_robot=True + Directory with init when parser does not support inits Parsing Should Fail init ... Parsing '${DIR}${/}__init__.init' failed: @@ -79,8 +83,8 @@ Validate Suite Should Contain Tests ${suite} &{tests} Validate Directory Suite - [Arguments] ${name} ${custom}=True ${init}=False - Validate Suite ${SUITE} ${name} ${DIR} ${custom} + [Arguments] ${init}=False ${custom_robot}=False + Validate Suite ${SUITE} ${{'📁' if ${init} else 'Custom'}} ${DIR} ${init} ... Passing=PASS ... Failing=FAIL:Error message ... Empty=FAIL:Test cannot be empty. @@ -92,7 +96,7 @@ Validate Directory Suite ... Passing=PASS ... Failing=FAIL:Error message ... Empty=FAIL:Test cannot be empty. - Validate Suite ${SUITE.suites[2]} Tests ${DIR}/tests.robot custom=False + Validate Suite ${SUITE.suites[2]} Tests ${DIR}/tests.robot custom=${custom robot} ... Test in Robot file=PASS FOR ${test} IN @{SUITE.all_tests} IF ${init} diff --git a/src/robot/run.py b/src/robot/run.py index 9becc0bd6a1..8e2d58c2270 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -287,12 +287,6 @@ tag:<pattern>: flatten matched keywords using same matching rules as with `--removekeywords tag:<pattern>` - --listener class * A class for monitoring test execution. Gets - notifications e.g. when tests start and end. - Arguments to the listener class can be given after - the name using a colon or a semicolon as a separator. - Examples: --listener MyListenerClass - --listener path/to/Listener.py:arg1:arg2 --nostatusrc Sets the return code to zero regardless of failures in test cases. Error codes are returned normally. --dryrun Verifies test data and runs tests so that library @@ -311,11 +305,20 @@ The seed must be an integer. Examples: --randomize all --randomize tests:1234 - --parser parser FIXME: PARSER: Documentation - --prerunmodifier class * Class to programmatically modify the suite - structure before execution. - --prerebotmodifier class * Class to programmatically modify the result - model before creating reports and logs. + --listener listener * Class or module for monitoring test execution. + Gets notifications e.g. when tests start and end. + Arguments to the listener class can be given after + the name using a colon or a semicolon as a separator. + Examples: --listener MyListener + --listener path/to/Listener.py:arg1:arg2 + --prerunmodifier modifier * Class to programmatically modify the suite + structure before execution. Accepts arguments the + same way as with --listener. + --prerebotmodifier modifier * Class to programmatically modify the result + model before creating reports and logs. Accepts + arguments the same way as with --listener. + --parser parser * Custom parser class or module. Parser classes accept + arguments the same way as with --listener. --console type How to report execution on the console. verbose: report every suite and test (default) dotted: only show `.` for passed test, `s` for From 66771ee266a7e54bda105b205b79a866e1dc7097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 26 Apr 2023 11:16:01 +0300 Subject: [PATCH 0515/1592] Read the Docs config. Trying to enable using latest Python version for documentation generation. Nowadays Python 3.7 is used and that isn't compatible with `robot.api.interfaces`. Need to see is using `python: "3"` enough or do we need to force `"3.11"` explicitly. --- .readthedocs.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000000..dffec091319 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,25 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3" + +# Build documentation in the doc/api directory with Sphinx +sphinx: + configuration: doc/api/conf.py + +# If using Sphinx, optionally build your docs in additional formats such as PDF +# formats: +# - pdf + +# Optionally declare the Python requirements required to build your docs +#python: +# install: +# - requirements: docs/requirements.txt From e34565215abc20295b62f281db20b8deea020703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 26 Apr 2023 12:40:50 +0300 Subject: [PATCH 0516/1592] Mention Slack and Forum in API doc intro --- doc/api/index.rst | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/doc/api/index.rst b/doc/api/index.rst index bc6039c7f1c..159fd163f0b 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -6,16 +6,16 @@ This documentation describes the public API of `Robot Framework`__. Installation, basic usage and wealth of other topics are covered by the `Robot Framework User Guide`__. -Main API entry points are documented here, but the lower level -implementation details are not always that well documented. If the -documentation is insufficient, it is possible to view the source code -by clicking ``[source]`` link in the documentation. In case viewing the -source is not helpful either, questions may be sent to the -`robotframework-users`__ mailing list. - -__ http://robotframework.org -__ http://robotframework.org/robotframework/#user-guide -__ http://groups.google.com/group/robotframework-users +If you have questions related to the APIs, you can ask them on +Robot Framework Slack__, Forum__ or `mailing list`__. If you encounter +bugs, please `submit an issue`__. + +__ https://robotframework.org +__ https://robotframework.org/robotframework/#user-guide +__ https://slack.robotframework.org +__ https://forum.robotframework.org +__ https://groups.google.com/group/robotframework-users +__ https://issues.robotframework.org .. toctree:: :maxdepth: 2 From 54ab58448e79fad8baa84574276fefd20e77ec1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 26 Apr 2023 12:41:03 +0300 Subject: [PATCH 0517/1592] Test why documenting this module fails on Read the Docs. All code is effectively commented out. --- src/robot/api/interfaces.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 8ef94da2085..bb7379439a6 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -33,7 +33,7 @@ package and need to imported via :mod:`robot.api.interfaces`. .. note:: Using this module requires having the typing_extensions__ module - installed with Python 3.6 and 3.7. + installed when using Python 3.6 or 3.7. New in Robot Framework 6.1. @@ -45,6 +45,10 @@ __ https://pypi.org/project/typing-extensions/ """ +class Test: + """Trying to figure out why this generating docs for this module fails in RTD.""" + +''' import sys from abc import ABC, abstractmethod from pathlib import Path @@ -615,3 +619,4 @@ def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: an error. """ raise NotImplementedError +''' From caa8c3aa197c3d4c51a338fb8e35455fbd99d913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 26 Apr 2023 13:07:46 +0300 Subject: [PATCH 0518/1592] Doc generation issue solved. Uncomment code. --- src/robot/api/interfaces.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index bb7379439a6..21f63faac0f 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -45,10 +45,6 @@ __ https://pypi.org/project/typing-extensions/ """ -class Test: - """Trying to figure out why this generating docs for this module fails in RTD.""" - -''' import sys from abc import ABC, abstractmethod from pathlib import Path @@ -619,4 +615,3 @@ def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: an error. """ raise NotImplementedError -''' From cf0cdca3539f1dfcff567c4b851bc7ceb94a934a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 27 Apr 2023 00:19:59 +0300 Subject: [PATCH 0519/1592] Add TestDefaults.set_to. Makes it easier for custom parsers (#1283) to set defaults to test. --- atest/testdata/parsing/custom/CustomParser.py | 5 +---- src/robot/running/builder/settings.py | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/atest/testdata/parsing/custom/CustomParser.py b/atest/testdata/parsing/custom/CustomParser.py index e675b1e9fe9..3b489d02d9d 100644 --- a/atest/testdata/parsing/custom/CustomParser.py +++ b/atest/testdata/parsing/custom/CustomParser.py @@ -27,10 +27,7 @@ def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: return 'bad' suite = custom.parse(source) for test in suite.tests: - test.tags += defaults.tags - test.setup = defaults.setup - test.teardown = defaults.teardown - test.timeout = defaults.timeout + defaults.set_to(test) return suite def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index fe65a37920e..464b5b9bfe8 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -24,7 +24,7 @@ from robot.model import Tags -from ..model import Keyword +from ..model import Keyword, TestCase class KeywordDict(TypedDict): @@ -111,6 +111,21 @@ def timeout(self) -> 'str|None': def timeout(self, timeout: 'str|None'): self._timeout = timeout + def set_to(self, test: TestCase): + """Sets defaults to the given test. + + Tags are always added to the test. Setup, teardown and timeout are + set only if the test does not have them set initially. + """ + if self.tags: + test.tags += self.tags + if self.setup and not test.has_setup: + test.setup = self.setup + if self.teardown and not test.has_teardown: + test.teardown = self.teardown + if self.timeout and not test.timeout: + test.timeout = self.timeout + class FileSettings: From 9889c83181de9d4e547243b9454a13afc30e809b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 27 Apr 2023 01:21:34 +0300 Subject: [PATCH 0520/1592] UG tuning. - Fix formatting under Listener interface. - Rename `pathli` link targe to `pathlib`. Required making column in Supported conversions table wider. --- .../CreatingTestLibraries.rst | 262 +++++++++--------- .../ListenerInterface.rst | 3 +- 2 files changed, 132 insertions(+), 133 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index f6e85e57b13..a945f89d02e 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1180,135 +1180,135 @@ Other types cause conversion failures. :class: tabular :widths: 5 5 5 5 60 20 - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | Type | ABC | Aliases | Accepts | Explanation | Examples | - +=============+===============+============+==============+================================================================+======================================+ - | bool_ | | boolean | str_, | Strings `TRUE`, `YES`, `ON` and `1` are converted to `True`, | | `TRUE` (converted to `True`) | - | | | | int_, | the empty string as well as `FALSE`, `NO`, `OFF` and `0` | | `off` (converted to `False`) | - | | | | float_, | are converted to `False`, and the string `NONE` is converted | | `example` (used as-is) | - | | | | None_ | to `None`. Other strings and other accepted values are | | - | | | | | passed as-is, allowing keywords to handle them specially if | | - | | | | | needed. All string comparisons are case-insensitive. | | - | | | | | | | - | | | | | True and false strings can be localized_. See the | | - | | | | | Translations_ appendix for supported translations. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | int_ | Integral_ | integer, | str_, | Conversion is done using the int_ built-in function. Floats | | `42` | - | | | long | float_ | are accepted only if they can be represented as integers | | `-1` | - | | | | | exactly. For example, `1.0` is accepted and `1.1` is not. | | `0xFF` | - | | | | | If converting a string to an integer fails and the type | | `0o777` | - | | | | | is got implicitly based on a default value, conversion to | | `0b1010` | - | | | | | float is attempted as well. | | `10 000 000` | - | | | | | | | `0xBAD_C0FFEE` | - | | | | | Starting from RF 4.1, it is possible to use hexadecimal, | | `${1}` | - | | | | | octal and binary numbers by prefixing values with | | `${1.0}` | - | | | | | `0x`, `0o` and `0b`, respectively. | | - | | | | | | | - | | | | | Starting from RF 4.1, spaces and underscores can be used as | | - | | | | | visual separators for digit grouping purposes. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | float_ | Real_ | double | str_, | Conversion is done using the float_ built-in. | | `3.14` | - | | | | Real_ | | | `2.9979e8` | - | | | | | Starting from RF 4.1, spaces and underscores can be used as | | `10 000.000 01` | - | | | | | visual separators for digit grouping purposes. | | `10_000.000_01` | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | Decimal_ | | | str_, | Conversion is done using the Decimal_ class. Decimal_ is | | `3.14` | - | | | | int_, | recommended over float_ when decimal numbers need to be | | `10 000.000 01` | - | | | | float_ | represented exactly. | | `10_000.000_01` | - | | | | | | | - | | | | | Starting from RF 4.1, spaces and underscores can be used as | | - | | | | | visual separators for digit grouping purposes. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | str_ | | string, | Any | All arguments are converted to Unicode strings. New in RF 4.0. | | - | | | unicode | | | | - | | | | | | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | bytes_ | ByteString_ | | str_, | Strings are converted to bytes so that each Unicode code point | | `good` | - | | | | bytearray_ | below 256 is directly mapped to a matching byte. Higher code | | `hyvä` (converted to `hyv\xe4`) | - | | | | | points are not allowed. | | `\x00` (the null byte) | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | bytearray_ | | | str_, | Same conversion as with bytes_ but the result is a bytearray_. | | - | | | | bytes_ | | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | `datetime | | | str_, | Strings are expected to be timestamps in `ISO 8601`_ like | | `2022-02-09T16:39:43.632269` | - | <dt-mod_>`__| | | int_, | format `YYYY-MM-DD hh:mm:ss.mmmmmm`, where any non-digit | | `2022-02-09 16:39` | - | | | | float_ | character can be used as a separator or separators can be | | `2022-02-09` | - | | | | | omitted altogether. Additionally, only the date part is | | `${1644417583.632269}` (Epoch time)| - | | | | | mandatory, all possibly missing time components are considered | | - | | | | | to be zeros. | | - | | | | | | | - | | | | | Integers and floats are considered to represent seconds since | | - | | | | | the `Unix epoch`_. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | date_ | | | str_ | Same string conversion as with `datetime <dt-mod_>`__ but all | | `2018-09-12` | - | | | | | time components are expected to be omitted or to be zeros. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | timedelta_ | | | str_, | Strings are expected to represent a time interval in one of | | `42` (42 seconds) | - | | | | int_, | the time formats Robot Framework supports: `time as number`_, | | `1 minute 2 seconds` | - | | | | float_ | `time as time string`_ or `time as "timer" string`_. Integers | | `01:02` (same as above) | - | | | | | and floats are considered to be seconds. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | `Path | PathLike_ | | str_ | Strings are converted `Path <pathli_>`__ objects. On Windows | | `/tmp/absolute/path` | - | <pathli_>`__| | | | `/` is converted to :codesc:`\\` automatically. New in RF 6.0. | | `relative/path/to/file.ext` | - | | | | | | | `name.txt` | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | Enum_ | | | str_ | The specified type must be an enumeration (a subclass of Enum_ | .. sourcecode:: python | - | | | | | or Flag_) and given arguments must match its member names. | | - | | | | | | class Direction(Enum): | - | | | | | Starting from RF 3.2.2, matching member names is case-, space- | NORTH = auto() | - | | | | | and underscore-insensitive. | NORTH_WEST = auto() | - | | | | | | | - | | | | | | | `NORTH` (Direction.NORTH) | - | | | | | | | `north west` (Direction.NORTH_WEST)| - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | IntEnum_ | | | str_, | The specified type must be an integer based enumeration (a | .. sourcecode:: python | - | | | | int_ | subclass of IntEnum_ or IntFlag_) and given arguments must | | - | | | | | match its member names or values. | class PowerState(IntEnum): | - | | | | | | OFF = 0 | - | | | | | Matching member names is case-, space- and | ON = 1 | - | | | | | and underscore-insensitive. Values can be given as actual | | - | | | | | integers and as strings that can be converted to integers. | | `OFF` (PowerState.OFF) | - | | | | | | | `1` (PowerState.ON) | - | | | | | Support for IntEnum_ and IntFlag_ is new in RF 4.1. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | None_ | | NoneType | str_ | String `NONE` (case-insensitive) is converted to the Python | | `None` | - | | | | | `None` object. Other values cause an error. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | Any_ | | | Any | Any value is accepted. No conversion is done. | | - | | | | | | | - | | | | | New in RF 6.1. Any_ was not recognized with earlier versions, | | - | | | | | but conversion may have been done based on `default values | | - | | | | | <Implicit argument types based on default values_>`__. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | list_ | Sequence_ | | str_, | Strings must be Python list literals. They are converted | | `['one', 'two']` | - | | | | Sequence_ | to actual lists using the `ast.literal_eval`_ function. | | `[('one', 1), ('two', 2)]` | - | | | | | They can contain any values `ast.literal_eval` supports, | | - | | | | | including lists and other containers. | | - | | | | | | | - | | | | | If the used type hint is list_ (e.g. `arg: list`), sequences | | - | | | | | that are not lists are converted to lists. If the type hint is | | - | | | | | generic Sequence_, sequences are used without conversion. | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | tuple_ | | | str_, | Same as `list`, but string arguments must tuple literals. | | `('one', 'two')` | - | | | | Sequence_ | | | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | set_ | `Set | | str_, | Same as `list`, but string arguments must be set literals or | | `{1, 2, 3, 42}` | - | | <abc.Set_>`__ | | Container_ | `set()` to create an empty set. | | `set()` | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | frozenset_ | | | str_, | Same as `set`, but the result is a frozenset_. | | `{1, 2, 3, 42}` | - | | | | Container_ | | | `frozenset()` | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | dict_ | Mapping_ | dictionary,| str_, | Same as `list`, but string arguments must be dictionary | | `{'a': 1, 'b': 2}` | - | | | map | Mapping_ | literals. | | `{'key': 1, 'nested': {'key': 2}}` | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | TypedDict_ | | | str_, | Same as `dict`, but dictionary items are also converted | .. sourcecode:: python | - | | | | Mapping_ | to the specified types and items not included in the type | | - | | | | | spec are not allowed. | class Config(TypedDict): | - | | | | | | width: int | - | | | | | New in RF 6.0. Normal `dict` conversion was used earlier. | enabled: bool | - | | | | | | | - | | | | | | | `{'width': 1600, 'enabled': True}` | - +-------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | Type | ABC | Aliases | Accepts | Explanation | Examples | + +==============+===============+============+==============+================================================================+======================================+ + | bool_ | | boolean | str_, | Strings `TRUE`, `YES`, `ON` and `1` are converted to `True`, | | `TRUE` (converted to `True`) | + | | | | int_, | the empty string as well as `FALSE`, `NO`, `OFF` and `0` | | `off` (converted to `False`) | + | | | | float_, | are converted to `False`, and the string `NONE` is converted | | `example` (used as-is) | + | | | | None_ | to `None`. Other strings and other accepted values are | | + | | | | | passed as-is, allowing keywords to handle them specially if | | + | | | | | needed. All string comparisons are case-insensitive. | | + | | | | | | | + | | | | | True and false strings can be localized_. See the | | + | | | | | Translations_ appendix for supported translations. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | int_ | Integral_ | integer, | str_, | Conversion is done using the int_ built-in function. Floats | | `42` | + | | | long | float_ | are accepted only if they can be represented as integers | | `-1` | + | | | | | exactly. For example, `1.0` is accepted and `1.1` is not. | | `0xFF` | + | | | | | If converting a string to an integer fails and the type | | `0o777` | + | | | | | is got implicitly based on a default value, conversion to | | `0b1010` | + | | | | | float is attempted as well. | | `10 000 000` | + | | | | | | | `0xBAD_C0FFEE` | + | | | | | Starting from RF 4.1, it is possible to use hexadecimal, | | `${1}` | + | | | | | octal and binary numbers by prefixing values with | | `${1.0}` | + | | | | | `0x`, `0o` and `0b`, respectively. | | + | | | | | | | + | | | | | Starting from RF 4.1, spaces and underscores can be used as | | + | | | | | visual separators for digit grouping purposes. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | float_ | Real_ | double | str_, | Conversion is done using the float_ built-in. | | `3.14` | + | | | | Real_ | | | `2.9979e8` | + | | | | | Starting from RF 4.1, spaces and underscores can be used as | | `10 000.000 01` | + | | | | | visual separators for digit grouping purposes. | | `10_000.000_01` | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | Decimal_ | | | str_, | Conversion is done using the Decimal_ class. Decimal_ is | | `3.14` | + | | | | int_, | recommended over float_ when decimal numbers need to be | | `10 000.000 01` | + | | | | float_ | represented exactly. | | `10_000.000_01` | + | | | | | | | + | | | | | Starting from RF 4.1, spaces and underscores can be used as | | + | | | | | visual separators for digit grouping purposes. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | str_ | | string, | Any | All arguments are converted to Unicode strings. New in RF 4.0. | | + | | | unicode | | | | + | | | | | | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | bytes_ | ByteString_ | | str_, | Strings are converted to bytes so that each Unicode code point | | `good` | + | | | | bytearray_ | below 256 is directly mapped to a matching byte. Higher code | | `hyvä` (converted to `hyv\xe4`) | + | | | | | points are not allowed. | | `\x00` (the null byte) | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | bytearray_ | | | str_, | Same conversion as with bytes_ but the result is a bytearray_. | | + | | | | bytes_ | | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | `datetime | | | str_, | Strings are expected to be timestamps in `ISO 8601`_ like | | `2022-02-09T16:39:43.632269` | + | <dt-mod_>`__ | | | int_, | format `YYYY-MM-DD hh:mm:ss.mmmmmm`, where any non-digit | | `2022-02-09 16:39` | + | | | | float_ | character can be used as a separator or separators can be | | `2022-02-09` | + | | | | | omitted altogether. Additionally, only the date part is | | `${1644417583.632269}` (Epoch time)| + | | | | | mandatory, all possibly missing time components are considered | | + | | | | | to be zeros. | | + | | | | | | | + | | | | | Integers and floats are considered to represent seconds since | | + | | | | | the `Unix epoch`_. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | date_ | | | str_ | Same string conversion as with `datetime <dt-mod_>`__ but all | | `2018-09-12` | + | | | | | time components are expected to be omitted or to be zeros. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | timedelta_ | | | str_, | Strings are expected to represent a time interval in one of | | `42` (42 seconds) | + | | | | int_, | the time formats Robot Framework supports: `time as number`_, | | `1 minute 2 seconds` | + | | | | float_ | `time as time string`_ or `time as "timer" string`_. Integers | | `01:02` (same as above) | + | | | | | and floats are considered to be seconds. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | `Path | PathLike_ | | str_ | Strings are converted `pathlib.Path <pathlib_>`__ objects. | | `/tmp/absolute/path` | + | <pathlib_>`__| | | | On Windows `/` is converted to :codesc:`\\` automatically. | | `relative/path/to/file.ext` | + | | | | | New in RF 6.0. | | `name.txt` | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | Enum_ | | | str_ | The specified type must be an enumeration (a subclass of Enum_ | .. sourcecode:: python | + | | | | | or Flag_) and given arguments must match its member names. | | + | | | | | | class Direction(Enum): | + | | | | | Starting from RF 3.2.2, matching member names is case-, space- | NORTH = auto() | + | | | | | and underscore-insensitive. | NORTH_WEST = auto() | + | | | | | | | + | | | | | | | `NORTH` (Direction.NORTH) | + | | | | | | | `north west` (Direction.NORTH_WEST)| + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | IntEnum_ | | | str_, | The specified type must be an integer based enumeration (a | .. sourcecode:: python | + | | | | int_ | subclass of IntEnum_ or IntFlag_) and given arguments must | | + | | | | | match its member names or values. | class PowerState(IntEnum): | + | | | | | | OFF = 0 | + | | | | | Matching member names is case-, space- and | ON = 1 | + | | | | | and underscore-insensitive. Values can be given as actual | | + | | | | | integers and as strings that can be converted to integers. | | `OFF` (PowerState.OFF) | + | | | | | | | `1` (PowerState.ON) | + | | | | | Support for IntEnum_ and IntFlag_ is new in RF 4.1. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | None_ | | NoneType | str_ | String `NONE` (case-insensitive) is converted to the Python | | `None` | + | | | | | `None` object. Other values cause an error. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | Any_ | | | Any | Any value is accepted. No conversion is done. | | + | | | | | | | + | | | | | New in RF 6.1. Any_ was not recognized with earlier versions, | | + | | | | | but conversion may have been done based on `default values | | + | | | | | <Implicit argument types based on default values_>`__. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | list_ | Sequence_ | | str_, | Strings must be Python list literals. They are converted | | `['one', 'two']` | + | | | | Sequence_ | to actual lists using the `ast.literal_eval`_ function. | | `[('one', 1), ('two', 2)]` | + | | | | | They can contain any values `ast.literal_eval` supports, | | + | | | | | including lists and other containers. | | + | | | | | | | + | | | | | If the used type hint is list_ (e.g. `arg: list`), sequences | | + | | | | | that are not lists are converted to lists. If the type hint is | | + | | | | | generic Sequence_, sequences are used without conversion. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | tuple_ | | | str_, | Same as `list`, but string arguments must tuple literals. | | `('one', 'two')` | + | | | | Sequence_ | | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | set_ | `Set | | str_, | Same as `list`, but string arguments must be set literals or | | `{1, 2, 3, 42}` | + | | <abc.Set_>`__ | | Container_ | `set()` to create an empty set. | | `set()` | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | frozenset_ | | | str_, | Same as `set`, but the result is a frozenset_. | | `{1, 2, 3, 42}` | + | | | | Container_ | | | `frozenset()` | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | dict_ | Mapping_ | dictionary,| str_, | Same as `list`, but string arguments must be dictionary | | `{'a': 1, 'b': 2}` | + | | | map | Mapping_ | literals. | | `{'key': 1, 'nested': {'key': 2}}` | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | TypedDict_ | | | str_, | Same as `dict`, but dictionary items are also converted | .. sourcecode:: python | + | | | | Mapping_ | to the specified types and items not included in the type | | + | | | | | spec are not allowed. | class Config(TypedDict): | + | | | | | | width: int | + | | | | | New in RF 6.0. Normal `dict` conversion was used earlier. | enabled: bool | + | | | | | | | + | | | | | | | `{'width': 1600, 'enabled': True}` | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ .. note:: Starting from Robot Framework 5.0, types that have a converted are automatically shown in Libdoc_ outputs. @@ -1331,7 +1331,7 @@ Other types cause conversion failures. .. _dt-mod: https://docs.python.org/library/datetime.html#datetime.datetime .. _date: https://docs.python.org/library/datetime.html#datetime.date .. _timedelta: https://docs.python.org/library/datetime.html#datetime.timedelta -.. _pathli: https://docs.python.org/library/pathlib.html +.. _pathlib: https://docs.python.org/library/pathlib.html .. _PathLike: https://docs.python.org/library/os.html#os.PathLike .. _Enum: https://docs.python.org/library/enum.html#enum.Enum .. _Flag: https://docs.python.org/library/enum.html#enum.Flag @@ -2446,7 +2446,7 @@ a dependency to Robot Framework. If Robot Framework is not running, the messages are redirected automatically to Python's standard logging__ module. -__ https://robot-framework.readthedocs.org/en/latest/autodoc/robot.api.html#module-robot.api.logger +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.api.html#module-robot.api.logger __ http://docs.python.org/library/logging.html Using Python's standard `logging` module diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 7ade634d37c..84bfaca2e1e 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -32,7 +32,6 @@ Other option is to give an absolute or a relative path to the listener file into use by using this option several times:: robot --listener MyListener tests.robot - robot --listener com.company.package.Listener tests.robot robot --listener path/to/MyListener.py tests.robot robot --listener module.Listener --listener AnotherListener tests.robot @@ -271,7 +270,7 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | * `condition`: The looping condition. | | | | * `limit`: The maximum iteration limit. | | | | * `on_limit`: What to do if the limit is exceeded. | - | | | Valid values are `pass` and `fail`. New in RF 6.1. | + | | | Valid values are `pass` and `fail`. New in RF 6.1. | | | | * `on_limit_message`: The custom error raised when the | | | | limit of the WHILE loop is reached. New in RF 6.1. | | | | | From bdf9e2784e815fe84419b428958ea3ab5c89c0dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 27 Apr 2023 01:54:55 +0300 Subject: [PATCH 0521/1592] Document the new parser API. #1283 --- .../ParserInterface.rst | 161 ++++++++++++++++++ doc/userguide/src/RobotFrameworkUserGuide.rst | 1 + src/robot/api/interfaces.py | 2 +- src/robot/running/builder/builders.py | 2 +- src/robot/running/builder/settings.py | 10 +- 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst diff --git a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst new file mode 100644 index 00000000000..d531d77342b --- /dev/null +++ b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst @@ -0,0 +1,161 @@ +Parser interface +================ + +Robot Framework supports custom parsers that can handle custom data formats or +even override Robot Framework's own parser. + +.. note:: Custom parsers are new in Robot Framework 6.1. + +.. contents:: + :depth: 2 + :local: + +Taking parsers into use +----------------------- + +Parsers are taken into use from the command line using the :option:`--parser` +option using exactly the same semantics as with listeners__. This includes +specifying a parser as a name or as a path, how arguments can be given to +parser classes, and so on:: + + robot --parser MyParser tests.custom + robot --parser path/to/MyParser.py tests.custom + robot --parser Parser1:arg --parser Parser2:a1:a2 path/to/tests + +__ `Taking listeners into use`_ + +Parser API +---------- + +Parsers can be implemented both as modules and classes. This section explains +what attributes and methods they must contain. + +`EXTENSION` or `extension` attribute +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This attribute specifies what file extension or extensions the parser supports. +Both `EXTENSION` and `extension` names are accepted, and the former has precedence +if both exist. That attribute can be either a string or a sequence of strings. +Extensions are case-insensitive and can be specified with or without the leading +dot. + +If a parser supports the :file:`.robot` extension, it will be used for parsing +these files instead of the standard parser. + +`parse` method +~~~~~~~~~~~~~~ + +The mandatory `parse` method is responsible for parsing `suite files`_. It is +called with each parsed file that has an extension that the parser supports. +The method must return a `TestSuite <running.TestSuite_>`__ object. + +In simple cases `parse` can be implemented so that it accepts just a single +argument that is a `pathlib.Path <pathlib_>`__ object pointing to the file to +parse. If the parser is interested in defaults for :setting:`Test Setup`, +:setting:`Test Teardown`, :setting:`Test Tags` and :setting:`Test Timeout` +set in higher level `suite initialization files`_, the `parse` method must +accept two arguments. In that case the second argument is a TestDefaults_ object. + +.. _TestDefaults: https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.builder.html#robot.running.builder.settings.TestDefaults + +`parse_init` method +~~~~~~~~~~~~~~~~~~~ + +The optional `parse_init` method is responsible for parsing `suite initialization +files`_ i.e. files in in format `__init__.ext` where `.ext` is an extension +supported by the parser. The method must return a `TestSuite <running.TestSuite_>`__ +object representing the whole directory. Suites created from child suite files +and directories will be added to its child suites. + +Also `parse_init` can be implemented so that it accepts one or two arguments, +depending on is it interested in test related default values or not. If it +accepts defaults, it can manipulate the passed TestDefaults_ object and changes +are seen when parsing child suite files. + +This method is optional and only needed if a parser needs to support suite +initialization files. + +Optional base class +~~~~~~~~~~~~~~~~~~~ + +Parsers do not need to implement any explicit interface, but it may be helpful +to extend the optional Parser_ base class. The main benefit is that the base +class has documentation and type hints. It also works as a bit more formal API +specification. + +.. _Parser: https://robot-framework.readthedocs.io/en/master/autodoc/robot.api.html#robot.api.interfaces.Parser + +Examples +-------- + +A simple parser implemented as a module and supporting one hard-coded extension: + +.. sourcecode:: python + + from robot.api import TestSuite + + + EXTENSION = '.example' + + + def parse(source): + """Create a dummy suite without actually parsing anything.""" + suite = TestSuite(name='Example', source=source) + test = suite.tests.create(name='Test') + test.body.create_keyword('Log', args=['Hello!']) + return suite + +A parser implemented as a class having type hints and accepting the used extension +as an argument: + +.. sourcecode:: python + + from pathlib import Path + from robot.api import TestSuite + + + class ExampleParser: + + def __init__(self, extension: str): + self.extension = extension + + def parse(self, source: Path) -> TestSuite: + """Create a suite with tests created from each line in the source file.""" + suite = TestSuite(TestSuite.name_from_source(source), source=source) + for line in source.read_text().splitlines(): + test = suite.tests.create(name=line) + test.body.create_keyword('Log', args=['Hello!']) + return suite + +A parser extending the optional Parser_ base class, supporting multiple extensions, +using TestDefaults_ and implementing also `parse_init`: + +.. sourcecode:: python + + from pathlib import Path + from robot.api import TestSuite + from robot.api.interfaces import Parser, TestDefaults + + + class ExampleParser(Parser): + extension = ('example', 'another') + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + """Create a suite and set defaults from init file to tests.""" + suite = TestSuite(TestSuite.name_from_source(source), source=source) + for line in source.read_text().splitlines(): + test = suite.tests.create(name=line) + test.body.create_keyword('Log', args=['Hello!']) + defaults.set_to(test) + return suite + + def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: + """Create a dummy suite and set some defaults. + + This method is called only if there is an initialization file with + a supported extension. + """ + defaults.tags = ['tag from init'] + defaults.setup = {'name': 'Log', 'args': ['Hello from init!']} + return TestSuite(TestSuite.name_from_source(source.parent), doc='Example', + source=source, metadata={'Example': 'Value'}) diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index 556f3bc4f0c..ec1a1bec696 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -79,6 +79,7 @@ .. include:: ExtendingRobotFramework/CreatingTestLibraries.rst .. include:: ExtendingRobotFramework/RemoteLibrary.rst .. include:: ExtendingRobotFramework/ListenerInterface.rst +.. include:: ExtendingRobotFramework/ParserInterface.rst ~~~~~~~~~~~~~~~~~~~~ Supporting Tools diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 21f63faac0f..b0f05cf66b5 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -41,7 +41,7 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#hybrid-library-api __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-2 __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-version-3 -__ FIXME: PARSER: Link to UG docs. +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#parser-interface __ https://pypi.org/project/typing-extensions/ """ diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 0c3a6707386..0c3bb278c38 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -66,7 +66,7 @@ def __init__(self, included_suites: Sequence[str] = (), :param included_extensions: List of extensions of files to parse. Same as `--extension`. :param custom_parsers: - FIXME: PARSER: Documentation. + Custom parser names or paths. Same as `--parser`. New in RF 6.1. :param rpa: Explicit execution mode. ``True`` for RPA and ``False`` for test automation. By default, mode is got from data file headers and possible conflicting headers cause an error. diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index 464b5b9bfe8..9ba8e53ed71 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -43,7 +43,15 @@ class TestDefaults: """Represents default values for test related settings set in init files. Parsers parsing suite files can read defaults and parsers parsing init - files can set them. + files can set them. The easiest way to set defaults to a test is using + the :meth:`set_to` method. + + This class is part of the `public parser API`__. When implementing ``parse`` + or ``parse_init`` method so that they accept two arguments, the second is + an instance of this class. If the class is needed as a type hint, it can + be imported via ``robot.running` or `robot.api.interfaces``. + + __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#parser-interface """ def __init__(self, parent: 'TestDefaults|None' = None): From 497018c81024f222e8933599d9274678468c519b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 27 Apr 2023 02:57:58 +0300 Subject: [PATCH 0522/1592] Support custom parsers as objects programmatically. #1283 --- src/robot/running/builder/builders.py | 34 +++++++++++++++------------ src/robot/utils/text.py | 5 +++- utest/running/test_builder.py | 31 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 0c3bb278c38..9694a3ceb7d 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -22,7 +22,7 @@ from robot.errors import DataError from robot.output import LOGGER from robot.parsing import SuiteStructure, SuiteStructureBuilder, SuiteStructureVisitor -from robot.utils import Importer, seq2str, split_args_from_name_or_path +from robot.utils import Importer, seq2str, split_args_from_name_or_path, type_name from ..model import ResourceFile, TestSuite from .parsers import (CustomParser, JsonParser, NoInitFileDirectoryParser, Parser, @@ -62,15 +62,16 @@ def __init__(self, included_suites: Sequence[str] = (), """ :param include_suites: List of suite names to include. If not given, all suites are included. - Same as using `--suite` on the command line. + Same as using ``--suite`` on the command line. :param included_extensions: - List of extensions of files to parse. Same as `--extension`. + List of extensions of files to parse. Same as ``--extension``. :param custom_parsers: - Custom parser names or paths. Same as `--parser`. New in RF 6.1. + Custom parsers as names or paths (same as ``--parser``) or as + parser objects. New in RF 6.1. :param rpa: Explicit execution mode. ``True`` for RPA and ``False`` for test automation. By default, mode is got from data file headers and possible conflicting headers cause an error. - Same as `--rpa` or `--norpa`. + Same as ``--rpa`` or ``--norpa``. :param lang: Additional languages to be supported during parsing. Can be a string matching any of the supported language codes or names, an initialized :class:`~robot.conf.languages.Language` subclass, @@ -78,7 +79,7 @@ def __init__(self, included_suites: Sequence[str] = (), :class:`~robot.conf.languages.Languages` instance. :param allow_empty_suite: Specify is it an error if the built suite contains no tests. - Same as `--runemptysuite`. + Same as ``--runemptysuite``. :param process_curdir: Control processing the special ``${CURDIR}`` variable. It is resolved already at parsing time by default, but that can be @@ -104,19 +105,22 @@ def _get_standard_parsers(self, lang: LanguagesLike, 'json': json_parser } - def _get_custom_parsers(self, names: Sequence[str]) -> 'dict[str, CustomParser]': - parsers = {} + def _get_custom_parsers(self, parsers: Sequence[str]) -> 'dict[str, CustomParser]': + custom_parsers = {} importer = Importer('parser', LOGGER) - for name in names: - name, args = split_args_from_name_or_path(name) - imported = importer.import_class_or_module(name, args) + for parser in parsers: + if isinstance(parser, (str, Path)): + name, args = split_args_from_name_or_path(parser) + parser = importer.import_class_or_module(name, args) + else: + name = type_name(parser) try: - parser = CustomParser(imported) + custom_parser = CustomParser(parser) except TypeError as err: raise DataError(f"Importing parser '{name}' failed: {err}") - for ext in parser.extensions: - parsers[ext] = parser - return parsers + for ext in custom_parser.extensions: + custom_parsers[ext] = custom_parser + return custom_parsers def build(self, *paths: 'Path|str'): """ diff --git a/src/robot/utils/text.py b/src/robot/utils/text.py index d69ea2b4c57..aceae8062a0 100644 --- a/src/robot/utils/text.py +++ b/src/robot/utils/text.py @@ -13,10 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from itertools import takewhile import inspect import os.path import re +from itertools import takewhile +from pathlib import Path from .charwidth import get_char_width from .misc import seq2str2 @@ -132,6 +133,8 @@ def split_args_from_name_or_path(name): """ if os.path.exists(name): return os.path.abspath(name), [] + if isinstance(name, Path): + name = str(name) index = _get_arg_separator_index_from_name_or_path(name) if index == -1: return name, [] diff --git a/utest/running/test_builder.py b/utest/running/test_builder.py index 36ef81b3269..907946b0c03 100644 --- a/utest/running/test_builder.py +++ b/utest/running/test_builder.py @@ -2,6 +2,7 @@ from pathlib import Path from robot.errors import DataError +from robot.utils import Importer from robot.utils.asserts import assert_equal, assert_raises, assert_true from robot.running import TestSuite, TestSuiteBuilder @@ -115,6 +116,36 @@ def test_rpa(self): self._validate_rpa(build('../rpa/', rpa=False), False) assert_raises(DataError, build, '../rpa') + def test_custom_parser(self): + path = DATADIR / '../parsing/custom/CustomParser.py' + for parser in [path, str(path)]: + suite = build('../parsing/custom/tests.custom', custom_parsers=[parser]) + assert_equal(suite.name, 'Tests') + assert_equal([t.name for t in suite.tests], ['Passing', 'Failing', 'Empty']) + + def test_custom_parser_with_args(self): + path = DATADIR / '../parsing/custom/CustomParser.py:custom' + for parser in [path, str(path)]: + suite = build('../parsing/custom/tests.custom', custom_parsers=[parser]) + assert_equal(suite.name, 'Tests') + assert_equal([t.name for t in suite.tests], ['Passing', 'Failing', 'Empty']) + + def test_custom_parser_as_object(self): + path = DATADIR / '../parsing/custom/CustomParser.py' + parser = Importer().import_class_or_module(path, instantiate_with_args=()) + suite = build('../parsing/custom/tests.custom', custom_parsers=[parser]) + assert_equal(suite.name, 'Tests') + assert_equal([t.name for t in suite.tests], ['Passing', 'Failing', 'Empty']) + + def test_failing_parser_import(self): + err = assert_raises(DataError, build, custom_parsers=['non_existing_mod']) + assert_true(err.message.startswith("Importing parser 'non_existing_mod' failed:")) + + def test_incompatible_parser_object(self): + err = assert_raises(DataError, build, custom_parsers=[42]) + assert_equal(err.message, "Importing parser 'integer' failed: " + "'integer' does not have mandatory 'parse' method.") + def _validate_rpa(self, suite, expected): assert_equal(suite.rpa, expected, suite.name) for child in suite.suites: From 38daf17c90400ebe15e2acc00a583a4bc18f30a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 27 Apr 2023 11:58:20 +0300 Subject: [PATCH 0523/1592] Support passing TestDefaults to TestSuite.from_xxx methods. Makes it easier for custom parsers (#1283) working as pre-processors to convert file, model or string they have created to a suite so that defaults are taken into accont. --- .../ParserInterface.rst | 55 +++++++++++++--- src/robot/running/builder/builders.py | 26 +++++--- src/robot/running/builder/parsers.py | 4 +- src/robot/running/builder/settings.py | 13 ++-- src/robot/running/model.py | 64 ++++++++++++------- utest/running/test_run_model.py | 27 +++++++- 6 files changed, 139 insertions(+), 50 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst index d531d77342b..1f9dfaf06c0 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst @@ -88,7 +88,12 @@ specification. Examples -------- -A simple parser implemented as a module and supporting one hard-coded extension: +Parser implemented as module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The first example demonstrates a simple parser implemented as a module and +supporting one hard-coded extension. It just creates a dummy suite and does not +actually parse anything. .. sourcecode:: python @@ -99,14 +104,17 @@ A simple parser implemented as a module and supporting one hard-coded extension: def parse(source): - """Create a dummy suite without actually parsing anything.""" suite = TestSuite(name='Example', source=source) test = suite.tests.create(name='Test') test.body.create_keyword('Log', args=['Hello!']) return suite -A parser implemented as a class having type hints and accepting the used extension -as an argument: +Parser implemented as class +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The second parser is implemented as a class that accepts the extension to use +as an argument. The parser reads the given source file and creates dummy tests +from each line it contains. .. sourcecode:: python @@ -120,15 +128,17 @@ as an argument: self.extension = extension def parse(self, source: Path) -> TestSuite: - """Create a suite with tests created from each line in the source file.""" suite = TestSuite(TestSuite.name_from_source(source), source=source) for line in source.read_text().splitlines(): test = suite.tests.create(name=line) test.body.create_keyword('Log', args=['Hello!']) return suite -A parser extending the optional Parser_ base class, supporting multiple extensions, -using TestDefaults_ and implementing also `parse_init`: +Parser extending optional base class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This parser extends the optional Parser_ base class. It supports parsing suite +initialization files, uses TestDefaults_ and registers multiple extensions. .. sourcecode:: python @@ -141,7 +151,7 @@ using TestDefaults_ and implementing also `parse_init`: extension = ('example', 'another') def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: - """Create a suite and set defaults from init file to tests.""" + """Create a suite and set possible defaults from init files to tests.""" suite = TestSuite(TestSuite.name_from_source(source), source=source) for line in source.read_text().splitlines(): test = suite.tests.create(name=line) @@ -159,3 +169,32 @@ using TestDefaults_ and implementing also `parse_init`: defaults.setup = {'name': 'Log', 'args': ['Hello from init!']} return TestSuite(TestSuite.name_from_source(source.parent), doc='Example', source=source, metadata={'Example': 'Value'}) + +Parser as preprocessor +~~~~~~~~~~~~~~~~~~~~~~ + +The final parser acts as a preprocessor for Robot Framework data files that +supports headers in format `=== Test Cases ===` in addition to +`*** Test Cases ***`. In this kind of usage it is convenient to use +`TestSuite.from_string`__, `TestSuite.from_model`__ or +`TestSuite.from_file_system`__ factory methods for constructing the returned suite. + +.. sourcecode:: python + + from pathlib import Path + from robot.running import TestDefaults, TestSuite + + + class RobotPreprocessor: + extension = '.robot' + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + name = TestSuite.name_from_source(source) + data = source.read_text() + for header in 'Settings', 'Variables', 'Test Cases', 'Keywords': + data = data.replace(f'=== {header} ===', f'*** {header} ***') + return TestSuite.from_string(data, defaults=defaults).config(name=name) + +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_string +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_model +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_file_system diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 9694a3ceb7d..8c54a9f778c 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -57,10 +57,11 @@ class TestSuiteBuilder: def __init__(self, included_suites: Sequence[str] = (), included_extensions: Sequence[str] = ('.robot', '.rbt'), custom_parsers: Sequence[str] = (), + defaults: 'TestDefaults|None' = None, rpa: 'bool|None' = None, lang: LanguagesLike = None, allow_empty_suite: bool = False, process_curdir: bool = True): """ - :param include_suites: + :param included_suites: List of suite names to include. If not given, all suites are included. Same as using ``--suite`` on the command line. :param included_extensions: @@ -68,11 +69,15 @@ def __init__(self, included_suites: Sequence[str] = (), :param custom_parsers: Custom parsers as names or paths (same as ``--parser``) or as parser objects. New in RF 6.1. - :param rpa: Explicit execution mode. ``True`` for RPA and - ``False`` for test automation. By default, mode is got from data file - headers and possible conflicting headers cause an error. - Same as ``--rpa`` or ``--norpa``. - :param lang: Additional languages to be supported during parsing. + :param defaults: + Possible test specific defaults from suite initialization files. + New in RF 6.1. + :param rpa: + Explicit execution mode. ``True`` for RPA and ``False`` for test + automation. By default, mode is got from data file headers and possible + conflicting headers cause an error. Same as ``--rpa`` or ``--norpa``. + :param lang: + Additional languages to be supported during parsing. Can be a string matching any of the supported language codes or names, an initialized :class:`~robot.conf.languages.Language` subclass, a list containing such strings or instances, or a @@ -87,6 +92,7 @@ def __init__(self, included_suites: Sequence[str] = (), """ self.standard_parsers = self._get_standard_parsers(lang, process_curdir) self.custom_parsers = self._get_custom_parsers(custom_parsers) + self.defaults = defaults self.included_suites = tuple(included_suites or ()) self.included_extensions = tuple(included_extensions or ()) self.rpa = rpa @@ -131,7 +137,7 @@ def build(self, *paths: 'Path|str'): extensions = chain(self.included_extensions, self.custom_parsers) structure = SuiteStructureBuilder(extensions, self.included_suites).build(*paths) - suite = SuiteStructureParser(self._get_parsers(paths), + suite = SuiteStructureParser(self._get_parsers(paths), self.defaults, self.rpa).parse(structure) if not self.included_suites and not self.allow_empty_suite: self._validate_not_empty(suite, multi_source=len(paths) > 1) @@ -172,16 +178,18 @@ def _validate_not_empty(self, suite: TestSuite, multi_source: bool = False): class SuiteStructureParser(SuiteStructureVisitor): - def __init__(self, parsers: 'dict[str, Parser]', rpa: 'bool|None' = None): + def __init__(self, parsers: 'dict[str, Parser]', + defaults: 'TestDefaults|None' = None, rpa: 'bool|None' = None): self.parsers = parsers self.rpa = rpa + self.defaults = defaults self._rpa_given = rpa is not None self.suite: 'TestSuite|None' = None self._stack: 'list[tuple[TestSuite, TestDefaults]]' = [] @property def parent_defaults(self) -> 'TestDefaults|None': - return self._stack[-1][-1] if self._stack else None + return self._stack[-1][-1] if self._stack else self.defaults def parse(self, structure: SuiteStructure) -> TestSuite: structure.visit(self) diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 4db672c6669..014f5bea653 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -64,10 +64,10 @@ def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: SuiteBuilder(suite, InitFileSettings(defaults)).build(model) return suite - def parse_model(self, model: File) -> TestSuite: + def parse_model(self, model: File, defaults: 'TestDefaults|None' = None) -> TestSuite: source = model.source suite = TestSuite(name=TestSuite.name_from_source(source), source=source) - SuiteBuilder(suite, FileSettings()).build(model) + SuiteBuilder(suite, FileSettings(defaults)).build(model) return suite def _get_curdir(self, source: Path) -> 'str|None': diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index 9ba8e53ed71..28a1c3cf7d8 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -54,12 +54,15 @@ class TestDefaults: __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#parser-interface """ - def __init__(self, parent: 'TestDefaults|None' = None): + def __init__(self, parent: 'TestDefaults|None' = None, + setup: 'Keyword|KeywordDict|None' = None, + teardown: 'Keyword|KeywordDict|None' = None, + tags: 'Sequence[str]' = (), timeout: 'str|None' = None): self.parent = parent - self.setup = None - self.teardown = None - self.tags = () - self.timeout = None + self.setup = setup + self.teardown = teardown + self.tags = tags + self.timeout = timeout @property def setup(self) -> 'Keyword|None': diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 3cc00449f07..9f5a81fbceb 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -36,6 +36,7 @@ import warnings from pathlib import Path +from typing import TYPE_CHECKING from robot import model from robot.conf import RobotSettings @@ -50,6 +51,10 @@ from .randomizer import Randomizer from .statusreporter import StatusReporter +if TYPE_CHECKING: + from robot.parsing import File + from .builder import TestDefaults + class Body(model.Body): __slots__ = [] @@ -398,65 +403,78 @@ def __init__(self, name='', doc='', metadata=None, source=None, rpa=None): self.resource = ResourceFile(parent=self) @setter - def resource(self, resource): + def resource(self, resource: 'ResourceFile|dict') -> 'ResourceFile': if isinstance(resource, dict): resource = ResourceFile.from_dict(resource) resource.parent = self return resource @classmethod - def from_file_system(cls, *paths, **config): + def from_file_system(cls, *paths: 'Path|str', **config) -> 'TestSuite': """Create a :class:`TestSuite` object based on the given ``paths``. - ``paths`` are file or directory paths where to read the data from. - - Internally utilizes the :class:`~.builders.TestSuiteBuilder` class - and ``config`` can be used to configure how it is initialized. + :param paths: File or directory paths where to read the data from. + :param config: Configuration parameters for :class:`~.builders.TestSuiteBuilder` + class that is used internally for building the suite. - New in Robot Framework 3.2. + See also :meth:`from_model` and :meth:`from_string`. """ from .builder import TestSuiteBuilder return TestSuiteBuilder(**config).build(*paths) @classmethod - def from_model(cls, model, name=None): + def from_model(cls, model: 'File', name: 'str|None' = None, *, + defaults: 'TestDefaults|None' = None) -> 'TestSuite': """Create a :class:`TestSuite` object based on the given ``model``. + :param model: Model to create the suite from. + :param name: Deprecated since Robot Framework 6.1. + :param defaults: Possible test specific defaults from suite + initialization files. New in Robot Framework 6.1. + The model can be created by using the :func:`~robot.parsing.parser.parser.get_model` function and possibly modified by other tooling in the :mod:`robot.parsing` module. - The ``name`` argument is deprecated since Robot Framework 6.1. Users - should set the name and possible other attributes to the returned suite - separately. One easy way is using the :meth:`config` method like this:: + Giving suite name is deprecated and users should set it and possible + other attributes to the returned suite separately. One easy way is using + the :meth:`config` method like this:: suite = TestSuite.from_model(model).config(name='X', doc='Example') - New in Robot Framework 3.2. + See also :meth:`from_file_system` and :meth:`from_string`. """ from .builder import RobotParser - suite = RobotParser().parse_model(model) + suite = RobotParser().parse_model(model, defaults) if name is not None: - # TODO: Change DeprecationWarning to more visible UserWarning in RF 6.2. + # TODO: Remove 'name' in RF 7. warnings.warn("'name' argument of 'TestSuite.from_model' is deprecated. " - "Set the name to the returned suite separately.", - DeprecationWarning) + "Set the name to the returned suite separately.") suite.name = name return suite @classmethod - def from_string(cls, string, **config): + def from_string(cls, string: str, *, defaults: 'TestDefaults|None' = None, + **config) -> 'TestSuite': """Create a :class:`TestSuite` object based on the given ``string``. - The string is internally parsed into a model by using the - :func:`~robot.parsing.parser.parser.get_model` function and ``config`` - can be used to configure it. The model is then converted into a suite - by using :meth:`from_model`. + :param string: String to create the suite from. + :param defaults: Possible test specific defaults from suite + initialization files. + :param config: Configuration parameters for + :func:`~robot.parsing.parser.parser.get_model` used internally. - New in Robot Framework 6.1. + If suite name or other attributes need to be set, an easy way is using + the :meth:`config` method like this:: + + suite = TestSuite.from_string(string).config(name='X', doc='Example') + + New in Robot Framework 6.1. See also :meth:`from_model` and + :meth:`from_file_system`. """ from robot.parsing import get_model - return cls.from_model(get_model(string, data_only=True, **config)) + model = get_model(string, data_only=True, **config) + return cls.from_model(model, defaults=defaults) def configure(self, randomize_suites=False, randomize_tests=False, randomize_seed=None, **options): diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 85ab973b57c..cb05fa3ffaa 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -8,7 +8,8 @@ from robot import api, model from robot.model.modelobject import ModelObject from robot.running import (Break, Continue, Error, For, If, IfBranch, Keyword, - Return, TestCase, TestSuite, Try, TryBranch, While) + Return, TestCase, TestDefaults, TestSuite, Try, TryBranch, + While) from robot.running.model import ResourceFile, UserKeyword from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, assert_raises, assert_true) @@ -64,6 +65,7 @@ class TestSuiteFromSources(unittest.TestCase): *** Test Cases *** Example + [Tags] tag Keyword *** Keywords *** @@ -94,6 +96,11 @@ def test_from_file_system_with_config(self): suite = TestSuite.from_file_system(self.path, rpa=True) self._verify_suite(suite, rpa=True) + def test_from_file_system_with_defaults(self): + defaults = TestDefaults(tags=('from defaults',), timeout='10s') + suite = TestSuite.from_file_system(self.path, defaults=defaults) + self._verify_suite(suite, tags=('from defaults', 'tag'), timeout='10s') + def test_from_model(self): model = api.get_model(self.data) suite = TestSuite.from_model(model) @@ -104,6 +111,12 @@ def test_from_model_containing_source(self): suite = TestSuite.from_model(model) self._verify_suite(suite) + def test_from_model_with_defaults(self): + model = api.get_model(self.path) + defaults = TestDefaults(tags=('from defaults',), timeout='10s') + suite = TestSuite.from_model(model, defaults=defaults) + self._verify_suite(suite, tags=('from defaults', 'tag'), timeout='10s') + def test_from_model_with_custom_name(self): for source in [self.data, self.path]: model = api.get_model(source) @@ -118,12 +131,18 @@ def test_from_string(self): suite = TestSuite.from_string(self.data) self._verify_suite(suite, name='') - def test_from_string_config(self): + def test_from_string_with_config(self): suite = TestSuite.from_string(self.data.replace('Test Cases', 'Testit'), lang='Finnish', curdir='.') self._verify_suite(suite, name='') - def _verify_suite(self, suite, name='Test Run Model', rpa=False): + def test_from_string_with_defaults(self): + defaults = TestDefaults(tags=('from defaults',), timeout='10s') + suite = TestSuite.from_string(self.data, defaults=defaults) + self._verify_suite(suite, name='', tags=('from defaults', 'tag'), timeout='10s') + + def _verify_suite(self, suite, name='Test Run Model', tags=('tag',), + timeout=None, rpa=False): assert_equal(suite.name, name) assert_equal(suite.doc, 'Some text.') assert_equal(suite.rpa, rpa) @@ -135,6 +154,8 @@ def _verify_suite(self, suite, name='Test Run Model', rpa=False): assert_equal(suite.resource.keywords[0].body[0].name, 'Log') assert_equal(suite.resource.keywords[0].body[0].args, ('Hello!',)) assert_equal(suite.tests[0].name, 'Example') + assert_equal(suite.tests[0].tags, tags) + assert_equal(suite.tests[0].timeout, timeout) assert_equal(suite.tests[0].setup.name, 'No Operation') assert_equal(suite.tests[0].body[0].name, 'Keyword') From dd50bebb29c71744e7f75a50679b75b14b07e34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 27 Apr 2023 15:11:59 +0300 Subject: [PATCH 0524/1592] Refactor TestDefauls. Store tags as a tuple, not as `Tags`, and setup/teardown as a dict, not as `Keyword`. The main motivation is avoiding possible issues if same `Keyword` instance is used as setup/teardown with multiple tests and modified in one place. --- .../ParserInterface.rst | 10 +-- src/robot/running/builder/settings.py | 87 +++++++++---------- src/robot/running/builder/transformers.py | 11 +-- 3 files changed, 49 insertions(+), 59 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst index 1f9dfaf06c0..892762f3de8 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst @@ -106,7 +106,7 @@ actually parse anything. def parse(source): suite = TestSuite(name='Example', source=source) test = suite.tests.create(name='Test') - test.body.create_keyword('Log', args=['Hello!']) + test.body.create_keyword(name='Log', args=['Hello!']) return suite Parser implemented as class @@ -131,7 +131,7 @@ from each line it contains. suite = TestSuite(TestSuite.name_from_source(source), source=source) for line in source.read_text().splitlines(): test = suite.tests.create(name=line) - test.body.create_keyword('Log', args=['Hello!']) + test.body.create_keyword(name='Log', args=['Hello!']) return suite Parser extending optional base class @@ -154,8 +154,8 @@ initialization files, uses TestDefaults_ and registers multiple extensions. """Create a suite and set possible defaults from init files to tests.""" suite = TestSuite(TestSuite.name_from_source(source), source=source) for line in source.read_text().splitlines(): - test = suite.tests.create(name=line) - test.body.create_keyword('Log', args=['Hello!']) + test = suite.tests.create(name=line, doc='Example') + test.body.create_keyword(name='Log', args=['Hello!']) defaults.set_to(test) return suite @@ -165,7 +165,7 @@ initialization files, uses TestDefaults_ and registers multiple extensions. This method is called only if there is an initialization file with a supported extension. """ - defaults.tags = ['tag from init'] + defaults.tags = ('tags', 'from init') defaults.setup = {'name': 'Log', 'args': ['Hello from init!']} return TestSuite(TestSuite.name_from_source(source.parent), doc='Example', source=source, metadata={'Example': 'Value'}) diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index 28a1c3cf7d8..de1d7a7c862 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -22,21 +22,18 @@ except ImportError: TypedDict = dict -from robot.model import Tags +from ..model import TestCase -from ..model import Keyword, TestCase +class FixtureDict(TypedDict): + """Dictionary containing setup or teardown info. -class KeywordDict(TypedDict): - """Dictionary to create setup or teardown from. - - :attr:`args` and :attr:`lineno` are optional. + :attr:`args` is optional. """ - # `args` and `lineno` are not marked optional, because that would be hard - # until we require Python 3.8 and ugly until Python 3.11. + # `args` is not marked optional because that would be hard until we require + # Python 3.8 and ugly until Python 3.11. name: str args: 'Sequence[str]' - lineno: int class TestDefaults: @@ -55,8 +52,8 @@ class TestDefaults: """ def __init__(self, parent: 'TestDefaults|None' = None, - setup: 'Keyword|KeywordDict|None' = None, - teardown: 'Keyword|KeywordDict|None' = None, + setup: 'FixtureDict|None' = None, + teardown: 'FixtureDict|None' = None, tags: 'Sequence[str]' = (), timeout: 'str|None' = None): self.parent = parent self.setup = setup @@ -65,7 +62,7 @@ def __init__(self, parent: 'TestDefaults|None' = None, self.timeout = timeout @property - def setup(self) -> 'Keyword|None': + def setup(self) -> 'FixtureDict|None': """Default setup as a ``Keyword`` object or ``None`` when not set. Can be set also using a dictionary. @@ -77,13 +74,11 @@ def setup(self) -> 'Keyword|None': return None @setup.setter - def setup(self, setup: 'Keyword|KeywordDict|None'): - if isinstance(setup, dict): - setup = Keyword.from_dict(setup) + def setup(self, setup: 'FixtureDict|None'): self._setup = setup @property - def teardown(self) -> 'Keyword|None': + def teardown(self) -> 'FixtureDict|None': """Default teardown as a ``Keyword`` object or ``None`` when not set. Can be set also using a dictionary. @@ -95,19 +90,17 @@ def teardown(self) -> 'Keyword|None': return None @teardown.setter - def teardown(self, teardown: 'Keyword|KeywordDict|None'): - if isinstance(teardown, dict): - teardown = Keyword.from_dict(teardown) + def teardown(self, teardown: 'FixtureDict|None'): self._teardown = teardown @property - def tags(self) -> Tags: + def tags(self) -> 'tuple[str]': """Default tags. Can be set also as a sequence.""" return self._tags + self.parent.tags if self.parent else self._tags @tags.setter def tags(self, tags: 'Sequence[str]'): - self._tags = Tags(tags) + self._tags = tuple(tags) @property def timeout(self) -> 'str|None': @@ -131,9 +124,9 @@ def set_to(self, test: TestCase): if self.tags: test.tags += self.tags if self.setup and not test.has_setup: - test.setup = self.setup + test.setup.config(**self.setup) if self.teardown and not test.has_teardown: - test.teardown = self.teardown + test.teardown.config(**self.teardown) if self.timeout and not test.timeout: test.timeout = self.timeout @@ -142,44 +135,44 @@ class FileSettings: def __init__(self, test_defaults: 'TestDefaults|None' = None): self.test_defaults = test_defaults or TestDefaults() - self._test_setup = None - self._test_teardown = None - self._test_tags = Tags() - self._test_timeout = None - self._test_template = None - self._default_tags = Tags() - self._keyword_tags = Tags() + self.test_setup = None + self.test_teardown = None + self.test_tags = () + self.test_timeout = None + self.test_template = None + self.default_tags = () + self.keyword_tags = () @property - def test_setup(self) -> 'Keyword|None': + def test_setup(self) -> 'FixtureDict|None': return self._test_setup or self.test_defaults.setup @test_setup.setter - def test_setup(self, setup: KeywordDict): - self._test_setup = Keyword.from_dict(setup) + def test_setup(self, setup: 'FixtureDict|None'): + self._test_setup = setup @property - def test_teardown(self) -> 'Keyword|None': + def test_teardown(self) -> 'FixtureDict|None': return self._test_teardown or self.test_defaults.teardown @test_teardown.setter - def test_teardown(self, teardown: KeywordDict): - self._test_teardown = Keyword.from_dict(teardown) + def test_teardown(self, teardown: 'FixtureDict|None'): + self._test_teardown = teardown @property - def test_tags(self) -> Tags: + def test_tags(self) -> 'tuple[str]': return self._test_tags + self.test_defaults.tags @test_tags.setter def test_tags(self, tags: 'Sequence[str]'): - self._test_tags = Tags(tags) + self._test_tags = tuple(tags) @property def test_timeout(self) -> 'str|None': return self._test_timeout or self.test_defaults.timeout @test_timeout.setter - def test_timeout(self, timeout: str): + def test_timeout(self, timeout: 'str|None'): self._test_timeout = timeout @property @@ -187,34 +180,34 @@ def test_template(self) -> 'str|None': return self._test_template @test_template.setter - def test_template(self, template: str): + def test_template(self, template: 'str|None'): self._test_template = template @property - def default_tags(self) -> Tags: + def default_tags(self) -> 'tuple[str]': return self._default_tags @default_tags.setter def default_tags(self, tags: 'Sequence[str]'): - self._default_tags = Tags(tags) + self._default_tags = tuple(tags) @property - def keyword_tags(self) -> Tags: + def keyword_tags(self) -> 'tuple[str]': return self._keyword_tags @keyword_tags.setter def keyword_tags(self, tags: 'Sequence[str]'): - self._keyword_tags = Tags(tags) + self._keyword_tags = tuple(tags) class InitFileSettings(FileSettings): @FileSettings.test_setup.setter - def test_setup(self, setup: KeywordDict): + def test_setup(self, setup: 'FixtureDict|None'): self.test_defaults.setup = setup @FileSettings.test_teardown.setter - def test_teardown(self, teardown: KeywordDict): + def test_teardown(self, teardown: 'FixtureDict|None'): self.test_defaults.teardown = teardown @FileSettings.test_tags.setter @@ -222,5 +215,5 @@ def test_tags(self, tags: 'Sequence[str]'): self.test_defaults.tags = tags @FileSettings.test_timeout.setter - def test_timeout(self, timeout: str): + def test_timeout(self, timeout: 'str|None'): self.test_defaults.timeout = timeout diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 38bfe93ca1a..70e7046e0ec 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -179,16 +179,13 @@ def visit_TestCase(self, node): template=settings.test_template, error=error) if settings.test_setup: - self.test.setup.config(name=settings.test_setup.name, - args=settings.test_setup.args, - lineno=settings.test_setup.lineno) + self.test.setup.config(**settings.test_setup) if settings.test_teardown: - self.test.teardown.config(name=settings.test_teardown.name, - args=settings.test_teardown.args, - lineno=settings.test_teardown.lineno) + self.test.teardown.config(**settings.test_teardown) self.generic_visit(node) tags = self.tags if self.tags is not None else settings.default_tags - self.test.tags.add(tags) + if tags: + self.test.tags.add(tags) if self.test.template: self._set_template(self.test, self.test.template) From 7e9c718987c4e7f005515cc0ffbe7bdd0d234564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 2 May 2023 10:58:08 +0300 Subject: [PATCH 0525/1592] Enhance and fix typing. For example: - Use `tuple[X, ...]` with homogenous tuples instead of incorrect `tuple[X]`. - Add `cast` to few places to make VSCode happy. --- src/robot/conf/languages.py | 24 ++++++++--------- src/robot/model/testsuite.py | 2 +- src/robot/running/builder/builders.py | 21 ++++++++------- src/robot/running/builder/parsers.py | 2 +- src/robot/running/builder/settings.py | 39 ++++++++++++++------------- src/robot/utils/normalizing.py | 17 +++++++++--- 6 files changed, 58 insertions(+), 47 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index a5defbf09d0..935467f225a 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -16,7 +16,7 @@ import inspect from itertools import chain from pathlib import Path -from typing import Iterable, Iterator, Union +from typing import cast, Iterable, Iterator, Union from robot.errors import DataError from robot.utils import classproperty, is_list_like, Importer, normalize @@ -88,13 +88,13 @@ def add_language(self, lang: LanguageLike): for lang in languages: self._add_language(lang) - def _exists(self, path): + def _exists(self, path: Path): try: return path.exists() except OSError: # Can happen on Windows w/ Python < 3.10. return False - def _add_language(self, lang): + def _add_language(self, lang: 'Language'): if lang in self.languages: return self.languages.append(lang) @@ -104,10 +104,10 @@ def _add_language(self, lang): self.true_strings |= {s.title() for s in lang.true_strings} self.false_strings |= {s.title() for s in lang.false_strings} - def _get_languages(self, languages, add_english=True): + def _get_languages(self, languages, add_english=True) -> 'list[Language]': languages = self._resolve_languages(languages, add_english) available = self._get_available_languages() - returned = [] + returned: 'list[Language]' = [] for lang in languages: if isinstance(lang, Language): returned.append(lang) @@ -141,16 +141,16 @@ def _resolve_languages(self, languages, add_english=True): } return languages - def _get_available_languages(self): + def _get_available_languages(self) -> 'dict[str, type[Language]]': available = {} for lang in Language.__subclasses__(): - available[normalize(lang.code, ignore='-')] = lang - available[normalize(lang.name)] = lang + available[normalize(cast(str, lang.code), ignore='-')] = lang + available[normalize(cast(str, lang.name))] = lang if '' in available: available.pop('') return available - def _import_language_module(self, name_or_path): + def _import_language_module(self, name_or_path) -> 'list[Language]': def is_language(member): return (inspect.isclass(member) and issubclass(member, Language) @@ -244,7 +244,7 @@ def code(cls) -> str: """ if cls is Language: return cls.__dict__['code'] - code = cls.__name__.lower() + code = cast(type, cls).__name__.lower() if len(code) < 3: return code return f'{code[:2]}-{code[2:].upper()}' @@ -262,7 +262,7 @@ def name(cls) -> str: return cls.__doc__.splitlines()[0] if cls.__doc__ else '' @property - def headers(self) -> 'dict[str, str]': + def headers(self) -> 'dict[str|None, str]': return { self.settings_header: En.settings_header, self.variables_header: En.variables_header, @@ -273,7 +273,7 @@ def headers(self) -> 'dict[str, str]': } @property - def settings(self) -> 'dict[str, str]': + def settings(self) -> 'dict[str|None, str]': return { self.library_setting: En.library_setting, self.resource_setting: En.resource_setting, diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 723b16b52ac..4f35c10d8b5 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -43,7 +43,7 @@ class TestSuite(ModelObject): __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', '_my_visitors'] def __init__(self, name: str = '', doc: str = '', metadata: 'Mapping|None' = None, - source: 'Path|str|None' = None, rpa: bool = False, + source: 'Path|str|None' = None, rpa: 'bool|None' = None, parent: 'TestSuite|None' = None): self._name = name self.doc = doc diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 8c54a9f778c..095215cf3c1 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -16,7 +16,7 @@ from itertools import chain from os.path import normpath from pathlib import Path -from typing import Sequence +from typing import cast, Sequence from robot.conf import LanguagesLike from robot.errors import DataError @@ -144,21 +144,21 @@ def build(self, *paths: 'Path|str'): suite.remove_empty_suites(preserve_direct_children=len(paths) > 1) return suite - def _normalize_paths(self, paths: 'tuple[Path|str]') -> 'tuple[Path]': + def _normalize_paths(self, paths: 'Sequence[Path|str]') -> 'tuple[Path, ...]': if not paths: raise DataError('One or more source paths required.') # Cannot use `Path.resolve()` here because it resolves all symlinks which # isn't desired. `Path` doesn't have any methods for normalizing paths # so need to use `os.path.normpath()`. Also that _may_ resolve symlinks, # but we need to do it for backwards compatibility. - paths = tuple(Path(normpath(p)).absolute() for p in paths) + paths = [Path(normpath(p)).absolute() for p in paths] non_existing = [p for p in paths if not p.exists()] if non_existing: raise DataError(f"Parsing {seq2str(non_existing)} failed: " f"File or directory to execute does not exist.") - return paths + return tuple(paths) - def _get_parsers(self, paths: 'tuple[Path]'): + def _get_parsers(self, paths: 'Sequence[Path]') -> 'dict[str|None, Parser]': parsers = {None: NoInitFileDirectoryParser(), **self.custom_parsers} robot_parser = self.standard_parsers['robot'] for ext in chain(self.included_extensions, @@ -178,7 +178,7 @@ def _validate_not_empty(self, suite: TestSuite, multi_source: bool = False): class SuiteStructureParser(SuiteStructureVisitor): - def __init__(self, parsers: 'dict[str, Parser]', + def __init__(self, parsers: 'dict[str|None, Parser]', defaults: 'TestDefaults|None' = None, rpa: 'bool|None' = None): self.parsers = parsers self.rpa = rpa @@ -193,8 +193,9 @@ def parent_defaults(self) -> 'TestDefaults|None': def parse(self, structure: SuiteStructure) -> TestSuite: structure.visit(self) - self.suite.rpa = self.rpa - return self.suite + suite = cast(TestSuite, self.suite) + suite.rpa = self.rpa + return suite def visit_file(self, structure: SuiteStructure): LOGGER.info(f"Parsing file '{structure.source}'.") @@ -220,7 +221,7 @@ def end_directory(self, structure: SuiteStructure): suite.rpa = suite.suites[0].rpa def _build_suite_file(self, structure: SuiteStructure): - source = structure.source + source = cast(Path, structure.source) defaults = self.parent_defaults or TestDefaults() parser = self.parsers[structure.extension] try: @@ -233,7 +234,7 @@ def _build_suite_file(self, structure: SuiteStructure): return suite def _build_suite_directory(self, structure: SuiteStructure): - source = structure.init_file or structure.source + source = cast(Path, structure.init_file or structure.source) defaults = TestDefaults(self.parent_defaults) parser = self.parsers[structure.extension] try: diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 014f5bea653..d8d6f89b1a1 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -125,7 +125,7 @@ def name(self) -> str: return type_name(self.parser) @property - def extensions(self) -> 'tuple[str]': + def extensions(self) -> 'tuple[str, ...]': ext = (getattr(self.parser, 'EXTENSION', None) or getattr(self.parser, 'extension', None)) extensions = [ext] if isinstance(ext, str) else list(ext or ()) diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index de1d7a7c862..ad596265393 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -13,27 +13,28 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys from collections.abc import Sequence -try: - from typing import TypedDict -except ImportError: - try: - from typing_extensions import TypedDict - except ImportError: - TypedDict = dict from ..model import TestCase -class FixtureDict(TypedDict): - """Dictionary containing setup or teardown info. +if sys.version_info >= (3, 8): + from typing import TypedDict + - :attr:`args` is optional. - """ - # `args` is not marked optional because that would be hard until we require - # Python 3.8 and ugly until Python 3.11. - name: str - args: 'Sequence[str]' + class FixtureDict(TypedDict): + """Dictionary containing setup or teardown info. + + :attr:`args` and :attr:`lineno` are optional. + """ + name: str + args: 'Sequence[str]' + lineno: int + +else: + class FixtureDict(dict): + pass class TestDefaults: @@ -94,7 +95,7 @@ def teardown(self, teardown: 'FixtureDict|None'): self._teardown = teardown @property - def tags(self) -> 'tuple[str]': + def tags(self) -> 'tuple[str, ...]': """Default tags. Can be set also as a sequence.""" return self._tags + self.parent.tags if self.parent else self._tags @@ -160,7 +161,7 @@ def test_teardown(self, teardown: 'FixtureDict|None'): self._test_teardown = teardown @property - def test_tags(self) -> 'tuple[str]': + def test_tags(self) -> 'tuple[str, ...]': return self._test_tags + self.test_defaults.tags @test_tags.setter @@ -184,7 +185,7 @@ def test_template(self, template: 'str|None'): self._test_template = template @property - def default_tags(self) -> 'tuple[str]': + def default_tags(self) -> 'tuple[str, ...]': return self._default_tags @default_tags.setter @@ -192,7 +193,7 @@ def default_tags(self, tags: 'Sequence[str]'): self._default_tags = tuple(tags) @property - def keyword_tags(self) -> 'tuple[str]': + def keyword_tags(self) -> 'tuple[str, ...]': return self._keyword_tags @keyword_tags.setter diff --git a/src/robot/utils/normalizing.py b/src/robot/utils/normalizing.py index 5f971564510..bffed1ff79c 100644 --- a/src/robot/utils/normalizing.py +++ b/src/robot/utils/normalizing.py @@ -13,11 +13,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections.abc import MutableMapping import re +from collections.abc import Mapping, MutableMapping, Sequence +from typing import overload -from .robottypes import is_dict_like, is_string +@overload +def normalize(string: str, ignore: 'Sequence[str]' = (), caseless: bool = True, + spaceless: bool = True) -> str: + ... + +@overload +def normalize(string: bytes, ignore: 'Sequence[bytes]' = (), caseless: bool = True, + spaceless: bool = True) -> bytes: + ... def normalize(string, ignore=(), caseless=True, spaceless=True): """Normalizes given string according to given spec. @@ -25,7 +34,7 @@ def normalize(string, ignore=(), caseless=True, spaceless=True): By default string is turned to lower case and all whitespace is removed. Additional characters can be removed by giving them in ``ignore`` list. """ - empty = '' if is_string(string) else b'' + empty = '' if isinstance(string, str) else b'' if isinstance(ignore, bytes): # Iterating bytes in Python3 yields integers. ignore = [bytes([i]) for i in ignore] @@ -99,7 +108,7 @@ def __repr__(self): return f'{name}({params})' def __eq__(self, other): - if not is_dict_like(other): + if not isinstance(other, Mapping): return False if not isinstance(other, NormalizedDict): other = NormalizedDict(other) From 187bae53160e14d8edafc8cfd658cd7753337081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 2 May 2023 13:14:14 +0300 Subject: [PATCH 0526/1592] Add typing to robot.parsing model. (#4740) Includes few TODOs to experiment later. --- src/robot/parsing/lexer/blocklexers.py | 107 ++--- src/robot/parsing/lexer/context.py | 82 ++-- src/robot/parsing/lexer/lexer.py | 15 +- src/robot/parsing/lexer/settings.py | 62 +-- src/robot/parsing/lexer/statementlexers.py | 72 ++-- src/robot/parsing/lexer/tokenizer.py | 18 +- src/robot/parsing/lexer/tokens.py | 15 +- src/robot/parsing/model/__init__.py | 9 +- src/robot/parsing/model/blocks.py | 225 +++++----- src/robot/parsing/model/statements.py | 468 +++++++++++++-------- src/robot/parsing/model/visitor.py | 8 +- src/robot/parsing/parser/blockparsers.py | 105 +++-- src/robot/parsing/parser/fileparser.py | 95 ++--- src/robot/parsing/parser/parser.py | 42 +- utest/api/test_exposed_api.py | 2 +- utest/parsing/parsing_test_utils.py | 4 +- utest/parsing/test_model.py | 49 +-- utest/parsing/test_statements.py | 18 + 18 files changed, 782 insertions(+), 614 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index 776fff2f195..6fdfb8a92de 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Iterator + from robot.utils import normalize_whitespace from .context import FileContext, LexingContext, SuiteFileContext, TestOrKeywordContext @@ -39,21 +41,20 @@ class BlockLexer(Lexer): def __init__(self, ctx: LexingContext): super().__init__(ctx) - self.lexers = [] + self.lexers: 'list[Lexer]' = [] - def accepts_more(self, statement: list): + def accepts_more(self, statement: 'list[Token]') -> bool: return True - def input(self, statement: list): + def input(self, statement: 'list[Token]'): if self.lexers and self.lexers[-1].accepts_more(statement): lexer = self.lexers[-1] else: lexer = self.lexer_for(statement) self.lexers.append(lexer) lexer.input(statement) - return lexer - def lexer_for(self, statement: list): + def lexer_for(self, statement: 'list[Token]') -> Lexer: for cls in self.lexer_classes(): if cls.handles(statement, self.ctx): lexer = cls(self.ctx) @@ -61,14 +62,14 @@ def lexer_for(self, statement: list): raise TypeError(f"{type(self).__name__} does not have lexer for " f"statement {statement}.") - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return () def lex(self): for lexer in self.lexers: lexer.lex() - def _lex_with_priority(self, priority): + def _lex_with_priority(self, priority: 'type[Lexer]'): for lexer in self.lexers: if isinstance(lexer, priority): lexer.lex() @@ -82,7 +83,7 @@ class FileLexer(BlockLexer): def lex(self): self._lex_with_priority(priority=SettingSectionLexer) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (SettingSectionLexer, VariableSectionLexer, TestCaseSectionLexer, TaskSectionLexer, KeywordSectionLexer, CommentSectionLexer, @@ -91,112 +92,112 @@ def lexer_classes(self): class SectionLexer(BlockLexer): - def accepts_more(self, statement: list): + def accepts_more(self, statement: 'list[Token]') -> bool: return not statement[0].value.startswith('*') class SettingSectionLexer(SectionLexer): @classmethod - def handles(cls, statement: list, ctx: FileContext): + def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: return ctx.setting_section(statement) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (SettingSectionHeaderLexer, SettingLexer) class VariableSectionLexer(SectionLexer): @classmethod - def handles(cls, statement: list, ctx: FileContext): + def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: return ctx.variable_section(statement) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (VariableSectionHeaderLexer, VariableLexer) class TestCaseSectionLexer(SectionLexer): @classmethod - def handles(cls, statement: list, ctx: FileContext): + def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: return ctx.test_case_section(statement) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TestCaseSectionHeaderLexer, TestCaseLexer) class TaskSectionLexer(SectionLexer): @classmethod - def handles(cls, statement: list, ctx: FileContext): + def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: return ctx.task_section(statement) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TaskSectionHeaderLexer, TestCaseLexer) class KeywordSectionLexer(SettingSectionLexer): @classmethod - def handles(cls, statement: list, ctx: FileContext): + def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: return ctx.keyword_section(statement) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (KeywordSectionHeaderLexer, KeywordLexer) class CommentSectionLexer(SectionLexer): @classmethod - def handles(cls, statement: list, ctx: FileContext): + def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: return ctx.comment_section(statement) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (CommentSectionHeaderLexer, CommentLexer) class ImplicitCommentSectionLexer(SectionLexer): @classmethod - def handles(cls, statement: list, ctx: FileContext): + def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: return True - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (ImplicitCommentLexer,) class InvalidSectionLexer(SectionLexer): @classmethod - def handles(cls, statement: list, ctx: FileContext): - return statement and statement[0].value.startswith('*') + def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: + return bool(statement and statement[0].value.startswith('*')) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (InvalidSectionHeaderLexer, CommentLexer) class TestOrKeywordLexer(BlockLexer): - name_type = NotImplemented + name_type: str _name_seen = False - def accepts_more(self, statement: list): + def accepts_more(self, statement: 'list[Token]') -> bool: return not statement[0].value - def input(self, statement: list): + def input(self, statement: 'list[Token]'): self._handle_name_or_indentation(statement) if statement: super().input(statement) - def _handle_name_or_indentation(self, statement): + def _handle_name_or_indentation(self, statement: 'list[Token]'): if not self._name_seen: - token = statement.pop(0) - token.type = self.name_type + name_token = statement.pop(0) + name_token.type = self.name_type if statement: - token._add_eos_after = True + name_token._add_eos_after = True self._name_seen = True else: while statement and not statement[0].value: - statement.pop(0).type = None # These tokens will be ignored + statement.pop(0).type = None # These tokens will be ignored class TestCaseLexer(TestOrKeywordLexer): @@ -208,7 +209,7 @@ def __init__(self, ctx: SuiteFileContext): def lex(self): self._lex_with_priority(priority=TestOrKeywordSettingLexer) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TestOrKeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) @@ -219,7 +220,7 @@ class KeywordLexer(TestOrKeywordLexer): def __init__(self, ctx: FileContext): super().__init__(ctx.keyword_context()) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TestOrKeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, ReturnLexer, TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) @@ -230,11 +231,12 @@ def __init__(self, ctx: TestOrKeywordContext): super().__init__(ctx) self._block_level = 0 - def accepts_more(self, statement: list): + def accepts_more(self, statement: 'list[Token]') -> bool: return self._block_level > 0 - def input(self, statement: list): - lexer = super().input(statement) + def input(self, statement: 'list[Token]'): + super().input(statement) + lexer = self.lexers[-1] if isinstance(lexer, (ForHeaderLexer, IfHeaderLexer, TryHeaderLexer, WhileHeaderLexer)): self._block_level += 1 @@ -245,10 +247,10 @@ def input(self, statement: list): class ForLexer(NestedBlockLexer): @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: return ForHeaderLexer.handles(statement, ctx) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (ForHeaderLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, KeywordCallLexer) @@ -256,10 +258,10 @@ def lexer_classes(self): class WhileLexer(NestedBlockLexer): @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: return WhileHeaderLexer.handles(statement, ctx) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (WhileHeaderLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, KeywordCallLexer) @@ -267,10 +269,10 @@ def lexer_classes(self): class TryLexer(NestedBlockLexer): @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: return TryHeaderLexer.handles(statement, ctx) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, ForLexer, InlineIfLexer, IfLexer, WhileLexer, EndLexer, ReturnLexer, BreakLexer, ContinueLexer, SyntaxErrorLexer, KeywordCallLexer) @@ -279,10 +281,10 @@ def lexer_classes(self): class IfLexer(NestedBlockLexer): @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: return IfHeaderLexer.handles(statement, ctx) - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (InlineIfLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ForLexer, TryLexer, WhileLexer, EndLexer, ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, KeywordCallLexer) @@ -291,25 +293,24 @@ def lexer_classes(self): class InlineIfLexer(BlockLexer): @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: if len(statement) <= 2: return False return InlineIfHeaderLexer.handles(statement, ctx) - def accepts_more(self, statement: list): + def accepts_more(self, statement: 'list[Token]') -> bool: return False - def lexer_classes(self): + def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (InlineIfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) - def input(self, statement: list): + def input(self, statement: 'list[Token]'): for part in self._split(statement): if part: super().input(part) - return self - def _split(self, statement): + def _split(self, statement: 'list[Token]') -> 'Iterator[list[Token]]': current = [] expect_condition = False for token in statement: diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index fb4fa99146d..183b9564a22 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -13,90 +13,95 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.conf import Languages +from typing import cast + +from robot.conf import Languages, LanguageLike, LanguagesLike +from robot.parsing.lexer.settings import Settings from robot.utils import normalize_whitespace -from .settings import (InitFileSettings, SuiteFileSettings, ResourceFileSettings, - TestCaseSettings, KeywordSettings) +from .settings import (InitFileSettings, Settings, SuiteFileSettings, + ResourceFileSettings, TestCaseSettings, KeywordSettings) from .tokens import Token +# TODO: Try making generic. +# TODO: Add separate __init__s for FileContext (accepts only lang) and Test/KwContext (accepts only settings) class LexingContext: - settings_class = None + settings_class: 'type[Settings]' - def __init__(self, settings=None, lang=None): - if not settings: - self.languages = lang if isinstance(lang, Languages) else Languages(lang) + def __init__(self, settings: 'Settings|None' = None, lang: LanguagesLike = None): + if settings is None: + if not isinstance(lang, Languages): + lang = Languages(cast(LanguageLike, lang)) + self.languages = lang self.settings = self.settings_class(self.languages) else: self.languages = settings.languages self.settings = settings - def lex_setting(self, statement): + def lex_setting(self, statement: 'list[Token]'): self.settings.lex(statement) class FileContext(LexingContext): - def __init__(self, settings=None, lang=None): - super().__init__(settings, lang) - - def add_language(self, lang): + def add_language(self, lang: LanguageLike): self.languages.add_language(lang) - def keyword_context(self): + def keyword_context(self) -> 'KeywordContext': return KeywordContext(settings=KeywordSettings(self.languages)) - def setting_section(self, statement): + def setting_section(self, statement: 'list[Token]') -> bool: return self._handles_section(statement, 'Settings') - def variable_section(self, statement): + def variable_section(self, statement: 'list[Token]') -> bool: return self._handles_section(statement, 'Variables') - def test_case_section(self, statement): + def test_case_section(self, statement: 'list[Token]') -> bool: return False - def task_section(self, statement): + def task_section(self, statement: 'list[Token]') -> bool: return False - def keyword_section(self, statement): + def keyword_section(self, statement: 'list[Token]') -> bool: return self._handles_section(statement, 'Keywords') - def comment_section(self, statement): + def comment_section(self, statement: 'list[Token]') -> bool: return self._handles_section(statement, 'Comments') - def lex_invalid_section(self, statement): - message = self._get_invalid_section_error(statement[0].value) - statement[0].error = message - statement[0].type = Token.INVALID_HEADER + def lex_invalid_section(self, statement: 'list[Token]'): + header = statement[0] + header.type = Token.INVALID_HEADER + header.error = self._get_invalid_section_error(header.value) for token in statement[1:]: token.type = Token.COMMENT - def _get_invalid_section_error(self, header): + def _get_invalid_section_error(self, header: str) -> str: raise NotImplementedError - def _handles_section(self, statement, header): + def _handles_section(self, statement: 'list[Token]', header: str) -> bool: marker = statement[0].value - return (marker and marker[0] == '*' and - self.languages.headers.get(self._normalize(marker)) == header) + return bool(marker and marker[0] == '*' and + self.languages.headers.get(self._normalize(marker)) == header) - def _normalize(self, marker): + def _normalize(self, marker: str) -> str: return normalize_whitespace(marker).strip('* ').title() class SuiteFileContext(FileContext): settings_class = SuiteFileSettings + settings: SuiteFileSettings - def test_case_context(self): + def test_case_context(self) -> 'TestCaseContext': return TestCaseContext(settings=TestCaseSettings(self.settings, self.languages)) - def test_case_section(self, statement): + def test_case_section(self, statement: 'list[Token]') -> bool: return self._handles_section(statement, 'Test Cases') - def task_section(self, statement): + def task_section(self, statement: 'list[Token]') -> bool: return self._handles_section(statement, 'Tasks') - def _get_invalid_section_error(self, header): + def _get_invalid_section_error(self, header: str) -> str: return (f"Unrecognized section header '{header}'. Valid sections: " f"'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' " f"and 'Comments'.") @@ -105,7 +110,7 @@ def _get_invalid_section_error(self, header): class ResourceFileContext(FileContext): settings_class = ResourceFileSettings - def _get_invalid_section_error(self, header): + def _get_invalid_section_error(self, header: str) -> str: name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): return f"Resource file with '{name}' section is invalid." @@ -113,11 +118,10 @@ def _get_invalid_section_error(self, header): f"'Settings', 'Variables', 'Keywords' and 'Comments'.") - class InitFileContext(FileContext): settings_class = InitFileSettings - def _get_invalid_section_error(self, header): + def _get_invalid_section_error(self, header: str) -> str: name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): return f"'{name}' section is not allowed in suite initialization file." @@ -125,19 +129,21 @@ def _get_invalid_section_error(self, header): f"'Settings', 'Variables', 'Keywords' and 'Comments'.") +# TODO: Try removing base class class TestOrKeywordContext(LexingContext): @property - def template_set(self): + def template_set(self) -> bool: return False class TestCaseContext(TestOrKeywordContext): + settings: TestCaseSettings @property - def template_set(self): + def template_set(self) -> bool: return self.settings.template_set class KeywordContext(TestOrKeywordContext): - pass + settings: KeywordSettings diff --git a/src/robot/parsing/lexer/lexer.py b/src/robot/parsing/lexer/lexer.py index 02df061bdc2..f9428abcb96 100644 --- a/src/robot/parsing/lexer/lexer.py +++ b/src/robot/parsing/lexer/lexer.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from itertools import chain from robot.conf import LanguagesLike @@ -126,7 +126,7 @@ def get_tokens(self) -> 'Iterator[Token]': tokens = self._tokenize_variables(tokens) return tokens - def _get_tokens(self, statements: 'list[list[Token]]') -> 'Iterator[Token]': + def _get_tokens(self, statements: 'Iterable[list[Token]]') -> 'Iterator[Token]': if self.data_only: ignored_types = {None, Token.COMMENT_HEADER, Token.COMMENT} else: @@ -147,11 +147,12 @@ def _get_tokens(self, statements: 'list[list[Token]]') -> 'Iterator[Token]': if token_type == inline_if_type: inline_if = True last = token - if last and not last._add_eos_after: - yield EOS.from_token(last) - if inline_if: - yield END.from_token(last, virtual=True) - yield EOS.from_token(last) + if last: + if not last._add_eos_after: + yield EOS.from_token(last) + if inline_if: + yield END.from_token(last, virtual=True) + yield EOS.from_token(last) def _split_trailing_commented_and_empty_lines(self, statement: 'list[Token]') \ -> 'list[list[Token]]': diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 76aa2280aaf..43fc67d413b 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -13,14 +13,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC, abstractmethod + +from robot.conf import Languages from robot.utils import normalize, normalize_whitespace, RecommendationFinder from .tokens import Token -class Settings: - names = () - aliases = {} +class Settings(ABC): + names: 'tuple[str, ...]' = () + aliases: 'dict[str, str]' = {} multi_use = ( 'Metadata', 'Library', @@ -52,11 +55,11 @@ class Settings: 'Library', ) - def __init__(self, languages): - self.settings = {n: None for n in self.names} + def __init__(self, languages: Languages): + self.settings: 'dict[str, list[Token]|None]' = {n: None for n in self.names} self.languages = languages - def lex(self, statement): + def lex(self, statement: 'list[Token]'): setting = statement[0] orig = self._format_name(setting.value) name = normalize_whitespace(orig).title() @@ -70,10 +73,10 @@ def lex(self, statement): else: self._lex_setting(setting, statement[1:], name) - def _format_name(self, name): + def _format_name(self, name: str) -> str: return name - def _validate(self, orig, name, statement): + def _validate(self, orig: str, name: str, statement: 'list[Token]'): if name not in self.settings: message = self._get_non_existing_setting_message(orig, name) raise ValueError(message) @@ -84,7 +87,7 @@ def _validate(self, orig, name, statement): raise ValueError(f"Setting '{orig}' accepts only one value, " f"got {len(statement)-1}.") - def _get_non_existing_setting_message(self, name, normalized): + def _get_non_existing_setting_message(self, name: str, normalized: str) -> str: if self._is_valid_somewhere(normalized): return self._not_valid_here(name) return RecommendationFinder(normalize).find_and_format( @@ -93,21 +96,22 @@ def _get_non_existing_setting_message(self, name, normalized): message=f"Non-existing setting '{name}'." ) - def _is_valid_somewhere(self, normalized): + def _is_valid_somewhere(self, normalized: str) -> bool: for cls in Settings.__subclasses__(): if normalized in cls.names or normalized in cls.aliases: return True return False - def _not_valid_here(self, name): + @abstractmethod + def _not_valid_here(self, name: str) -> str: raise NotImplementedError - def _lex_error(self, setting, values, error): + def _lex_error(self, setting: Token, values: 'list[Token]', error: str): setting.set_error(error) for token in values: token.type = Token.COMMENT - def _lex_setting(self, setting, values, name): + def _lex_setting(self, setting: Token, values: 'list[Token]', name: str): self.settings[name] = values # TODO: Change token type from 'FORCE TAGS' to 'TEST TAGS' in RF 7.0. setting_type_map = {'Test Tags': 'FORCE TAGS', 'Name': 'SUITE NAME'} @@ -119,19 +123,19 @@ def _lex_setting(self, setting, values, name): else: self._lex_arguments(values) - def _lex_name_and_arguments(self, tokens): + def _lex_name_and_arguments(self, tokens: 'list[Token]'): if tokens: tokens[0].type = Token.NAME - self._lex_arguments(tokens[1:]) + self._lex_arguments(tokens[1:]) - def _lex_name_arguments_and_with_name(self, tokens): + def _lex_name_arguments_and_with_name(self, tokens: 'list[Token]'): self._lex_name_and_arguments(tokens) if len(tokens) > 1 and \ normalize_whitespace(tokens[-2].value) in ('WITH NAME', 'AS'): tokens[-2].type = Token.WITH_NAME tokens[-1].type = Token.NAME - def _lex_arguments(self, tokens): + def _lex_arguments(self, tokens: 'list[Token]'): for token in tokens: token.type = Token.ARGUMENT @@ -163,7 +167,7 @@ class SuiteFileSettings(Settings): 'Task Timeout': 'Test Timeout', } - def _not_valid_here(self, name): + def _not_valid_here(self, name: str) -> str: return f"Setting '{name}' is not allowed in suite file." @@ -191,7 +195,7 @@ class InitFileSettings(Settings): 'Task Timeout': 'Test Timeout', } - def _not_valid_here(self, name): + def _not_valid_here(self, name: str) -> str: return f"Setting '{name}' is not allowed in suite initialization file." @@ -204,7 +208,7 @@ class ResourceFileSettings(Settings): 'Variables' ) - def _not_valid_here(self, name): + def _not_valid_here(self, name: str) -> str: return f"Setting '{name}' is not allowed in resource file." @@ -218,30 +222,30 @@ class TestCaseSettings(Settings): 'Timeout' ) - def __init__(self, parent, languages): + def __init__(self, parent: SuiteFileSettings, languages: Languages): super().__init__(languages) self.parent = parent - def _format_name(self, name): + def _format_name(self, name: str) -> str: return name[1:-1].strip() @property - def template_set(self): + def template_set(self) -> bool: template = self.settings['Template'] if self._has_disabling_value(template): return False parent_template = self.parent.settings['Test Template'] return self._has_value(template) or self._has_value(parent_template) - def _has_disabling_value(self, setting): + def _has_disabling_value(self, setting: 'list[Token]|None') -> bool: if setting is None: return False return setting == [] or setting[0].value.upper() == 'NONE' - def _has_value(self, setting): - return setting and setting[0].value + def _has_value(self, setting: 'list[Token]|None') -> bool: + return bool(setting and setting[0].value) - def _not_valid_here(self, name): + def _not_valid_here(self, name: str) -> str: return f"Setting '{name}' is not allowed with tests or tasks." @@ -255,8 +259,8 @@ class KeywordSettings(Settings): 'Return' ) - def _format_name(self, name): + def _format_name(self, name: str) -> str: return name[1:-1].strip() - def _not_valid_here(self, name): + def _not_valid_here(self, name: str) -> str: return f"Setting '{name}' is not allowed with user keywords." diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 14fb440312e..34c0d803486 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC, abstractmethod +from typing import List + from robot.errors import DataError from robot.utils import normalize_whitespace from robot.variables import is_assign @@ -21,51 +24,57 @@ from .tokens import Token -class Lexer: - """Base class for lexers.""" +Statement = List[Token] + + +# TODO: Try making generic. +class Lexer(ABC): def __init__(self, ctx: LexingContext): self.ctx = ctx @classmethod - def handles(cls, statement: list, ctx: LexingContext): + def handles(cls, statement: Statement, ctx: LexingContext) -> bool: return True - def accepts_more(self, statement: list): + @abstractmethod + def accepts_more(self, statement: Statement) -> bool: raise NotImplementedError - def input(self, statement: list): + @abstractmethod + def input(self, statement: Statement): raise NotImplementedError + @abstractmethod def lex(self): raise NotImplementedError -class StatementLexer(Lexer): - token_type = None +class StatementLexer(Lexer, ABC): + token_type: str def __init__(self, ctx: FileContext): super().__init__(ctx) - self.statement = None + self.statement: Statement = [] - def accepts_more(self, statement: list): + def accepts_more(self, statement: Statement) -> bool: return False - def input(self, statement: list): + def input(self, statement: Statement): self.statement = statement def lex(self): raise NotImplementedError -class SingleType(StatementLexer): +class SingleType(StatementLexer, ABC): def lex(self): for token in self.statement: token.type = self.token_type -class TypeAndArguments(StatementLexer): +class TypeAndArguments(StatementLexer, ABC): def lex(self): self.statement[0].type = self.token_type @@ -73,11 +82,11 @@ def lex(self): token.type = Token.ARGUMENT -class SectionHeaderLexer(SingleType): +class SectionHeaderLexer(SingleType, ABC): ctx: FileContext @classmethod - def handles(cls, statement: list, ctx: FileContext): + def handles(cls, statement: Statement, ctx: FileContext) -> bool: return statement[0].value.startswith('*') @@ -119,7 +128,7 @@ class CommentLexer(SingleType): class ImplicitCommentLexer(CommentLexer): ctx: FileContext - def input(self, statement: list): + def input(self, statement: Statement): super().input(statement) if len(statement) == 1 and statement[0].value.lower().startswith('language:'): lang = statement[0].value.split(':', 1)[1].strip() @@ -145,12 +154,13 @@ def lex(self): self.ctx.lex_setting(self.statement) +# TODO: Try splitting to TestSettingLexer and KeywordSettingLexer. Same with Context. class TestOrKeywordSettingLexer(SettingLexer): @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: marker = statement[0].value - return marker and marker[0] == '[' and marker[-1] == ']' + return bool(marker and marker[0] == '[' and marker[-1] == ']') class VariableLexer(TypeAndArguments): @@ -186,7 +196,7 @@ class ForHeaderLexer(StatementLexer): separators = ('IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP') @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'FOR' def lex(self): @@ -214,7 +224,7 @@ class IfHeaderLexer(TypeAndArguments): token_type = Token.IF @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'IF' and len(statement) <= 2 @@ -222,7 +232,7 @@ class InlineIfHeaderLexer(StatementLexer): token_type = Token.INLINE_IF @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: for token in statement: if token.value == 'IF': return True @@ -246,7 +256,7 @@ class ElseIfHeaderLexer(TypeAndArguments): token_type = Token.ELSE_IF @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return normalize_whitespace(statement[0].value) == 'ELSE IF' @@ -254,7 +264,7 @@ class ElseHeaderLexer(TypeAndArguments): token_type = Token.ELSE @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'ELSE' @@ -262,7 +272,7 @@ class TryHeaderLexer(TypeAndArguments): token_type = Token.TRY @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'TRY' @@ -270,7 +280,7 @@ class ExceptHeaderLexer(StatementLexer): token_type = Token.EXCEPT @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'EXCEPT' def lex(self): @@ -294,7 +304,7 @@ class FinallyHeaderLexer(TypeAndArguments): token_type = Token.FINALLY @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'FINALLY' @@ -302,7 +312,7 @@ class WhileHeaderLexer(StatementLexer): token_type = Token.WHILE @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'WHILE' def lex(self): @@ -320,7 +330,7 @@ class EndLexer(TypeAndArguments): token_type = Token.END @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'END' @@ -328,7 +338,7 @@ class ReturnLexer(TypeAndArguments): token_type = Token.RETURN_STATEMENT @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'RETURN' @@ -336,7 +346,7 @@ class ContinueLexer(TypeAndArguments): token_type = Token.CONTINUE @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'CONTINUE' @@ -344,7 +354,7 @@ class BreakLexer(TypeAndArguments): token_type = Token.BREAK @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value == 'BREAK' @@ -352,7 +362,7 @@ class SyntaxErrorLexer(TypeAndArguments): token_type = Token.ERROR @classmethod - def handles(cls, statement: list, ctx: TestOrKeywordContext): + def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: return statement[0].value in {'ELSE', 'ELSE IF', 'EXCEPT', 'FINALLY', 'BREAK', 'CONTINUE', 'RETURN', 'END'} diff --git a/src/robot/parsing/lexer/tokenizer.py b/src/robot/parsing/lexer/tokenizer.py index e16bfd8873c..9058cfb3f5f 100644 --- a/src/robot/parsing/lexer/tokenizer.py +++ b/src/robot/parsing/lexer/tokenizer.py @@ -24,7 +24,7 @@ class Tokenizer: _pipe_splitter = re.compile(r'((?:\A|\s+)\|(?:\s+|\Z))', re.UNICODE) def tokenize(self, data: str, data_only: bool = False) -> 'Iterator[list[Token]]': - current = [] + current: 'list[Token]' = [] for lineno, line in enumerate(data.splitlines(not data_only), start=1): tokens = self._tokenize_line(line, lineno, not data_only) tokens, starts_new = self._cleanup_tokens(tokens, data_only) @@ -38,7 +38,7 @@ def tokenize(self, data: str, data_only: bool = False) -> 'Iterator[list[Token]] def _tokenize_line(self, line: str, lineno: int, include_separators: bool): # Performance optimized code. - tokens = [] + tokens: 'list[Token]' = [] append = tokens.append offset = 0 if line[:1] == '|' and line[:2].strip() == '|': @@ -72,7 +72,7 @@ def _split_from_pipes(self, line) -> 'Iterator[tuple[str, bool]]': yield separator, False yield rest, True - def _cleanup_tokens(self, tokens, data_only): + def _cleanup_tokens(self, tokens: 'list[Token]', data_only: bool): has_data, has_comments, continues \ = self._handle_comments_and_continuation(tokens) self._remove_trailing_empty(tokens) @@ -87,7 +87,8 @@ def _cleanup_tokens(self, tokens, data_only): tokens = [t for t in tokens if t.type is None] return tokens, starts_new - def _handle_comments_and_continuation(self, tokens): + def _handle_comments_and_continuation(self, tokens: 'list[Token]') \ + -> 'tuple[bool, bool, bool]': has_data = False commented = False continues = False @@ -110,14 +111,14 @@ def _handle_comments_and_continuation(self, tokens): has_data = True return has_data, commented, continues - def _remove_trailing_empty(self, tokens): + def _remove_trailing_empty(self, tokens: 'list[Token]'): for token in reversed(tokens): if not token.value and token.type != Token.EOL: tokens.remove(token) elif token.type is None: break - def _remove_leading_empty(self, tokens): + def _remove_leading_empty(self, tokens: 'list[Token]'): data_or_continuation = (None, Token.CONTINUATION) for token in list(tokens): if not token.value: @@ -125,12 +126,13 @@ def _remove_leading_empty(self, tokens): elif token.type in data_or_continuation: break - def _ensure_data_after_continuation(self, tokens): + def _ensure_data_after_continuation(self, tokens: 'list[Token]'): cont = self._find_continuation(tokens) token = Token(lineno=cont.lineno, col_offset=cont.end_col_offset) tokens.insert(tokens.index(cont) + 1, token) - def _find_continuation(self, tokens): + def _find_continuation(self, tokens: 'list[Token]') -> Token: for token in tokens: if token.type == Token.CONTINUATION: return token + raise ValueError('Continuation not found.') diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index d71035bb939..6ebc27324c3 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -14,6 +14,7 @@ # limitations under the License. from collections.abc import Iterator +from typing import cast from robot.variables import VariableIterator @@ -74,12 +75,15 @@ class Token: RETURN = 'RETURN' RETURN_SETTING = RETURN + # TODO: Change WITH_NAME value to AS in RF 7.0. Remove WITH_NAME in RF 8. + WITH_NAME = 'WITH NAME' + AS = 'AS' + NAME = 'NAME' VARIABLE = 'VARIABLE' ARGUMENT = 'ARGUMENT' ASSIGN = 'ASSIGN' KEYWORD = 'KEYWORD' - WITH_NAME = 'WITH NAME' FOR = 'FOR' FOR_SEPARATOR = 'FOR SEPARATOR' END = 'END' @@ -90,7 +94,6 @@ class Token: TRY = 'TRY' EXCEPT = 'EXCEPT' FINALLY = 'FINALLY' - AS = 'AS' WHILE = 'WHILE' RETURN_STATEMENT = 'RETURN STATEMENT' CONTINUE = 'CONTINUE' @@ -168,8 +171,8 @@ def __init__(self, type: 'str|None' = None, value: 'str|None' = None, Token.END: 'END', Token.CONTINUE: 'CONTINUE', Token.BREAK: 'BREAK', Token.RETURN_STATEMENT: 'RETURN', Token.CONTINUATION: '...', Token.EOL: '\n', Token.WITH_NAME: 'WITH NAME', Token.AS: 'AS' - }.get(type, '') - self.value = value + }.get(type, '') # type: ignore + self.value = cast(str, value) self.lineno = lineno self.col_offset = col_offset self.error = error @@ -203,10 +206,10 @@ def tokenize_variables(self) -> 'Iterator[Token]': return self._tokenize_no_variables() return self._tokenize_variables(variables) - def _tokenize_no_variables(self): + def _tokenize_no_variables(self) -> 'Iterator[Token]': yield self - def _tokenize_variables(self, variables): + def _tokenize_variables(self, variables) -> 'Iterator[Token]': lineno = self.lineno col_offset = self.col_offset remaining = '' diff --git a/src/robot/parsing/model/__init__.py b/src/robot/parsing/model/__init__.py index 85b0fa4af63..49ee2fcd2b5 100644 --- a/src/robot/parsing/model/__init__.py +++ b/src/robot/parsing/model/__init__.py @@ -13,8 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .blocks import (File, SettingSection, VariableSection, TestCaseSection, - KeywordSection, CommentSection, InvalidSection, - TestCase, Keyword, For, If, Try, While) -from .statements import Statement +from .blocks import (Block, CommentSection, Container, File, For, If, + ImplicitCommentSection, InvalidSection, Keyword, + KeywordSection, NestedBlock, Section, SettingSection, + TestCase, TestCaseSection, Try, VariableSection, While) +from .statements import Config, End, Statement from .visitor import ModelTransformer, ModelVisitor diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 8354380d8ea..e0e9b004ac4 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -13,39 +13,46 @@ # See the License for the specific language governing permissions and # limitations under the License. -import ast +from abc import ABC from contextlib import contextmanager +from io import IOBase +from pathlib import Path +from typing import cast, Iterator, Sequence, Union -from robot.utils import file_writer, is_pathlike, is_string, test_or_task +from robot.utils import file_writer, test_or_task -from .statements import (Break, Continue, Error, KeywordCall, ReturnSetting, - ReturnStatement, Statement, TemplateArguments) +from .statements import (Break, Continue, ElseHeader, ElseIfHeader, End, ExceptHeader, + Error, FinallyHeader, ForHeader, IfHeader, KeywordCall, + KeywordName, Node, ReturnSetting, ReturnStatement, + SectionHeader, Statement, TemplateArguments, TestCaseName, + TryHeader, WhileHeader) from .visitor import ModelVisitor from ..lexer import Token -class Block(ast.AST): - _fields = () - _attributes = ('lineno', 'col_offset', 'end_lineno', 'end_col_offset', 'errors') - errors = () +Body = Sequence[Union[Statement, 'Block']] +Errors = Sequence[str] + + +class Container(Node, ABC): @property - def lineno(self): + def lineno(self) -> int: statement = FirstStatementFinder.find_from(self) return statement.lineno if statement else -1 @property - def col_offset(self): + def col_offset(self) -> int: statement = FirstStatementFinder.find_from(self) return statement.col_offset if statement else -1 @property - def end_lineno(self): + def end_lineno(self) -> int: statement = LastStatementFinder.find_from(self) return statement.end_lineno if statement else -1 @property - def end_col_offset(self): + def end_col_offset(self) -> int: statement = LastStatementFinder.find_from(self) return statement.end_col_offset if statement else -1 @@ -56,31 +63,18 @@ def validate(self, ctx: 'ValidationContext'): pass -class HeaderAndBody(Block): - _fields = ('header', 'body') - - def __init__(self, header=None, body=None, errors=()): - self.header = header - self.body = body or [] - self.errors = errors - - def _body_is_empty(self): - # This works with tests, keywords and blocks inside them, not with sections. - valid = (KeywordCall, TemplateArguments, Continue, ReturnStatement, Break, - Block, Error) - return not any(isinstance(node, valid) for node in self.body) - - -class File(Block): +class File(Container): _fields = ('sections',) - _attributes = ('source', 'languages') + Block._attributes + _attributes = ('source', 'languages') + Container._attributes - def __init__(self, sections=None, source=None, languages=()): - self.sections = sections or [] + def __init__(self, sections: 'Sequence[Section]' = (), source: 'Path|None' = None, + languages: Sequence[str] = ()): + super().__init__() + self.sections = list(sections) self.source = source - self.languages = languages + self.languages = list(languages) - def save(self, output=None): + def save(self, output: 'Path|str|IOBase|None' = None): """Save model to the given ``output`` or to the original source file. The ``output`` can be a path to a file or an already opened file @@ -94,42 +88,68 @@ def save(self, output=None): ModelWriter(output).write(self) -class Section(HeaderAndBody): - pass +class Block(Container, ABC): + _fields = ('header', 'body') + + def __init__(self, header: 'Statement|None', body: Body = (), errors: Errors = ()): + self.header = header + self.body = list(body) + self.errors = tuple(errors) + + def _body_is_empty(self): + # This works with tests, keywords, and blocks inside them, not with sections. + valid = (KeywordCall, TemplateArguments, Continue, Break, ReturnSetting, + ReturnStatement, NestedBlock, Error) + return not any(isinstance(node, valid) for node in self.body) + + +class Section(Block): + header: 'SectionHeader|None' class SettingSection(Section): - pass + header: SectionHeader class VariableSection(Section): - pass + header: SectionHeader # TODO: should there be a separate TaskSection? class TestCaseSection(Section): + header: SectionHeader @property - def tasks(self): + def tasks(self) -> bool: return self.header.type == Token.TASK_HEADER class KeywordSection(Section): - pass + header: SectionHeader class CommentSection(Section): - pass + header: 'SectionHeader|None' + + +class ImplicitCommentSection(CommentSection): + header: None + + def __init__(self, header: 'Statement|None' = None, body: Body = (), + errors: Errors = ()): + body = ([header] if header is not None else []) + list(body) + super().__init__(None, body, errors) class InvalidSection(Section): pass -class TestCase(HeaderAndBody): +class TestCase(Block): + header: TestCaseName @property - def name(self): + def name(self) -> str: return self.header.name def validate(self, ctx: 'ValidationContext'): @@ -137,41 +157,51 @@ def validate(self, ctx: 'ValidationContext'): self.errors += (test_or_task('{Test} cannot be empty.', ctx.tasks),) -class Keyword(HeaderAndBody): +class Keyword(Block): + header: KeywordName @property - def name(self): + def name(self) -> str: return self.header.name def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): - if not any(isinstance(node, ReturnSetting) for node in self.body): - self.errors += ("User keyword cannot be empty.",) + self.errors += ("User keyword cannot be empty.",) + + +class NestedBlock(Block): + _fields = ('header', 'body', 'end') + + def __init__(self, header: Statement, body: Body = (), end: 'End|None' = None, + errors: Errors = ()): + super().__init__(header, body, errors) + self.end = end -class If(HeaderAndBody): +class If(NestedBlock): """Represents IF structures in the model. Used with IF, Inline IF, ELSE IF and ELSE nodes. The :attr:`type` attribute specifies the type. """ _fields = ('header', 'body', 'orelse', 'end') + header: 'IfHeader|ElseIfHeader|ElseHeader' - def __init__(self, header, body=None, orelse=None, end=None, errors=()): - super().__init__(header, body, errors) + def __init__(self, header: Statement, body: Body = (), orelse: 'If|None' = None, + end: 'End|None' = None, errors: Errors = ()): + super().__init__(header, body, end, errors) self.orelse = orelse - self.end = end @property - def type(self): + def type(self) -> str: return self.header.type @property - def condition(self): + def condition(self) -> 'str|None': return self.header.condition @property - def assign(self): + def assign(self) -> 'tuple[str, ...]': return self.header.assign def validate(self, ctx: 'ValidationContext'): @@ -211,7 +241,7 @@ def _validate_inline_if(self): assign = branch.assign while branch: if branch.body: - item = branch.body[0] + item = cast(Statement, branch.body[0]) if assign and item.type != Token.KEYWORD: self.errors += ('Inline IF with assignment can only contain ' 'keyword calls.',) @@ -222,35 +252,31 @@ def _validate_inline_if(self): branch = branch.orelse -class For(HeaderAndBody): - _fields = ('header', 'body', 'end') - - def __init__(self, header, body=None, end=None, errors=()): - super().__init__(header, body, errors) - self.end = end +class For(NestedBlock): + header: ForHeader @property - def variables(self): + def variables(self) -> 'tuple[str, ...]': return self.header.variables @property - def values(self): + def values(self) -> 'tuple[str, ...]': return self.header.values @property - def flavor(self): + def flavor(self) -> 'str|None': return self.header.flavor @property - def start(self): + def start(self) -> 'str|None': return self.header.start @property - def mode(self): + def mode(self) -> 'str|None': return self.header.mode @property - def fill(self): + def fill(self) -> 'str|None': return self.header.fill def validate(self, ctx: 'ValidationContext'): @@ -260,28 +286,29 @@ def validate(self, ctx: 'ValidationContext'): self.errors += ('FOR loop must have closing END.',) -class Try(HeaderAndBody): +class Try(NestedBlock): _fields = ('header', 'body', 'next', 'end') + header: 'TryHeader|ExceptHeader|ElseHeader|FinallyHeader' - def __init__(self, header, body=None, next=None, end=None, errors=()): - super().__init__(header, body, errors) + def __init__(self, header: Statement, body: Body = (), next: 'Try|None' = None, + end: 'End|None' = None, errors: Errors = ()): + super().__init__(header, body, end, errors) self.next = next - self.end = end @property - def type(self): + def type(self) -> str: return self.header.type @property - def patterns(self): + def patterns(self) -> 'tuple[str, ...]': return getattr(self.header, 'patterns', ()) @property - def pattern_type(self): + def pattern_type(self) -> 'str|None': return getattr(self.header, 'pattern_type', None) @property - def variable(self): + def variable(self) -> 'str|None': return getattr(self.header, 'variable', None) def validate(self, ctx: 'ValidationContext'): @@ -332,27 +359,23 @@ def _validate_end(self): self.errors += ('TRY must have closing END.',) -class While(HeaderAndBody): - _fields = ('header', 'body', 'end') - - def __init__(self, header, body=None, end=None, errors=()): - super().__init__(header, body, errors) - self.end = end +class While(NestedBlock): + header: WhileHeader @property - def condition(self): + def condition(self) -> str: return self.header.condition @property - def limit(self): + def limit(self) -> 'str|None': return self.header.limit @property - def on_limit(self): + def on_limit(self) -> 'str|None': return self.header.on_limit @property - def on_limit_message(self): + def on_limit_message(self) -> 'str|None': return self.header.on_limit_message def validate(self, ctx: 'ValidationContext'): @@ -364,15 +387,15 @@ def validate(self, ctx: 'ValidationContext'): class ModelWriter(ModelVisitor): - def __init__(self, output): - if is_string(output) or is_pathlike(output): + def __init__(self, output: 'Path|str|IOBase'): + if isinstance(output, (Path, str)): self.writer = file_writer(output) self.close_writer = True else: self.writer = output self.close_writer = False - def write(self, model: Block): + def write(self, model: Node): try: self.visit(model) finally: @@ -380,7 +403,7 @@ def write(self, model: Block): self.writer.close() def visit_Statement(self, statement: Statement): - for token in statement.tokens: + for token in statement: self.writer.write(token.value) @@ -404,7 +427,7 @@ def __init__(self): self.blocks = [] @contextmanager - def block(self, node: Block): + def block(self, node: Block) -> Iterator[None]: self.blocks.append(node) try: yield @@ -412,26 +435,26 @@ def block(self, node: Block): self.blocks.pop() @property - def parent_block(self): + def parent_block(self) -> 'Block|None': return self.blocks[-1] if self.blocks else None @property - def tasks(self): + def tasks(self) -> bool: for parent in self.blocks: if isinstance(parent, TestCaseSection): return parent.tasks return False @property - def in_keyword(self): + def in_keyword(self) -> bool: return any(isinstance(b, Keyword) for b in self.blocks) @property - def in_loop(self): + def in_loop(self) -> bool: return any(isinstance(b, (For, While)) for b in self.blocks) @property - def in_finally(self): + def in_finally(self) -> bool: parent = self.parent_block return isinstance(parent, Try) and parent.header.type == Token.FINALLY @@ -439,19 +462,19 @@ def in_finally(self): class FirstStatementFinder(ModelVisitor): def __init__(self): - self.statement = None + self.statement: 'Statement|None' = None @classmethod - def find_from(cls, model): + def find_from(cls, model: Node) -> 'Statement|None': finder = cls() finder.visit(model) return finder.statement - def visit_Statement(self, statement): + def visit_Statement(self, statement: Statement): if self.statement is None: self.statement = statement - def generic_visit(self, node): + def generic_visit(self, node: Node): if self.statement is None: super().generic_visit(node) @@ -459,13 +482,13 @@ def generic_visit(self, node): class LastStatementFinder(ModelVisitor): def __init__(self): - self.statement = None + self.statement: 'Statement|None' = None @classmethod - def find_from(cls, model): + def find_from(cls, model: Node) -> 'Statement|None': finder = cls() finder.visit(model) return finder.statement - def visit_Statement(self, statement): + def visit_Statement(self, statement: Statement): self.statement = statement diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 9e7adcc7c84..458c8b13117 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -15,12 +15,13 @@ import ast import re -from typing import TYPE_CHECKING +from abc import ABC, abstractmethod +from collections.abc import Iterator, Sequence +from typing import cast, ClassVar, overload, TYPE_CHECKING, Type, TypeVar from robot.conf import Language from robot.running.arguments import UserKeywordArgumentParser -from robot.utils import (is_list_like, normalize_whitespace, seq2str, split_from_equals, - test_or_task) +from robot.utils import normalize_whitespace, seq2str, split_from_equals, test_or_task from robot.variables import is_scalar_assign, is_dict_variable, search_variable from ..lexer import Token @@ -29,47 +30,64 @@ from .blocks import ValidationContext +T = TypeVar('T', bound='Statement') FOUR_SPACES = ' ' EOL = '\n' -class Statement(ast.AST): - type = None - handles_types = () - _fields = ('type', 'tokens') +class Node(ast.AST, ABC): _attributes = ('lineno', 'col_offset', 'end_lineno', 'end_col_offset', 'errors') - _statement_handlers = {} + lineno: int + col_offset: int + end_lineno: int + end_col_offset: int + errors: 'tuple[str, ...]' = () + - def __init__(self, tokens, errors=()): +class Statement(Node, ABC): + _fields = ('type', 'tokens') + type: str + handles_types: 'ClassVar[tuple[str, ...]]' = () + statement_handlers: 'ClassVar[dict[str, Type[Statement]]]' = {} + + def __init__(self, tokens: 'Sequence[Token]', errors: 'Sequence[str]' = ()): self.tokens = tuple(tokens) - self.errors = errors + self.errors = tuple(errors) @property - def lineno(self): + def lineno(self) -> int: return self.tokens[0].lineno if self.tokens else -1 @property - def col_offset(self): + def col_offset(self) -> int: return self.tokens[0].col_offset if self.tokens else -1 @property - def end_lineno(self): + def end_lineno(self) -> int: return self.tokens[-1].lineno if self.tokens else -1 @property - def end_col_offset(self): + def end_col_offset(self) -> int: return self.tokens[-1].end_col_offset if self.tokens else -1 @classmethod - def register(cls, subcls): + def register(cls, subcls: Type[T]) -> Type[T]: types = subcls.handles_types or (subcls.type,) for typ in types: - cls._statement_handlers[typ] = subcls + cls.statement_handlers[typ] = subcls return subcls @classmethod - def from_tokens(cls, tokens): - handlers = cls._statement_handlers + def from_tokens(cls, tokens: 'Sequence[Token]') -> 'Statement': + """Create a statement from given tokens. + + Statement type is got automatically from token types. + + This classmethod should be called from :class:`Statement`, not from + its subclasses. If you know the subclass to use, simply create an + instance of it directly. + """ + handlers = cls.statement_handlers for token in tokens: if token.type in handlers: return handlers[token.type](tokens) @@ -78,7 +96,8 @@ def from_tokens(cls, tokens): return EmptyLine(tokens) @classmethod - def from_params(cls, *args, **kwargs): + @abstractmethod + def from_params(cls, *args, **kwargs) -> 'Statement': """Create a statement from passed parameters. Required and optional arguments in general match class properties. @@ -89,14 +108,18 @@ def from_params(cls, *args, **kwargs): - ``separator`` whitespace inserted between each token. Default is four spaces. - ``indent`` whitespace inserted before first token. Default is four spaces. - ``eol`` end of line sign. Default is ``'\\n'``. + + This classmethod should be called from the :class:`Statement` subclass + to create, not from the :class:`Statement` class itself. """ raise NotImplementedError @property - def data_tokens(self): + def data_tokens(self) -> 'list[Token]': return [t for t in self.tokens if t.type not in Token.NON_DATA_TOKENS] - def get_token(self, *types): + # TODO: Try raising an exception if three's no match. + def get_token(self, *types: str) -> 'Token|None': """Return a token with any of the given ``types``. If there are no matches, return ``None``. If there are multiple @@ -107,11 +130,19 @@ def get_token(self, *types): return token return None - def get_tokens(self, *types): + def get_tokens(self, *types: str) -> 'list[Token]': """Return tokens having any of the given ``types``.""" return [t for t in self.tokens if t.type in types] - def get_value(self, type, default=None): + @overload + def get_value(self, type: str, default: str) -> str: + ... + + @overload + def get_value(self, type: str, default: None = None) -> 'str|None': + ... + + def get_value(self, type: str, default: 'str|None' = None) -> 'str|None': """Return value of a token with the given ``type``. If there are no matches, return ``default``. If there are multiple @@ -120,22 +151,28 @@ def get_value(self, type, default=None): token = self.get_token(type) return token.value if token else default - def get_values(self, *types): + def get_values(self, *types: str) -> 'tuple[str, ...]': """Return values of tokens having any of the given ``types``.""" return tuple(t.value for t in self.tokens if t.type in types) - def get_option(self, name, default=None): + def get_option(self, name: str, default: 'str|None' = None) -> 'str|None': """Return value of a configuration option with the given ``name``. If the option has not been used, return ``default``. New in Robot Framework 6.1. """ - options = dict(opt.split('=', 1) for opt in self.get_values(Token.OPTION)) - return options.get(name, default) + # FIXME: Change the logic to return the first match, not the last. + # Also change validation so that only one option is allowed. + result = default + for option in self.get_values(Token.OPTION): + opt_name, opt_value = option.split('=', 1) + if opt_name == name: + result = opt_value + return result @property - def lines(self): + def lines(self) -> 'Iterator[list[Token]]': line = [] for token in self.tokens: line.append(token) @@ -148,16 +185,16 @@ def lines(self): def validate(self, ctx: 'ValidationContext'): pass - def __iter__(self): + def __iter__(self) -> 'Iterator[Token]': return iter(self.tokens) - def __len__(self): + def __len__(self) -> int: return len(self.tokens) - def __getitem__(self, item): + def __getitem__(self, item) -> Token: return self.tokens[item] - def __repr__(self): + def __repr__(self) -> str: name = type(self).__name__ tokens = f'tokens={list(self.tokens)}' errors = f', errors={list(self.errors)}' if self.errors else '' @@ -167,10 +204,10 @@ def __repr__(self): class DocumentationOrMetadata(Statement): @property - def value(self): + def value(self) -> str: return ''.join(self._get_lines()).rstrip() - def _get_lines(self): + def _get_lines(self) -> 'Iterator[str]': base_offset = -1 for tokens in self._get_line_tokens(): yield from self._get_line_values(tokens, base_offset) @@ -178,8 +215,8 @@ def _get_lines(self): if base_offset < 0 or 0 < first.col_offset < base_offset and first.value: base_offset = first.col_offset - def _get_line_tokens(self): - line = [] + def _get_line_tokens(self) -> 'Iterator[list[Token]]': + line: 'list[Token]' = [] lineno = -1 # There are no EOLs during execution or if data has been parsed with # `data_only=True` otherwise, so we need to look at line numbers to @@ -199,7 +236,7 @@ def _get_line_tokens(self): if line: yield line - def _get_line_values(self, tokens, offset): + def _get_line_values(self, tokens: 'list[Token]', offset: int) -> 'Iterator[str]': token = None for index, token in enumerate(tokens): if token.col_offset > offset > 0: @@ -211,22 +248,22 @@ def _get_line_values(self, tokens, offset): if token and not self._has_trailing_backslash_or_newline(token.value): yield '\n' - def _remove_trailing_backslash(self, value): + def _remove_trailing_backslash(self, value: str) -> str: if value and value[-1] == '\\': match = re.search(r'(\\+)$', value) - if len(match.group(1)) % 2 == 1: + if match and len(match.group(1)) % 2 == 1: value = value[:-1] return value - def _has_trailing_backslash_or_newline(self, line): + def _has_trailing_backslash_or_newline(self, line: str) -> bool: match = re.search(r'(\\+)n?$', line) - return match and len(match.group(1)) % 2 == 1 + return bool(match and len(match.group(1)) % 2 == 1) class SingleValue(Statement): @property - def value(self): + def value(self) -> 'str|None': values = self.get_values(Token.NAME, Token.ARGUMENT) if values and values[0].upper() != 'NONE': return values[0] @@ -236,18 +273,18 @@ def value(self): class MultiValue(Statement): @property - def values(self): + def values(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) class Fixture(Statement): @property - def name(self): - return self.get_value(Token.NAME) + def name(self) -> str: + return self.get_value(Token.NAME, '') @property - def args(self): + def args(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) @@ -259,27 +296,28 @@ class SectionHeader(Statement): Token.INVALID_HEADER) @classmethod - def from_params(cls, type, name=None, eol=EOL): + def from_params(cls, type: str, name: 'str|None' = None, + eol: str = EOL) -> 'SectionHeader': if not name: names = ('Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords', 'Comments') name = dict(zip(cls.handles_types, names))[type] - if not name.startswith('*'): - name = f'*** {name} ***' + name = cast(str, name) + header = f'*** {name} ***' if not name.startswith('*') else name return cls([ - Token(type, name), - Token('EOL', '\n') + Token(type, header), + Token(Token.EOL, eol) ]) @property - def type(self): + def type(self) -> str: token = self.get_token(*self.handles_types) - return token.type + return token.type # type: ignore @property - def name(self): + def name(self) -> str: token = self.get_token(*self.handles_types) - return normalize_whitespace(token.value).strip('* ') + return normalize_whitespace(token.value).strip('* ') if token else '' @Statement.register @@ -287,7 +325,8 @@ class LibraryImport(Statement): type = Token.LIBRARY @classmethod - def from_params(cls, name, args=(), alias=None, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, args: 'Sequence[str]' = (), alias: 'str|None' = None, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'LibraryImport': tokens = [Token(Token.LIBRARY, 'Library'), Token(Token.SEPARATOR, separator), Token(Token.NAME, name)] @@ -303,17 +342,17 @@ def from_params(cls, name, args=(), alias=None, separator=FOUR_SPACES, eol=EOL): return cls(tokens) @property - def name(self): - return self.get_value(Token.NAME) + def name(self) -> str: + return self.get_value(Token.NAME, '') @property - def args(self): + def args(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) @property - def alias(self): - with_name = self.get_token(Token.WITH_NAME) - return self.get_tokens(Token.NAME)[-1].value if with_name else None + def alias(self) -> 'str|None': + separator = self.get_token(Token.WITH_NAME) + return self.get_tokens(Token.NAME)[-1].value if separator else None @Statement.register @@ -321,7 +360,8 @@ class ResourceImport(Statement): type = Token.RESOURCE @classmethod - def from_params(cls, name, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, separator: str = FOUR_SPACES, + eol: str = EOL) -> 'ResourceImport': return cls([ Token(Token.RESOURCE, 'Resource'), Token(Token.SEPARATOR, separator), @@ -330,8 +370,8 @@ def from_params(cls, name, separator=FOUR_SPACES, eol=EOL): ]) @property - def name(self): - return self.get_value(Token.NAME) + def name(self) -> str: + return self.get_value(Token.NAME, '') @Statement.register @@ -339,7 +379,8 @@ class VariablesImport(Statement): type = Token.VARIABLES @classmethod - def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, args: 'Sequence[str]' = (), + separator: str = FOUR_SPACES, eol: str = EOL) -> 'VariablesImport': tokens = [Token(Token.VARIABLES, 'Variables'), Token(Token.SEPARATOR, separator), Token(Token.NAME, name)] @@ -350,11 +391,11 @@ def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): return cls(tokens) @property - def name(self): - return self.get_value(Token.NAME) + def name(self) -> str: + return self.get_value(Token.NAME, '') @property - def args(self): + def args(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) @@ -363,8 +404,9 @@ class Documentation(DocumentationOrMetadata): type = Token.DOCUMENTATION @classmethod - def from_params(cls, value, indent=FOUR_SPACES, separator=FOUR_SPACES, - eol=EOL, settings_section=True): + def from_params(cls, value: str, indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL, + settings_section: bool = True) -> 'Documentation': if settings_section: tokens = [Token(Token.DOCUMENTATION, 'Documentation'), Token(Token.SEPARATOR, separator)] @@ -393,7 +435,8 @@ class Metadata(DocumentationOrMetadata): type = Token.METADATA @classmethod - def from_params(cls, name, value, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, value: str, separator: str = FOUR_SPACES, + eol: str = EOL) -> 'Metadata': tokens = [Token(Token.METADATA, 'Metadata'), Token(Token.SEPARATOR, separator), Token(Token.NAME, name)] @@ -410,8 +453,8 @@ def from_params(cls, name, value, separator=FOUR_SPACES, eol=EOL): return cls(tokens) @property - def name(self): - return self.get_value(Token.NAME) + def name(self) -> str: + return self.get_value(Token.NAME, '') @Statement.register @@ -419,7 +462,8 @@ class ForceTags(MultiValue): type = Token.FORCE_TAGS @classmethod - def from_params(cls, values, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, values: 'Sequence[str]', separator: str = FOUR_SPACES, + eol: str = EOL) -> 'ForceTags': tokens = [Token(Token.FORCE_TAGS, 'Force Tags')] for tag in values: tokens.extend([Token(Token.SEPARATOR, separator), @@ -433,7 +477,8 @@ class DefaultTags(MultiValue): type = Token.DEFAULT_TAGS @classmethod - def from_params(cls, values, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, values: 'Sequence[str]', separator: str = FOUR_SPACES, + eol: str = EOL) -> 'DefaultTags': tokens = [Token(Token.DEFAULT_TAGS, 'Default Tags')] for tag in values: tokens.extend([Token(Token.SEPARATOR, separator), @@ -447,7 +492,8 @@ class KeywordTags(MultiValue): type = Token.KEYWORD_TAGS @classmethod - def from_params(cls, values, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, values: 'Sequence[str]', separator: str = FOUR_SPACES, + eol: str = EOL) -> 'KeywordTags': tokens = [Token(Token.KEYWORD_TAGS, 'Keyword Tags')] for tag in values: tokens.extend([Token(Token.SEPARATOR, separator), @@ -461,7 +507,8 @@ class SuiteName(SingleValue): type = Token.SUITE_NAME @classmethod - def from_params(cls, value, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, value: str, separator: str = FOUR_SPACES, + eol: str = EOL) -> 'SuiteName': return cls([ Token(Token.SUITE_NAME, 'Name'), Token(Token.SEPARATOR, separator), @@ -475,7 +522,8 @@ class SuiteSetup(Fixture): type = Token.SUITE_SETUP @classmethod - def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, args: 'Sequence[str]' = (), + separator: str = FOUR_SPACES, eol: str = EOL) -> 'SuiteSetup': tokens = [Token(Token.SUITE_SETUP, 'Suite Setup'), Token(Token.SEPARATOR, separator), Token(Token.NAME, name)] @@ -491,7 +539,8 @@ class SuiteTeardown(Fixture): type = Token.SUITE_TEARDOWN @classmethod - def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, args: 'Sequence[str]' = (), + separator: str = FOUR_SPACES, eol: str = EOL) -> 'SuiteTeardown': tokens = [Token(Token.SUITE_TEARDOWN, 'Suite Teardown'), Token(Token.SEPARATOR, separator), Token(Token.NAME, name)] @@ -507,7 +556,8 @@ class TestSetup(Fixture): type = Token.TEST_SETUP @classmethod - def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, args: 'Sequence[str]' = (), + separator: str = FOUR_SPACES, eol: str = EOL) -> 'TestSetup': tokens = [Token(Token.TEST_SETUP, 'Test Setup'), Token(Token.SEPARATOR, separator), Token(Token.NAME, name)] @@ -523,7 +573,8 @@ class TestTeardown(Fixture): type = Token.TEST_TEARDOWN @classmethod - def from_params(cls, name, args=(), separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, args: 'Sequence[str]' = (), + separator: str = FOUR_SPACES, eol: str = EOL) -> 'TestTeardown': tokens = [Token(Token.TEST_TEARDOWN, 'Test Teardown'), Token(Token.SEPARATOR, separator), Token(Token.NAME, name)] @@ -539,7 +590,8 @@ class TestTemplate(SingleValue): type = Token.TEST_TEMPLATE @classmethod - def from_params(cls, value, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, value: str, separator: str = FOUR_SPACES, + eol: str = EOL) -> 'TestTemplate': return cls([ Token(Token.TEST_TEMPLATE, 'Test Template'), Token(Token.SEPARATOR, separator), @@ -553,7 +605,8 @@ class TestTimeout(SingleValue): type = Token.TEST_TIMEOUT @classmethod - def from_params(cls, value, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, value: str, separator: str = FOUR_SPACES, + eol: str = EOL) -> 'TestTimeout': return cls([ Token(Token.TEST_TIMEOUT, 'Test Timeout'), Token(Token.SEPARATOR, separator), @@ -567,9 +620,9 @@ class Variable(Statement): type = Token.VARIABLE @classmethod - def from_params(cls, name, value, separator=FOUR_SPACES, eol=EOL): - """``value`` can be given either as a string or as a list of strings.""" - values = value if is_list_like(value) else [value] + def from_params(cls, name: str, value: 'str|Sequence[str]', + separator: str = FOUR_SPACES, eol: str = EOL) -> 'Variable': + values = [value] if isinstance(value, str) else value tokens = [Token(Token.VARIABLE, name)] for value in values: tokens.extend([Token(Token.SEPARATOR, separator), @@ -578,14 +631,14 @@ def from_params(cls, name, value, separator=FOUR_SPACES, eol=EOL): return cls(tokens) @property - def name(self): - name = self.get_value(Token.VARIABLE) + def name(self) -> str: + name = self.get_value(Token.VARIABLE, '') if name.endswith('='): return name[:-1].rstrip() return name @property - def value(self): + def value(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) def validate(self, ctx: 'ValidationContext'): @@ -605,7 +658,7 @@ def _validate_dict_items(self): f"variables themselves.", ) - def _is_valid_dict_item(self, item): + def _is_valid_dict_item(self, item: str) -> bool: name, value = split_from_equals(item) return value is not None or is_dict_variable(item) @@ -615,15 +668,15 @@ class TestCaseName(Statement): type = Token.TESTCASE_NAME @classmethod - def from_params(cls, name, eol=EOL): + def from_params(cls, name: str, eol: str = EOL) -> 'TestCaseName': tokens = [Token(Token.TESTCASE_NAME, name)] if eol: tokens.append(Token(Token.EOL, eol)) return cls(tokens) @property - def name(self): - return self.get_value(Token.TESTCASE_NAME) + def name(self) -> str: + return self.get_value(Token.TESTCASE_NAME, '') def validate(self, ctx: 'ValidationContext'): if not self.name: @@ -635,15 +688,15 @@ class KeywordName(Statement): type = Token.KEYWORD_NAME @classmethod - def from_params(cls, name, eol=EOL): + def from_params(cls, name: str, eol: str = EOL) -> 'KeywordName': tokens = [Token(Token.KEYWORD_NAME, name)] if eol: tokens.append(Token(Token.EOL, eol)) return cls(tokens) @property - def name(self): - return self.get_value(Token.KEYWORD_NAME) + def name(self) -> str: + return self.get_value(Token.KEYWORD_NAME, '') def validate(self, ctx: 'ValidationContext'): if not self.name: @@ -655,8 +708,9 @@ class Setup(Fixture): type = Token.SETUP @classmethod - def from_params(cls, name, args=(), indent=FOUR_SPACES, separator=FOUR_SPACES, - eol=EOL): + def from_params(cls, name: str, args: 'Sequence[str]' = (), + indent: str = FOUR_SPACES, separator: str = FOUR_SPACES, + eol: str = EOL) -> 'Setup': tokens = [Token(Token.SEPARATOR, indent), Token(Token.SETUP, '[Setup]'), Token(Token.SEPARATOR, separator), @@ -673,8 +727,9 @@ class Teardown(Fixture): type = Token.TEARDOWN @classmethod - def from_params(cls, name, args=(), indent=FOUR_SPACES, separator=FOUR_SPACES, - eol=EOL): + def from_params(cls, name: str, args: 'Sequence[str]' = (), + indent: str = FOUR_SPACES, separator: str = FOUR_SPACES, + eol: str = EOL) -> 'Teardown': tokens = [Token(Token.SEPARATOR, indent), Token(Token.TEARDOWN, '[Teardown]'), Token(Token.SEPARATOR, separator), @@ -691,7 +746,8 @@ class Tags(MultiValue): type = Token.TAGS @classmethod - def from_params(cls, values, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, values: 'Sequence[str]', indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'Tags': tokens = [Token(Token.SEPARATOR, indent), Token(Token.TAGS, '[Tags]')] for tag in values: @@ -706,7 +762,8 @@ class Template(SingleValue): type = Token.TEMPLATE @classmethod - def from_params(cls, value, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, value: str, indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'Template': return cls([ Token(Token.SEPARATOR, indent), Token(Token.TEMPLATE, '[Template]'), @@ -721,7 +778,8 @@ class Timeout(SingleValue): type = Token.TIMEOUT @classmethod - def from_params(cls, value, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, value: str, indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'Timeout': return cls([ Token(Token.SEPARATOR, indent), Token(Token.TIMEOUT, '[Timeout]'), @@ -736,7 +794,8 @@ class Arguments(MultiValue): type = Token.ARGUMENTS @classmethod - def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, args: 'Sequence[str]', indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'Arguments': tokens = [Token(Token.SEPARATOR, indent), Token(Token.ARGUMENTS, '[Arguments]')] for arg in args: @@ -746,7 +805,7 @@ def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): return cls(tokens) def validate(self, ctx: 'ValidationContext'): - errors = [] + errors: 'list[str]' = [] UserKeywordArgumentParser(error_reporter=errors.append).parse(self.values) self.errors = tuple(errors) @@ -771,7 +830,8 @@ class Return(MultiValue): type = Token.RETURN @classmethod - def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, args: 'Sequence[str]', indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'Return': tokens = [Token(Token.SEPARATOR, indent), Token(Token.RETURN, '[Return]')] for arg in args: @@ -790,8 +850,9 @@ class KeywordCall(Statement): type = Token.KEYWORD @classmethod - def from_params(cls, name, assign=(), args=(), indent=FOUR_SPACES, - separator=FOUR_SPACES, eol=EOL): + def from_params(cls, name: str, assign: 'Sequence[str]' = (), + args: 'Sequence[str]' = (), indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'KeywordCall': tokens = [Token(Token.SEPARATOR, indent)] for assignment in assign: tokens.extend([Token(Token.ASSIGN, assignment), @@ -804,15 +865,15 @@ def from_params(cls, name, assign=(), args=(), indent=FOUR_SPACES, return cls(tokens) @property - def keyword(self): - return self.get_value(Token.KEYWORD) + def keyword(self) -> str: + return self.get_value(Token.KEYWORD, '') @property - def args(self): + def args(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) @property - def assign(self): + def assign(self) -> 'tuple[str, ...]': return self.get_values(Token.ASSIGN) @@ -821,7 +882,8 @@ class TemplateArguments(Statement): type = Token.ARGUMENT @classmethod - def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, args: 'Sequence[str]', indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'TemplateArguments': tokens = [] for index, arg in enumerate(args): tokens.extend([Token(Token.SEPARATOR, separator if index else indent), @@ -830,7 +892,7 @@ def from_params(cls, args, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): return cls(tokens) @property - def args(self): + def args(self) -> 'tuple[str, ...]': return self.get_values(self.type) @@ -839,8 +901,9 @@ class ForHeader(Statement): type = Token.FOR @classmethod - def from_params(cls, variables, values, flavor='IN', indent=FOUR_SPACES, - separator=FOUR_SPACES, eol=EOL): + def from_params(cls, variables: 'Sequence[str]', values: 'Sequence[str]', + flavor: str = 'IN', indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'ForHeader': tokens = [Token(Token.SEPARATOR, indent), Token(Token.FOR), Token(Token.SEPARATOR, separator)] @@ -855,28 +918,28 @@ def from_params(cls, variables, values, flavor='IN', indent=FOUR_SPACES, return cls(tokens) @property - def variables(self): + def variables(self) -> 'tuple[str, ...]': return self.get_values(Token.VARIABLE) @property - def values(self): + def values(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) @property - def flavor(self): + def flavor(self) -> 'str|None': separator = self.get_token(Token.FOR_SEPARATOR) return normalize_whitespace(separator.value) if separator else None @property - def start(self): + def start(self) -> 'str|None': return self.get_option('start') if self.flavor == 'IN ENUMERATE' else None @property - def mode(self): + def mode(self) -> 'str|None': return self.get_option('mode') if self.flavor == 'IN ZIP' else None @property - def fill(self): + def fill(self) -> 'str|None': return self.get_option('fill') if self.flavor == 'IN ZIP' else None def validate(self, ctx: 'ValidationContext'): @@ -891,41 +954,20 @@ def validate(self, ctx: 'ValidationContext'): if not self.values: self._add_error('no loop values') - def _add_error(self, error): + def _add_error(self, error: str): self.errors += (f'FOR loop has {error}.',) -class IfElseHeader(Statement): - - @property - def condition(self): - return None +class IfElseHeader(Statement, ABC): @property - def assign(self): - return None - - -@Statement.register -class IfHeader(IfElseHeader): - type = Token.IF - - @classmethod - def from_params(cls, condition, indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): - tokens = [Token(Token.SEPARATOR, indent), - Token(cls.type), - Token(Token.SEPARATOR, separator), - Token(Token.ARGUMENT, condition)] - if cls.type != Token.INLINE_IF: - tokens.append(Token(Token.EOL, eol)) - return cls(tokens) + def condition(self) -> 'str|None': + values = self.get_values(Token.ARGUMENT) + return ', '.join(values) if values else None @property - def condition(self): - values = self.get_values(Token.ARGUMENT) - if len(values) != 1: - return ', '.join(values) if values else None - return values[0] + def assign(self) -> 'tuple[str, ...]': + return self.get_values(Token.ASSIGN) def validate(self, ctx: 'ValidationContext'): conditions = len(self.get_tokens(Token.ARGUMENT)) @@ -936,25 +978,61 @@ def validate(self, ctx: 'ValidationContext'): @Statement.register -class InlineIfHeader(IfHeader): +class IfHeader(IfElseHeader): + type = Token.IF + + @classmethod + def from_params(cls, condition: str, indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'IfHeader': + return cls([ + Token(Token.SEPARATOR, indent), + Token(cls.type), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, condition), + Token(Token.EOL, eol) + ]) + + +@Statement.register +class InlineIfHeader(IfElseHeader): type = Token.INLINE_IF - @property - def assign(self): - return self.get_values(Token.ASSIGN) + @classmethod + def from_params(cls, condition: str, assign: 'Sequence[str]' = (), + indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES) -> 'InlineIfHeader': + tokens = [Token(Token.SEPARATOR, indent)] + for assignment in assign: + tokens.extend([Token(Token.ASSIGN, assignment), + Token(Token.SEPARATOR, separator)]) + tokens.extend([Token(Token.INLINE_IF), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, condition)]) + return cls(tokens) @Statement.register -class ElseIfHeader(IfHeader): +class ElseIfHeader(IfElseHeader): type = Token.ELSE_IF + @classmethod + def from_params(cls, condition: str, indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'ElseIfHeader': + return cls([ + Token(Token.SEPARATOR, indent), + Token(Token.ELSE_IF), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, condition), + Token(Token.EOL, eol) + ]) + @Statement.register class ElseHeader(IfElseHeader): type = Token.ELSE @classmethod - def from_params(cls, indent=FOUR_SPACES, eol=EOL): + def from_params(cls, indent: str = FOUR_SPACES, eol: str = EOL) -> 'ElseHeader': return cls([ Token(Token.SEPARATOR, indent), Token(Token.ELSE), @@ -967,10 +1045,10 @@ def validate(self, ctx: 'ValidationContext'): self.errors += (f'ELSE does not accept arguments, got {seq2str(values)}.',) -class NoArgumentHeader(Statement): +class NoArgumentHeader(Statement, ABC): @classmethod - def from_params(cls, indent=FOUR_SPACES, eol=EOL): + def from_params(cls, indent: str = FOUR_SPACES, eol: str = EOL): return cls([ Token(Token.SEPARATOR, indent), Token(cls.type), @@ -983,7 +1061,7 @@ def validate(self, ctx: 'ValidationContext'): f'{seq2str(self.values)}.',) @property - def values(self): + def values(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) @@ -997,13 +1075,14 @@ class ExceptHeader(Statement): type = Token.EXCEPT @classmethod - def from_params(cls, patterns=(), type=None, variable=None, indent=FOUR_SPACES, - separator=FOUR_SPACES, eol=EOL): + def from_params(cls, patterns: 'Sequence[str]' = (), type: 'str|None' = None, + variable: 'str|None' = None, indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'ExceptHeader': tokens = [Token(Token.SEPARATOR, indent), Token(Token.EXCEPT)] for pattern in patterns: tokens.extend([Token(Token.SEPARATOR, separator), - Token(Token.ARGUMENT, pattern)]), + Token(Token.ARGUMENT, pattern)]) if type: tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.OPTION, f'type={type}')]) @@ -1016,15 +1095,15 @@ def from_params(cls, patterns=(), type=None, variable=None, indent=FOUR_SPACES, return cls(tokens) @property - def patterns(self): + def patterns(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) @property - def pattern_type(self): + def pattern_type(self) -> 'str|None': return self.get_option('type') @property - def variable(self): + def variable(self) -> 'str|None': return self.get_value(Token.VARIABLE) def validate(self, ctx: 'ValidationContext'): @@ -1054,36 +1133,40 @@ class WhileHeader(Statement): type = Token.WHILE @classmethod - def from_params(cls, condition, limit=None, on_limit=None, on_limit_message=None, - indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, condition: str, limit: 'str|None' = None, + on_limit: 'str|None ' = None, on_limit_message: 'str|None' = None, + indent: str = FOUR_SPACES, separator: str = FOUR_SPACES, + eol: str = EOL) -> 'WhileHeader': tokens = [Token(Token.SEPARATOR, indent), - Token(cls.type), + Token(Token.WHILE), Token(Token.SEPARATOR, separator), Token(Token.ARGUMENT, condition)] if limit: tokens.extend([Token(Token.SEPARATOR, indent), Token(Token.OPTION, f'limit={limit}')]) + if on_limit: + tokens.extend([Token(Token.SEPARATOR, indent), + Token(Token.OPTION, f'on_limit={on_limit}')]) if on_limit_message: tokens.extend([Token(Token.SEPARATOR, indent), - Token(Token.OPTION, - f'on_limit_message={on_limit_message}')]) + Token(Token.OPTION, f'on_limit_message={on_limit_message}')]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @property - def condition(self): + def condition(self) -> str: return ', '.join(self.get_values(Token.ARGUMENT)) @property - def limit(self): + def limit(self) -> 'str|None': return self.get_option('limit') @property - def on_limit(self): + def on_limit(self) -> 'str|None': return self.get_option('on_limit') @property - def on_limit_message(self): + def on_limit_message(self) -> 'str|None': return self.get_option('on_limit_message') def validate(self, ctx: 'ValidationContext'): @@ -1103,7 +1186,8 @@ def values(self): return self.get_values(Token.ARGUMENT) @classmethod - def from_params(cls, values=(), indent=FOUR_SPACES, separator=FOUR_SPACES, eol=EOL): + def from_params(cls, values: 'Sequence[str]' = (), indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL) -> 'ReturnStatement': tokens = [Token(Token.SEPARATOR, indent), Token(Token.RETURN_STATEMENT)] for value in values: @@ -1119,7 +1203,7 @@ def validate(self, ctx: 'ValidationContext'): self.errors += ('RETURN cannot be used in FINALLY branch.',) -class LoopControl(NoArgumentHeader): +class LoopControl(NoArgumentHeader, ABC): def validate(self, ctx: 'ValidationContext'): super().validate(ctx) @@ -1144,7 +1228,8 @@ class Comment(Statement): type = Token.COMMENT @classmethod - def from_params(cls, comment, indent=FOUR_SPACES, eol=EOL): + def from_params(cls, comment: str, indent: str = FOUR_SPACES, + eol: str = EOL) -> 'Comment': return cls([ Token(Token.SEPARATOR, indent), Token(Token.COMMENT, comment), @@ -1157,14 +1242,14 @@ class Config(Statement): type = Token.CONFIG @classmethod - def from_params(cls, config, eol=EOL): + def from_params(cls, config: str, eol: str = EOL) -> 'Config': return cls([ Token(Token.CONFIG, config), Token(Token.EOL, eol) ]) @property - def language(self): + def language(self) -> 'Language|None': value = self.get_value(Token.CONFIG) return Language.from_name(value[len('language:'):]) if value else None @@ -1172,24 +1257,33 @@ def language(self): @Statement.register class Error(Statement): type = Token.ERROR - _errors = () + _errors: 'tuple[str, ...]' = () + + @classmethod + def from_params(cls, error: str, value: str = '', indent: str = FOUR_SPACES, + eol: str = EOL) -> 'Error': + return cls([ + Token(Token.SEPARATOR, indent), + Token(Token.ERROR, value, error=error), + Token(Token.EOL, eol) + ]) @property - def values(self): - return [t.value for t in self.data_tokens] + def values(self) -> 'list[str]': + return [token.value for token in self.data_tokens] @property - def errors(self): + def errors(self) -> 'tuple[str, ...]': """Errors got from the underlying ``ERROR``token. Errors can be set also explicitly. When accessing errors, they are returned along with errors got from tokens. """ tokens = self.get_tokens(Token.ERROR) - return tuple(t.error for t in tokens) + self._errors + return tuple(t.error or '' for t in tokens) + self._errors @errors.setter - def errors(self, errors): + def errors(self, errors: 'Sequence[str]'): self._errors = tuple(errors) @@ -1197,5 +1291,5 @@ class EmptyLine(Statement): type = Token.EOL @classmethod - def from_params(cls, eol=EOL): + def from_params(cls, eol: str = EOL): return cls([Token(Token.EOL, eol)]) diff --git a/src/robot/parsing/model/visitor.py b/src/robot/parsing/model/visitor.py index b5952fac3a1..fb176459fb5 100644 --- a/src/robot/parsing/model/visitor.py +++ b/src/robot/parsing/model/visitor.py @@ -15,6 +15,8 @@ import ast +from .statements import Node + class VisitorFinder: @@ -26,7 +28,7 @@ def _find_visitor(self, cls): return getattr(self, method) # Forward-compatibility. if method == 'visit_Return' and hasattr(self, 'visit_ReturnSetting'): - return self.visit_ReturnSetting + return getattr(self, 'visit_ReturnSetting') for base in cls.__bases__: visitor = self._find_visitor(base) if visitor: @@ -47,7 +49,7 @@ def visit_Statement(self, node): ... """ - def visit(self, node): + def visit(self, node: Node): visitor = self._find_visitor(type(node)) or self.generic_visit visitor(node) @@ -60,6 +62,6 @@ class ModelTransformer(ast.NodeTransformer, VisitorFinder): <https://docs.python.org/library/ast.html#ast.NodeTransformer>`__. """ - def visit(self, node): + def visit(self, node: Node): visitor = self._find_visitor(type(node)) or self.generic_visit return visitor(node) diff --git a/src/robot/parsing/parser/blockparsers.py b/src/robot/parsing/parser/blockparsers.py index 82a2fb85b4f..a5f70b54f6b 100644 --- a/src/robot/parsing/parser/blockparsers.py +++ b/src/robot/parsing/parser/blockparsers.py @@ -13,30 +13,36 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC, abstractmethod + from ..lexer import Token -from ..model import TestCase, Keyword, For, If, Try, While +from ..model import (Block, Container, End, For, If, Keyword, NestedBlock, + Statement, TestCase, Try, While) -class Parser: - """Base class for parsers.""" +class Parser(ABC): + model: Container - def __init__(self, model): + def __init__(self, model: Container): self.model = model - def handles(self, statement): + @abstractmethod + def handles(self, statement: Statement) -> bool: raise NotImplementedError - def parse(self, statement): + @abstractmethod + def parse(self, statement: Statement) -> 'Parser|None': raise NotImplementedError -class BlockParser(Parser): +class BlockParser(Parser, ABC): + model: Block unhandled_tokens = Token.HEADER_TOKENS | frozenset((Token.TESTCASE_NAME, Token.KEYWORD_NAME)) - def __init__(self, model): - Parser.__init__(self, model) - self.nested_parsers = { + def __init__(self, model: Block): + super().__init__(model) + self.parsers: 'dict[str, type[NestedBlockParser]]' = { Token.FOR: ForParser, Token.IF: IfParser, Token.INLINE_IF: IfParser, @@ -44,13 +50,14 @@ def __init__(self, model): Token.WHILE: WhileParser } - def handles(self, statement): + def handles(self, statement: Statement) -> bool: return statement.type not in self.unhandled_tokens - def parse(self, statement): - parser_class = self.nested_parsers.get(statement.type) + def parse(self, statement: Statement) -> 'BlockParser|None': + parser_class = self.parsers.get(statement.type) if parser_class: - parser = parser_class(statement) + model_class = parser_class.__annotations__['model'] + parser = parser_class(model_class(statement)) self.model.body.append(parser.model) return parser self.model.body.append(statement) @@ -58,75 +65,59 @@ def parse(self, statement): class TestCaseParser(BlockParser): - - def __init__(self, header): - BlockParser.__init__(self, TestCase(header)) + model: TestCase class KeywordParser(BlockParser): + model: Keyword - def __init__(self, header): - BlockParser.__init__(self, Keyword(header)) +class NestedBlockParser(BlockParser, ABC): + model: NestedBlock -class NestedBlockParser(BlockParser): - - def handles(self, statement): - return BlockParser.handles(self, statement) and \ - not getattr(self.model, 'end', False) + def __init__(self, model: NestedBlock, handle_end: bool = True): + super().__init__(model) + self.handle_end = handle_end - def parse(self, statement): + def handles(self, statement: Statement) -> bool: + if self.model.end: + return False if statement.type == Token.END: + return self.handle_end + return super().handles(statement) + + def parse(self, statement: Statement) -> 'BlockParser|None': + if isinstance(statement, End): self.model.end = statement return None - return BlockParser.parse(self, statement) + return super().parse(statement) class ForParser(NestedBlockParser): + model: For - def __init__(self, header): - NestedBlockParser.__init__(self, For(header)) +class WhileParser(NestedBlockParser): + model: While -class IfParser(NestedBlockParser): - def __init__(self, header, handle_end=True): - super().__init__(If(header)) - self.handle_end = handle_end +class IfParser(NestedBlockParser): + model: If - def parse(self, statement): + def parse(self, statement: Statement) -> 'BlockParser|None': if statement.type in (Token.ELSE_IF, Token.ELSE): - parser = IfParser(statement, handle_end=False) + parser = IfParser(If(statement), handle_end=False) self.model.orelse = parser.model return parser - return NestedBlockParser.parse(self, statement) - - def handles(self, statement): - if statement.type == Token.END and not self.handle_end: - return False - return super().handles(statement) + return super().parse(statement) class TryParser(NestedBlockParser): + model: Try - def __init__(self, header, handle_end=True): - super().__init__(Try(header)) - self.handle_end = handle_end - - def parse(self, statement): + def parse(self, statement) -> 'BlockParser|None': if statement.type in (Token.EXCEPT, Token.ELSE, Token.FINALLY): - parser = TryParser(statement, handle_end=False) + parser = TryParser(Try(statement), handle_end=False) self.model.next = parser.model return parser return super().parse(statement) - - def handles(self, statement): - if statement.type == Token.END and not self.handle_end: - return False - return super().handles(statement) - - -class WhileParser(NestedBlockParser): - - def __init__(self, header): - super().__init__(While(header)) diff --git a/src/robot/parsing/parser/fileparser.py b/src/robot/parsing/parser/fileparser.py index f8973149048..7aabcd25219 100644 --- a/src/robot/parsing/parser/fileparser.py +++ b/src/robot/parsing/parser/fileparser.py @@ -13,36 +13,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os.path +from pathlib import Path -from robot.utils import is_pathlike, is_string +from robot.utils import Source from ..lexer import Token -from ..model import (File, CommentSection, SettingSection, VariableSection, - TestCaseSection, KeywordSection, InvalidSection) - -from .blockparsers import Parser, TestCaseParser, KeywordParser +from ..model import (CommentSection, File, ImplicitCommentSection, InvalidSection, + Keyword, KeywordSection, Section, SettingSection, Statement, + TestCase, TestCaseSection, VariableSection) +from .blockparsers import KeywordParser, Parser, TestCaseParser class FileParser(Parser): + model: File - def __init__(self, source=None): + def __init__(self, source: 'Source|None' = None): super().__init__(File(source=self._get_path(source))) - - def _get_path(self, source): - if not source: - return None - if is_string(source) and '\n' not in source and os.path.isfile(source): - return source - if is_pathlike(source) and source.is_file(): - return str(source) - return None - - def handles(self, statement): - return True - - def parse(self, statement): - parser_class = { + self.parsers: 'dict[str, type[SectionParser]]' = { Token.SETTING_HEADER: SettingSectionParser, Token.VARIABLE_HEADER: VariableSectionParser, Token.TESTCASE_HEADER: TestCaseSectionParser, @@ -54,65 +41,79 @@ def parse(self, statement): Token.COMMENT: ImplicitCommentSectionParser, Token.ERROR: ImplicitCommentSectionParser, Token.EOL: ImplicitCommentSectionParser - }[statement.type] - parser = parser_class(statement) + } + + def _get_path(self, source: 'Source|None') -> 'Path|None': + if not source: + return None + if isinstance(source, str) and '\n' not in source: + source = Path(source) + try: + if isinstance(source, Path) and source.is_file(): + return source + except OSError: # Can happen on Windows w/ Python < 3.10. + pass + return None + + def handles(self, statement: Statement) -> bool: + return True + + def parse(self, statement: Statement) -> 'SectionParser': + parser_class = self.parsers[statement.type] + model_class: 'type[Section]' = parser_class.__annotations__['model'] + parser = parser_class(model_class(statement)) self.model.sections.append(parser.model) return parser class SectionParser(Parser): - model_class = None - - def __init__(self, header): - super().__init__(self.model_class(header)) + model: Section - def handles(self, statement): + def handles(self, statement: Statement) -> bool: return statement.type not in Token.HEADER_TOKENS - def parse(self, statement): + def parse(self, statement: Statement) -> 'Parser|None': self.model.body.append(statement) return None class SettingSectionParser(SectionParser): - model_class = SettingSection + model: SettingSection class VariableSectionParser(SectionParser): - model_class = VariableSection + model: VariableSection class CommentSectionParser(SectionParser): - model_class = CommentSection - - -class InvalidSectionParser(SectionParser): - model_class = InvalidSection + model: CommentSection class ImplicitCommentSectionParser(SectionParser): + model: ImplicitCommentSection - def model_class(self, statement): - return CommentSection(body=[statement]) + +class InvalidSectionParser(SectionParser): + model: InvalidSection class TestCaseSectionParser(SectionParser): - model_class = TestCaseSection + model: TestCaseSection - def parse(self, statement): + def parse(self, statement: Statement) -> 'Parser|None': if statement.type == Token.TESTCASE_NAME: - parser = TestCaseParser(statement) + parser = TestCaseParser(TestCase(statement)) self.model.body.append(parser.model) return parser - return SectionParser.parse(self, statement) + return super().parse(statement) class KeywordSectionParser(SectionParser): - model_class = KeywordSection + model: KeywordSection - def parse(self, statement): + def parse(self, statement: Statement) -> 'Parser|None': if statement.type == Token.KEYWORD_NAME: - parser = KeywordParser(statement) + parser = KeywordParser(Keyword(statement)) self.model.body.append(parser.model) return parser - return SectionParser.parse(self, statement) + return super().parse(statement) diff --git a/src/robot/parsing/parser/parser.py b/src/robot/parsing/parser/parser.py index e6ba49a6e40..06ca5f71da8 100644 --- a/src/robot/parsing/parser/parser.py +++ b/src/robot/parsing/parser/parser.py @@ -13,12 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Callable, Iterator + from robot.conf import LanguagesLike from robot.utils import Source from ..lexer import get_init_tokens, get_resource_tokens, get_tokens, Token -from ..model import File, ModelVisitor, Statement +from ..model import File, Config, ModelVisitor, Statement +from .blockparsers import Parser from .fileparser import FileParser @@ -75,15 +78,18 @@ def get_init_model(source: Source, data_only: bool = False, curdir: 'str|None' = return _get_model(get_init_tokens, source, data_only, curdir, lang) -def _get_model(token_getter, source, data_only=False, curdir=None, lang=None): +def _get_model(token_getter: Callable[..., Iterator[Token]], source: Source, + data_only: bool, curdir: 'str|None', lang: LanguagesLike): tokens = token_getter(source, data_only, lang=lang) statements = _tokens_to_statements(tokens, curdir) model = _statements_to_model(statements, source) + ConfigParser.parse(model) model.validate_model() return model -def _tokens_to_statements(tokens, curdir=None): +def _tokens_to_statements(tokens: Iterator[Token], + curdir: 'str|None') -> Iterator[Statement]: statement = [] EOS = Token.EOS for t in tokens: @@ -96,26 +102,30 @@ def _tokens_to_statements(tokens, curdir=None): statement = [] -def _statements_to_model(statements, source=None): - parser = FileParser(source=source) - model = parser.model - stack = [parser] +def _statements_to_model(statements: Iterator[Statement], source: Source) -> File: + root = FileParser(source=source) + stack: 'list[Parser]' = [root] for statement in statements: while not stack[-1].handles(statement): stack.pop() parser = stack[-1].parse(statement) if parser: stack.append(parser) - # Implicit comment sections have no header. - if model.sections and model.sections[0].header is None: - SetLanguages(model).visit(model.sections[0]) - return model + return root.model + +class ConfigParser(ModelVisitor): -class SetLanguages(ModelVisitor): + def __init__(self, model: File): + self.model = model - def __init__(self, file): - self.file = file + @classmethod + def parse(cls, model: File): + # Only implicit comment sections can contain configs. They have no header. + if model.sections and model.sections[0].header is None: + cls(model).visit(model.sections[0]) - def visit_Config(self, node): - self.file.languages += (node.language.code,) + def visit_Config(self, node: Config): + language = node.language + if language: + self.model.languages.append(language.code) diff --git a/utest/api/test_exposed_api.py b/utest/api/test_exposed_api.py index bef37b06ba7..b6ec3a6e17b 100644 --- a/utest/api/test_exposed_api.py +++ b/utest/api/test_exposed_api.py @@ -40,7 +40,7 @@ def test_parsing_token(self): assert_equal(api_parsing.Token, parsing.Token) def test_parsing_model_statements(self): - for cls in parsing.model.Statement._statement_handlers.values(): + for cls in parsing.model.Statement.statement_handlers.values(): assert_equal(getattr(api_parsing, cls.__name__), cls) assert_true(not hasattr(api_parsing, 'Statement')) diff --git a/utest/parsing/parsing_test_utils.py b/utest/parsing/parsing_test_utils.py index f3abd43df09..8dff1b8dc5d 100644 --- a/utest/parsing/parsing_test_utils.py +++ b/utest/parsing/parsing_test_utils.py @@ -1,7 +1,7 @@ import ast from robot.parsing import ModelTransformer -from robot.parsing.model.blocks import Block +from robot.parsing.model.blocks import Container from robot.parsing.model.statements import Statement from robot.utils.asserts import assert_equal @@ -16,7 +16,7 @@ def assert_model(model, expected, **expected_attrs): '%r != %r' % (model, expected), values=False) for m, e in zip(model, expected): assert_model(m, e) - elif isinstance(model, Block): + elif isinstance(model, Container): assert_block(model, expected, expected_attrs) elif isinstance(model, Statement): assert_statement(model, expected) diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index fb4ab5d2509..45e30794794 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -6,7 +6,7 @@ from robot.parsing import get_model, get_resource_model, ModelVisitor, ModelTransformer, Token from robot.parsing.model.blocks import ( - CommentSection, File, For, If, InvalidSection, Try, While, + File, For, If, ImplicitCommentSection, InvalidSection, Try, While, Keyword, KeywordSection, SettingSection, TestCase, TestCaseSection, VariableSection ) from robot.parsing.model.statements import ( @@ -37,9 +37,9 @@ Log Got ${arg1} and ${arg}! RETURN x ''' -PATH = os.path.join(os.getenv('TEMPDIR') or tempfile.gettempdir(), 'test_model.robot') +PATH = Path(os.getenv('TEMPDIR') or tempfile.gettempdir(), 'test_model.robot') EXPECTED = File(sections=[ - CommentSection( + ImplicitCommentSection( body=[ EmptyLine([ Token('EOL', '\n', 1, 0) @@ -145,23 +145,22 @@ class TestGetModel(unittest.TestCase): @classmethod def setUpClass(cls): - with open(PATH, 'w') as f: - f.write(DATA) + PATH.write_text(DATA) @classmethod def tearDownClass(cls): - os.remove(PATH) + PATH.unlink() def test_from_string(self): model = get_model(DATA) assert_model(model, EXPECTED) def test_from_path_as_string(self): - model = get_model(PATH) + model = get_model(str(PATH)) assert_model(model, EXPECTED, source=PATH) def test_from_path_as_path(self): - model = get_model(Path(PATH)) + model = get_model(PATH) assert_model(model, EXPECTED, source=PATH) def test_from_open_file(self): @@ -171,39 +170,41 @@ def test_from_open_file(self): class TestSaveModel(unittest.TestCase): + different_path = PATH.parent / 'different.robot' @classmethod def setUpClass(cls): - with open(PATH, 'w') as f: - f.write(DATA) + PATH.write_text(DATA) @classmethod def tearDownClass(cls): - os.remove(PATH) + PATH.unlink() + if cls.different_path.exists: + cls.different_path.unlink() def test_save_to_original_path(self): model = get_model(PATH) - os.remove(PATH) + PATH.unlink() model.save() assert_model(get_model(PATH), EXPECTED, source=PATH) def test_save_to_different_path(self): model = get_model(PATH) - different = PATH + '.robot' - model.save(different) - assert_model(get_model(different), EXPECTED, source=different) + path = self.different_path + model.save(path) + assert_model(get_model(path), EXPECTED, source=path) - def test_save_to_original_path_as_path(self): - model = get_model(Path(PATH)) - os.remove(PATH) + def test_save_to_original_path_as_str(self): + model = get_model(str(PATH)) + PATH.unlink() model.save() assert_model(get_model(PATH), EXPECTED, source=PATH) - def test_save_to_different_path_as_path(self): + def test_save_to_different_path_as_str(self): model = get_model(PATH) - different = PATH + '.robot' - model.save(Path(different)) - assert_model(get_model(different), EXPECTED, source=different) + path = self.different_path + model.save(Path(path)) + assert_model(get_model(path), EXPECTED, source=path) def test_save_to_original_fails_if_source_is_not_path(self): message = 'Saving model requires explicit output ' \ @@ -1521,7 +1522,7 @@ def visit_Statement(self, node): assert_equal(visitor.test_names, ['Example']) assert_equal(visitor.kw_names, ['Keyword']) assert_equal(visitor.blocks, - ['File', 'CommentSection', 'TestCaseSection', 'TestCase', + ['ImplicitCommentSection', 'TestCaseSection', 'TestCase', 'KeywordSection', 'Keyword']) assert_equal(visitor.statements, ['EOL', 'TESTCASE HEADER', 'EOL', 'TESTCASE NAME', @@ -1666,7 +1667,7 @@ def test_config(self): expected = File( languages=('fi', 'de'), sections=[ - CommentSection(body=[ + ImplicitCommentSection(body=[ Config([ Token('CONFIG', 'language: fi', 1, 0), Token('EOL', '\n', 1, 12) diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 6ae8a0d8eae..ce2720783a0 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -729,6 +729,24 @@ def test_InlineIfHeader(self): condition='$x > 0' ) + def test_InlineIfHeader_with_assign(self): + # Test/Keyword + # ${y} = IF $x > 0 + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.ASSIGN, '${y}'), + Token(Token.SEPARATOR, ' '), + Token(Token.INLINE_IF), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, '$x > 0') + ] + assert_created_statement( + tokens, + InlineIfHeader, + condition='$x > 0', + assign=['${y}'] + ) + def test_ElseIfHeader(self): # Test/Keyword # ELSE IF ${var} not in [@{list}] From 80cf0dacff0623cf07d110c1ef7bd2a0b2cf7b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 3 May 2023 01:22:51 +0300 Subject: [PATCH 0527/1592] Refactor SuiteStructure. Separate classes for SuiteFile and SuiteDirectory to ease typing. --- src/robot/parsing/__init__.py | 3 +- src/robot/parsing/suitestructure.py | 67 +++++++++++++++++++-------- src/robot/running/builder/builders.py | 13 +++--- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/src/robot/parsing/__init__.py b/src/robot/parsing/__init__.py index 556089341c2..3ad2107bc29 100644 --- a/src/robot/parsing/__init__.py +++ b/src/robot/parsing/__init__.py @@ -24,4 +24,5 @@ from .lexer import get_tokens, get_resource_tokens, get_init_tokens, Token from .model import File, ModelTransformer, ModelVisitor from .parser import get_model, get_resource_model, get_init_model -from .suitestructure import SuiteStructure, SuiteStructureBuilder, SuiteStructureVisitor +from .suitestructure import (SuiteFile, SuiteDirectory, SuiteStructure, + SuiteStructureBuilder, SuiteStructureVisitor) diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 6b3572334c7..80969dcea05 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -13,8 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC, abstractmethod from pathlib import Path -from typing import Iterable +from typing import Iterable, Sequence from robot.errors import DataError from robot.model import SuiteNamePatterns @@ -22,52 +23,78 @@ from robot.utils import get_error_message -class SuiteStructure: +class SuiteStructure(ABC): + source: 'Path|None' + init_file: 'Path|None' + children: 'list[SuiteStructure]|None' - def __init__(self, source: 'Path|None' = None, init_file: 'Path|None' = None, - children: 'list[SuiteStructure]|None' = None): + def __init__(self, source: 'Path|None', init_file: 'Path|None' = None, + children: 'Sequence[SuiteStructure]|None' = None): self.source = source self.init_file = init_file - self.children = children + self.children = list(children) if children is not None else None @property + @abstractmethod def extension(self) -> 'str|None': - source = self.source if self.is_file else self.init_file - return source.suffix[1:].lower() if source else None + raise NotImplementedError + + @abstractmethod + def visit(self, visitor: 'SuiteStructureVisitor'): + raise NotImplementedError + + +class SuiteFile(SuiteStructure): + source: Path + + def __init__(self, source: Path): + super().__init__(source) @property - def is_file(self) -> bool: - return self.children is None + def extension(self) -> str: + return self.source.suffix[1:].lower() + + def visit(self, visitor: 'SuiteStructureVisitor'): + visitor.visit_file(self) + + +class SuiteDirectory(SuiteStructure): + children: 'list[SuiteStructure]' + + def __init__(self, source: 'Path|None' = None, init_file: 'Path|None' = None, + children: Sequence[SuiteStructure] = ()): + super().__init__(source, init_file, children) @property def is_multi_source(self) -> bool: return self.source is None + @property + def extension(self) -> 'str|None': + return self.init_file.suffix[1:].lower() if self.init_file else None + def add(self, child: 'SuiteStructure'): self.children.append(child) def visit(self, visitor: 'SuiteStructureVisitor'): - if self.children is None: - visitor.visit_file(self) - else: - visitor.visit_directory(self) + visitor.visit_directory(self) class SuiteStructureVisitor: - def visit_file(self, structure: SuiteStructure): + def visit_file(self, structure: SuiteFile): pass - def visit_directory(self, structure: SuiteStructure): + def visit_directory(self, structure: SuiteDirectory): self.start_directory(structure) for child in structure.children: child.visit(self) self.end_directory(structure) - def start_directory(self, structure: SuiteStructure): + def start_directory(self, structure: SuiteDirectory): pass - def end_directory(self, structure: SuiteStructure): + def end_directory(self, structure: SuiteDirectory): pass @@ -96,12 +123,12 @@ def build(self, *paths: Path) -> SuiteStructure: def _build(self, path: Path, included_suites: SuiteNamePatterns) -> SuiteStructure: if path.is_file(): - return SuiteStructure(path) + return SuiteFile(path) return self._build_directory(path, included_suites) def _build_directory(self, path: Path, included_suites: SuiteNamePatterns) -> SuiteStructure: - structure = SuiteStructure(path, children=[]) + structure = SuiteDirectory(path) # If a directory is included, also its children are included. if self._is_suite_included(path.name, included_suites): included_suites = SuiteNamePatterns() @@ -147,7 +174,7 @@ def _is_included(self, path: Path, included_suites: SuiteNamePatterns) -> bool: return self._is_suite_included(path.stem, included_suites) def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: - structure = SuiteStructure(children=[]) + structure = SuiteDirectory() for path in paths: if self._is_init_file(path): if structure.init_file: diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 095215cf3c1..5b972d2242c 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -21,7 +21,8 @@ from robot.conf import LanguagesLike from robot.errors import DataError from robot.output import LOGGER -from robot.parsing import SuiteStructure, SuiteStructureBuilder, SuiteStructureVisitor +from robot.parsing import (SuiteFile, SuiteDirectory, SuiteStructure, + SuiteStructureBuilder, SuiteStructureVisitor) from robot.utils import Importer, seq2str, split_args_from_name_or_path, type_name from ..model import ResourceFile, TestSuite @@ -197,7 +198,7 @@ def parse(self, structure: SuiteStructure) -> TestSuite: suite.rpa = self.rpa return suite - def visit_file(self, structure: SuiteStructure): + def visit_file(self, structure: SuiteFile): LOGGER.info(f"Parsing file '{structure.source}'.") suite = self._build_suite_file(structure) if self.suite is None: @@ -205,7 +206,7 @@ def visit_file(self, structure: SuiteStructure): else: self._stack[-1][0].suites.append(suite) - def start_directory(self, structure: SuiteStructure): + def start_directory(self, structure: SuiteDirectory): if structure.source: LOGGER.info(f"Parsing directory '{structure.source}'.") suite, defaults = self._build_suite_directory(structure) @@ -215,12 +216,12 @@ def start_directory(self, structure: SuiteStructure): self._stack[-1][0].suites.append(suite) self._stack.append((suite, defaults)) - def end_directory(self, structure: SuiteStructure): + def end_directory(self, structure: SuiteDirectory): suite, _ = self._stack.pop() if suite.rpa is None and suite.suites: suite.rpa = suite.suites[0].rpa - def _build_suite_file(self, structure: SuiteStructure): + def _build_suite_file(self, structure: SuiteFile): source = cast(Path, structure.source) defaults = self.parent_defaults or TestDefaults() parser = self.parsers[structure.extension] @@ -233,7 +234,7 @@ def _build_suite_file(self, structure: SuiteStructure): raise DataError(f"Parsing '{source}' failed: {err.message}") return suite - def _build_suite_directory(self, structure: SuiteStructure): + def _build_suite_directory(self, structure: SuiteDirectory): source = cast(Path, structure.init_file or structure.source) defaults = TestDefaults(self.parent_defaults) parser = self.parsers[structure.extension] From 86221e39e321515336949d30f76a32a85b76c725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 3 May 2023 01:47:43 +0300 Subject: [PATCH 0528/1592] Avoid type checker errors. Using Protocol or possibly TypeGuard would be better than silencing errors, but both are too new for us. I consider it somewhat annoying in general that `hasattr` doesn't work as a type guard. --- src/robot/model/visitor.py | 26 ++++++++++++----------- src/robot/running/builder/transformers.py | 8 +++---- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 0e8d30942cc..9cb21fc08e6 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -178,12 +178,18 @@ def visit_keyword(self, keyword: 'Keyword'): the body of the keyword """ if self.start_keyword(keyword) is not False: - if hasattr(keyword, 'body'): - keyword.body.visit(self) - if getattr(keyword, 'has_teardown', False): - keyword.teardown.visit(self) + self._possible_body(keyword) + self._possible_teardown(keyword) self.end_keyword(keyword) + def _possible_body(self, item: 'BodyItem'): + if hasattr(item, 'body'): + item.body.visit(self) # type: ignore + + def _possible_teardown(self, item: 'BodyItem'): + if getattr(item, 'has_teardown', False): + item.teardown.visit(self) # type: ignore + def start_keyword(self, keyword: 'Keyword'): """Called when a keyword starts. @@ -419,8 +425,7 @@ def end_while_iteration(self, iteration: 'WhileIteration'): def visit_return(self, return_: 'Return'): """Visits a RETURN elements.""" if self.start_return(return_) is not False: - if hasattr(return_, 'body'): - return_.body.visit(self) + self._possible_body(return_) self.end_return(return_) def start_return(self, return_: 'Return'): @@ -442,8 +447,7 @@ def end_return(self, return_: 'Return'): def visit_continue(self, continue_: 'Continue'): """Visits CONTINUE elements.""" if self.start_continue(continue_) is not False: - if hasattr(continue_, 'body'): - continue_.body.visit(self) + self._possible_body(continue_) self.end_continue(continue_) def start_continue(self, continue_: 'Continue'): @@ -465,8 +469,7 @@ def end_continue(self, continue_: 'Continue'): def visit_break(self, break_: 'Break'): """Visits BREAK elements.""" if self.start_break(break_) is not False: - if hasattr(break_, 'body'): - break_.body.visit(self) + self._possible_body(break_) self.end_break(break_) def start_break(self, break_: 'Break'): @@ -492,8 +495,7 @@ def visit_error(self, error: 'Error'): invalid setting like ``[Invalid]``. """ if self.start_error(error) is not False: - if hasattr(error, 'body'): - error.body.visit(self) + self._possible_body(error) self.end_error(error) def start_error(self, error: 'Error'): diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 70e7046e0ec..7be36af7ce8 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -48,12 +48,12 @@ def visit_SuiteTeardown(self, node): lineno=node.lineno) def visit_TestSetup(self, node): - self.settings.test_setup = dict(name=node.name, args=node.args, - lineno=node.lineno) + self.settings.test_setup = {'name': node.name, 'args': node.args, + 'lineno': node.lineno} def visit_TestTeardown(self, node): - self.settings.test_teardown = dict(name=node.name, args=node.args, - lineno=node.lineno) + self.settings.test_teardown = {'name': node.name, 'args': node.args, + 'lineno': node.lineno} def visit_TestTimeout(self, node): self.settings.test_timeout = node.value From c3c872ed8e8c0f854f5296c0b562b6cb2ca64d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 3 May 2023 13:46:05 +0300 Subject: [PATCH 0529/1592] Make it an error to use config option multiple times. Options include `limit` with WHILE and `fill` with FOR IN ZIP. Also fix a bug in reporting FOR loops that fail due to parsing error. --- .../robot/running/for/for_in_enumerate.robot | 3 ++ atest/robot/running/for/for_in_zip.robot | 6 ++++ .../running/try_except/except_behaviour.robot | 21 ++++++----- .../try_except/try_except_resource.robot | 11 +++++- atest/robot/running/while/while_limit.robot | 4 +++ .../running/for/for_in_enumerate.robot | 8 ++++- atest/testdata/running/for/for_in_zip.robot | 16 +++++++-- .../running/try_except/except_behaviour.robot | 8 +++++ .../testdata/running/while/while_limit.robot | 6 ++++ src/robot/parsing/lexer/statementlexers.py | 35 ++++++++----------- src/robot/parsing/model/statements.py | 28 ++++++++++----- src/robot/running/bodyrunner.py | 3 +- 12 files changed, 107 insertions(+), 42 deletions(-) diff --git a/atest/robot/running/for/for_in_enumerate.robot b/atest/robot/running/for/for_in_enumerate.robot index 93bf15491bc..a030c0fc42f 100644 --- a/atest/robot/running/for/for_in_enumerate.robot +++ b/atest/robot/running/for/for_in_enumerate.robot @@ -38,6 +38,9 @@ Invalid start Invalid variable in start Check test and failed loop ${TEST NAME} IN ENUMERATE start=\${invalid} +Start multiple times + Check test and failed loop ${TEST NAME} IN ENUMERATE start=1, 2, 3 + Index and two items ${loop} = Check test and get loop ${TEST NAME} 1 Should be IN ENUMERATE loop ${loop} 3 diff --git a/atest/robot/running/for/for_in_zip.robot b/atest/robot/running/for/for_in_zip.robot index 36d3dc61e0f..fbb1dfa6a34 100644 --- a/atest/robot/running/for/for_in_zip.robot +++ b/atest/robot/running/for/for_in_zip.robot @@ -102,6 +102,12 @@ Invalid mode ${tc} = Check Test Case ${TEST NAME} Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=bad +Config more than once + ${tc} = Check Test Case ${TEST NAME} 1 + Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=longest, shortest + ${tc} = Check Test Case ${TEST NAME} 2 + Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=longest fill=x, y, z + Non-existing variable in mode ${tc} = Check Test Case ${TEST NAME} Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=\${bad} fill=\${ignored} diff --git a/atest/robot/running/try_except/except_behaviour.robot b/atest/robot/running/try_except/except_behaviour.robot index 74a8adcce74..26c32890efc 100644 --- a/atest/robot/running/try_except/except_behaviour.robot +++ b/atest/robot/running/try_except/except_behaviour.robot @@ -5,19 +5,19 @@ Test Template Verify try except and block statuses *** Test Cases *** Equals is the default matcher - FAIL PASS + FAIL PASS pattern_types=[None] Equals with whitespace FAIL PASS Glob matcher - FAIL NOT RUN PASS + FAIL NOT RUN PASS pattern_types=['GloB', 'gloB'] Startswith matcher - FAIL PASS + FAIL PASS pattern_types=['start'] Regexp matcher - FAIL NOT RUN PASS + FAIL NOT RUN PASS pattern_types=['REGEXP', 'REGEXP'] Regexp escapes FAIL PASS @@ -35,16 +35,19 @@ Non-string pattern FAIL NOT RUN NOT RUN NOT RUN NOT RUN Variable in pattern type - FAIL PASS + FAIL PASS pattern_types=['\${regexp}'] Invalid variable in pattern type - FAIL FAIL PASS + FAIL FAIL PASS pattern_types=['\${does not exist}'] Invalid pattern type - FAIL FAIL + FAIL FAIL pattern_types=['invalid'] Non-string pattern type - FAIL FAIL + FAIL FAIL pattern_types=['\${42}'] + +Pattern type multiple times + FAIL NOT RUN pattern_types=['glob, start'] Pattern type without patterns FAIL PASS @@ -53,7 +56,7 @@ Skip cannot be caught SKIP NOT RUN PASS tc_status=SKIP Return cannot be caught - PASS NOT RUN PASS path=body[0].body[0] + PASS NOT RUN PASS path=body[0].body[0] AS gets the message FAIL PASS diff --git a/atest/robot/running/try_except/try_except_resource.robot b/atest/robot/running/try_except/try_except_resource.robot index 6f557b433e4..590cc5ffd60 100644 --- a/atest/robot/running/try_except/try_except_resource.robot +++ b/atest/robot/running/try_except/try_except_resource.robot @@ -4,9 +4,10 @@ Library Collections *** Keywords *** Verify try except and block statuses - [Arguments] @{types_and_statuses} ${tc_status}= ${path}=body[0] + [Arguments] @{types_and_statuses} ${tc_status}= ${path}=body[0] ${pattern_types}=[] ${tc}= Check test status @{{[s.split(':')[-1] for s in $types_and_statuses]}} tc_status=${tc_status} Block statuses should be ${tc.${path}} @{types_and_statuses} + Pattern types should be ${tc.${path}} ${pattern_types} RETURN ${tc} Check Test Status @@ -34,3 +35,11 @@ Block statuses should be Should Be Equal ${block.status} ${type_and_status} END END + +Pattern types should be + [Arguments] ${try_except} ${pattern_types} + @{pattern_types} = Evaluate ${pattern_types} + FOR ${except} ${expected} IN ZIP ${try_except.body[1:]} ${pattern_types} mode=shortest + Should Be Equal ${except.type} EXCEPT + Should Be Equal ${except.pattern_type} ${expected} + END diff --git a/atest/robot/running/while/while_limit.robot b/atest/robot/running/while/while_limit.robot index 0918cc2a75e..8bbd6018ff9 100644 --- a/atest/robot/running/while/while_limit.robot +++ b/atest/robot/running/while/while_limit.robot @@ -50,6 +50,10 @@ Invalid limit invalid value Invalid limit mistyped prefix Check Test Case ${TESTNAME} +Limit used multiple times + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.body[0].limit} 1, 2 + Invalid values after limit ${tc} = Check Test Case ${TESTNAME} Should Be Equal ${tc.body[0].condition} $variable < 2, limit=-1x, invalid, values diff --git a/atest/testdata/running/for/for_in_enumerate.robot b/atest/testdata/running/for/for_in_enumerate.robot index d0297078568..d07fa06e5cf 100644 --- a/atest/testdata/running/for/for_in_enumerate.robot +++ b/atest/testdata/running/for/for_in_enumerate.robot @@ -21,7 +21,7 @@ Start FOR ${index} ${item} IN ENUMERATE ${1} ${2} ${3} ${4} ${5} start=1 Should Be Equal ${index} ${item} END - FOR ${index} ${item} IN ENUMERATE xxx start=xxx start=${100} + FOR ${index} ${item} IN ENUMERATE xxx start\=xxx start=${100} @{result} = Create List @{result} ${index}:${item} END Should Be True ${result} == ['100:xxx', '101:start=xxx'] @@ -45,6 +45,12 @@ Invalid variable in start Fail Should not be executed END +Start multiple times + [Documentation] FAIL Option 'start' allowed only once, got values '1', '2' and '3'. + FOR ${index} ${item} IN ENUMERATE xxx start=1 start=2 start=3 + Fail Should not be executed + END + Index and two items @{values} = Create List a b c d e f FOR ${i} ${item1} ${item2} IN ENUMERATE @{values} diff --git a/atest/testdata/running/for/for_in_zip.robot b/atest/testdata/running/for/for_in_zip.robot index 8174503313b..784fcd2a30f 100644 --- a/atest/testdata/running/for/for_in_zip.robot +++ b/atest/testdata/running/for/for_in_zip.robot @@ -102,7 +102,7 @@ Shortest mode END Should Be True ${result} == ['a:x', 'b:y', 'c:z'] @{result} = Create List - FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=ignored mode=${{'shortest'}} + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=${{'shortest'}} @{result} = Create List @{result} ${x}:${y} END Should Be True ${result} == ['a:1', 'b:2', 'c:3'] @@ -130,7 +130,7 @@ Longest mode with custom fill value END Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), ('?', 4), ('?', 5)] @{result} = Create List - FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} fill=ignored fill=${0} mode=longest + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} fill=${0} mode=longest @{result} = Create List @{result} ${{($x, $y)}} END Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), (0, 4), (0, 5)] @@ -141,6 +141,18 @@ Invalid mode @{result} = Create List @{result} ${x}:${y} END +Config more than once 1 + [Documentation] FAIL Option 'mode' allowed only once, got values 'longest' and 'shortest'. + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=longest mode=shortest + @{result} = Create List @{result} ${x}:${y} + END + +Config more than once 2 + [Documentation] FAIL Option 'fill' allowed only once, got values 'x', 'y' and 'z'. + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} fill=x mode=longest fill=y fill=z + @{result} = Create List @{result} ${x}:${y} + END + Non-existing variable in mode [Documentation] FAIL Invalid mode: Variable '\${bad}' not found. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=${bad} fill=${ignored} diff --git a/atest/testdata/running/try_except/except_behaviour.robot b/atest/testdata/running/try_except/except_behaviour.robot index fb815b1d602..fb10a20c6d2 100644 --- a/atest/testdata/running/try_except/except_behaviour.robot +++ b/atest/testdata/running/try_except/except_behaviour.robot @@ -122,6 +122,14 @@ Non-string pattern type Fail Should not be executed END +Pattern type multiple times + [Documentation] FAIL Option 'type' allowed only once, got values 'glob' and 'start'. + TRY + Fail failure + EXCEPT x type=glob type=start + Fail Should not be executed + END + Pattern type without patterns TRY Fail oh no diff --git a/atest/testdata/running/while/while_limit.robot b/atest/testdata/running/while/while_limit.robot index 216c6f9adbf..a913ace6b65 100644 --- a/atest/testdata/running/while/while_limit.robot +++ b/atest/testdata/running/while/while_limit.robot @@ -105,6 +105,12 @@ Invalid limit mistyped prefix Log ${variable} END +Limit used multiple times + [Documentation] FAIL Option 'limit' allowed only once, got values '1' and '2'. + WHILE True limit=1 limit=2 + Log ${variable} + END + Invalid values after limit [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limit=-1x', 'invalid' and 'values'. WHILE $variable < 2 limit=-1x invalid values diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 34c0d803486..34af3398809 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -66,6 +66,12 @@ def input(self, statement: Statement): def lex(self): raise NotImplementedError + def _lex_options(self, *names: str, end_index: 'int|None' = None): + for token in reversed(self.statement[:end_index]): + if not token.value.startswith(names): + break + token.type = Token.OPTION + class SingleType(StatementLexer, ABC): @@ -210,14 +216,10 @@ def lex(self): separator = normalize_whitespace(token.value) else: token.type = Token.VARIABLE - if (separator == 'IN ENUMERATE' - and self.statement[-1].value.startswith('start=')): - self.statement[-1].type = Token.OPTION + if separator == 'IN ENUMERATE': + self._lex_options('start=') elif separator == 'IN ZIP': - for token in reversed(self.statement): - if not token.value.startswith(('mode=', 'fill=')): - break - token.type = Token.OPTION + self._lex_options('mode=', 'fill=') class IfHeaderLexer(TypeAndArguments): @@ -285,19 +287,16 @@ def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: def lex(self): self.statement[0].type = Token.EXCEPT - last_pattern = None - as_seen = False - for token in self.statement[1:]: + as_index: 'int|None' = None + for index, token in enumerate(self.statement[1:], start=1): if token.value == 'AS': token.type = Token.AS - as_seen = True - elif as_seen: + as_index = index + elif as_index: token.type = Token.VARIABLE else: token.type = Token.ARGUMENT - last_pattern = token - if last_pattern and last_pattern.value.startswith('type='): - last_pattern.type = Token.OPTION + self._lex_options('type=', end_index=as_index) class FinallyHeaderLexer(TypeAndArguments): @@ -319,11 +318,7 @@ def lex(self): self.statement[0].type = Token.WHILE for token in self.statement[1:]: token.type = Token.ARGUMENT - for token in reversed(self.statement): - if not token.value.startswith(('limit=', 'on_limit=', - 'on_limit_message=')): - break - token.type = Token.OPTION + self._lex_options('limit=', 'on_limit=', 'on_limit_message=') class EndLexer(TypeAndArguments): diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 458c8b13117..5d13e52168b 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -118,7 +118,6 @@ def from_params(cls, *args, **kwargs) -> 'Statement': def data_tokens(self) -> 'list[Token]': return [t for t in self.tokens if t.type not in Token.NON_DATA_TOKENS] - # TODO: Try raising an exception if three's no match. def get_token(self, *types: str) -> 'Token|None': """Return a token with any of the given ``types``. @@ -160,16 +159,20 @@ def get_option(self, name: str, default: 'str|None' = None) -> 'str|None': If the option has not been used, return ``default``. + If the option has been used multiple times, values are joined together. + This is typically an error situation and validated elsewhere. + New in Robot Framework 6.1. """ - # FIXME: Change the logic to return the first match, not the last. - # Also change validation so that only one option is allowed. - result = default + options = self._get_options() + return ', '.join(options[name]) if name in options else default + + def _get_options(self) -> 'dict[str, list[str]]': + options: 'dict[str, list[str]]' = {} for option in self.get_values(Token.OPTION): - opt_name, opt_value = option.split('=', 1) - if opt_name == name: - result = opt_value - return result + name, value = option.split('=', 1) + options.setdefault(name, []).append(value) + return options @property def lines(self) -> 'Iterator[list[Token]]': @@ -185,6 +188,12 @@ def lines(self) -> 'Iterator[list[Token]]': def validate(self, ctx: 'ValidationContext'): pass + def _validate_options(self): + for name, values in self._get_options().items(): + if len(values) > 1: + self.errors += (f"Option '{name}' allowed only once, got values " + f"{seq2str(values)}.",) + def __iter__(self) -> 'Iterator[Token]': return iter(self.tokens) @@ -943,6 +952,7 @@ def fill(self) -> 'str|None': return self.get_option('fill') if self.flavor == 'IN ZIP' else None def validate(self, ctx: 'ValidationContext'): + self._validate_options() if not self.variables: self._add_error('no loop variables') if not self.flavor: @@ -1107,6 +1117,7 @@ def variable(self) -> 'str|None': return self.get_value(Token.VARIABLE) def validate(self, ctx: 'ValidationContext'): + self._validate_options() as_token = self.get_token(Token.AS) if as_token: variables = self.get_tokens(Token.VARIABLE) @@ -1175,6 +1186,7 @@ def validate(self, ctx: 'ValidationContext'): self.errors += (f'WHILE cannot have more than one condition, got {seq2str(values)}.',) if self.on_limit and not self.limit: self.errors += ('WHILE on_limit option cannot be used without limit.',) + self._validate_options() @Statement.register diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 8316ba7aa29..f6c3d9581a7 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -544,7 +544,8 @@ def run(self, data): def _run_invalid(self, data): error_reported = False for branch in data.body: - result = TryBranchResult(branch.type, branch.patterns, branch.variable) + result = TryBranchResult(branch.type, branch.patterns, branch.pattern_type, + branch.variable) with StatusReporter(branch, result, self._context, run=False, suppress=True): runner = BodyRunner(self._context, run=False, templated=self._templated) runner.run(branch.body) From 7383ca4affad98c9e1ac151f9040608422c16142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 3 May 2023 16:00:06 +0300 Subject: [PATCH 0530/1592] Make `Lexer.handles` an instance method, not a classmethod. This avoids the need to passing context both to `handles` and to `__init__`. This most importantly helps with typing (#4740) but also simplifies code. A drawback is that creating an instance has a tiny performance penalty. Also other small typing enhancements to make Mypy happier. --- src/robot/parsing/lexer/blocklexers.py | 123 +++++++++------------ src/robot/parsing/lexer/statementlexers.py | 54 ++++----- 2 files changed, 73 insertions(+), 104 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index 6fdfb8a92de..df3e4cd87e7 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -17,24 +17,20 @@ from robot.utils import normalize_whitespace -from .context import FileContext, LexingContext, SuiteFileContext, TestOrKeywordContext +from .context import (FileContext, LexingContext, SuiteFileContext, + TestOrKeywordContext) +from .statementlexers import (BreakLexer, CommentLexer, CommentSectionHeaderLexer, + ContinueLexer, ElseHeaderLexer, ElseIfHeaderLexer, + EndLexer, ExceptHeaderLexer, FinallyHeaderLexer, + ForHeaderLexer, IfHeaderLexer, ImplicitCommentLexer, + InlineIfHeaderLexer, InvalidSectionHeaderLexer, + KeywordCallLexer, KeywordSectionHeaderLexer, Lexer, + ReturnLexer, SettingLexer, SettingSectionHeaderLexer, + Statement, SyntaxErrorLexer, TaskSectionHeaderLexer, + TestCaseSectionHeaderLexer, TestOrKeywordSettingLexer, + TryHeaderLexer, VariableLexer, VariableSectionHeaderLexer, + WhileHeaderLexer) from .tokens import Token -from .statementlexers import (Lexer, - SettingSectionHeaderLexer, SettingLexer, - VariableSectionHeaderLexer, VariableLexer, - TestCaseSectionHeaderLexer, - TaskSectionHeaderLexer, - KeywordSectionHeaderLexer, - CommentSectionHeaderLexer, CommentLexer, ImplicitCommentLexer, - InvalidSectionHeaderLexer, - TestOrKeywordSettingLexer, - KeywordCallLexer, - IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, - InlineIfHeaderLexer, EndLexer, - TryHeaderLexer, ExceptHeaderLexer, FinallyHeaderLexer, - ForHeaderLexer, WhileHeaderLexer, - ContinueLexer, BreakLexer, ReturnLexer, - SyntaxErrorLexer) class BlockLexer(Lexer): @@ -43,10 +39,10 @@ def __init__(self, ctx: LexingContext): super().__init__(ctx) self.lexers: 'list[Lexer]' = [] - def accepts_more(self, statement: 'list[Token]') -> bool: + def accepts_more(self, statement: Statement) -> bool: return True - def input(self, statement: 'list[Token]'): + def input(self, statement: Statement): if self.lexers and self.lexers[-1].accepts_more(statement): lexer = self.lexers[-1] else: @@ -54,10 +50,10 @@ def input(self, statement: 'list[Token]'): self.lexers.append(lexer) lexer.input(statement) - def lexer_for(self, statement: 'list[Token]') -> Lexer: + def lexer_for(self, statement: Statement) -> Lexer: for cls in self.lexer_classes(): - if cls.handles(statement, self.ctx): - lexer = cls(self.ctx) + lexer = cls(self.ctx) + if lexer.handles(statement): return lexer raise TypeError(f"{type(self).__name__} does not have lexer for " f"statement {statement}.") @@ -91,16 +87,16 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class SectionLexer(BlockLexer): + ctx: FileContext - def accepts_more(self, statement: 'list[Token]') -> bool: + def accepts_more(self, statement: Statement) -> bool: return not statement[0].value.startswith('*') class SettingSectionLexer(SectionLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: - return ctx.setting_section(statement) + def handles(self, statement: Statement) -> bool: + return self.ctx.setting_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (SettingSectionHeaderLexer, SettingLexer) @@ -108,9 +104,8 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class VariableSectionLexer(SectionLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: - return ctx.variable_section(statement) + def handles(self, statement: Statement) -> bool: + return self.ctx.variable_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (VariableSectionHeaderLexer, VariableLexer) @@ -118,9 +113,8 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class TestCaseSectionLexer(SectionLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: - return ctx.test_case_section(statement) + def handles(self, statement: Statement) -> bool: + return self.ctx.test_case_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TestCaseSectionHeaderLexer, TestCaseLexer) @@ -128,9 +122,8 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class TaskSectionLexer(SectionLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: - return ctx.task_section(statement) + def handles(self, statement: Statement) -> bool: + return self.ctx.task_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TaskSectionHeaderLexer, TestCaseLexer) @@ -138,9 +131,8 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class KeywordSectionLexer(SettingSectionLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: - return ctx.keyword_section(statement) + def handles(self, statement: Statement) -> bool: + return self.ctx.keyword_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (KeywordSectionHeaderLexer, KeywordLexer) @@ -148,9 +140,8 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class CommentSectionLexer(SectionLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: - return ctx.comment_section(statement) + def handles(self, statement: Statement) -> bool: + return self.ctx.comment_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (CommentSectionHeaderLexer, CommentLexer) @@ -158,8 +149,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class ImplicitCommentSectionLexer(SectionLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: + def handles(self, statement: Statement) -> bool: return True def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -168,8 +158,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class InvalidSectionLexer(SectionLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: FileContext) -> bool: + def handles(self, statement: Statement) -> bool: return bool(statement and statement[0].value.startswith('*')) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -180,15 +169,15 @@ class TestOrKeywordLexer(BlockLexer): name_type: str _name_seen = False - def accepts_more(self, statement: 'list[Token]') -> bool: + def accepts_more(self, statement: Statement) -> bool: return not statement[0].value - def input(self, statement: 'list[Token]'): + def input(self, statement: Statement): self._handle_name_or_indentation(statement) if statement: super().input(statement) - def _handle_name_or_indentation(self, statement: 'list[Token]'): + def _handle_name_or_indentation(self, statement: Statement): if not self._name_seen: name_token = statement.pop(0) name_token.type = self.name_type @@ -226,15 +215,16 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class NestedBlockLexer(BlockLexer): + ctx: TestOrKeywordContext def __init__(self, ctx: TestOrKeywordContext): super().__init__(ctx) self._block_level = 0 - def accepts_more(self, statement: 'list[Token]') -> bool: + def accepts_more(self, statement: Statement) -> bool: return self._block_level > 0 - def input(self, statement: 'list[Token]'): + def input(self, statement: Statement): super().input(statement) lexer = self.lexers[-1] if isinstance(lexer, (ForHeaderLexer, IfHeaderLexer, TryHeaderLexer, @@ -246,9 +236,8 @@ def input(self, statement: 'list[Token]'): class ForLexer(NestedBlockLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: - return ForHeaderLexer.handles(statement, ctx) + def handles(self, statement: Statement) -> bool: + return ForHeaderLexer(self.ctx).handles(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (ForHeaderLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, EndLexer, @@ -257,9 +246,8 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class WhileLexer(NestedBlockLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: - return WhileHeaderLexer.handles(statement, ctx) + def handles(self, statement: Statement) -> bool: + return WhileHeaderLexer(self.ctx).handles(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (WhileHeaderLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, EndLexer, @@ -268,9 +256,8 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class TryLexer(NestedBlockLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: - return TryHeaderLexer.handles(statement, ctx) + def handles(self, statement: Statement) -> bool: + return TryHeaderLexer(self.ctx).handles(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, @@ -280,9 +267,8 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class IfLexer(NestedBlockLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: - return IfHeaderLexer.handles(statement, ctx) + def handles(self, statement: Statement) -> bool: + return IfHeaderLexer(self.ctx).handles(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (InlineIfLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, @@ -290,27 +276,26 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': BreakLexer, SyntaxErrorLexer, KeywordCallLexer) -class InlineIfLexer(BlockLexer): +class InlineIfLexer(NestedBlockLexer): - @classmethod - def handles(cls, statement: 'list[Token]', ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: if len(statement) <= 2: return False - return InlineIfHeaderLexer.handles(statement, ctx) + return InlineIfHeaderLexer(self.ctx).handles(statement) - def accepts_more(self, statement: 'list[Token]') -> bool: + def accepts_more(self, statement: Statement) -> bool: return False def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (InlineIfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) - def input(self, statement: 'list[Token]'): + def input(self, statement: Statement): for part in self._split(statement): if part: super().input(part) - def _split(self, statement: 'list[Token]') -> 'Iterator[list[Token]]': + def _split(self, statement: Statement) -> 'Iterator[list[Token]]': current = [] expect_condition = False for token in statement: diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 34af3398809..eb9de544d92 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -33,8 +33,7 @@ class Lexer(ABC): def __init__(self, ctx: LexingContext): self.ctx = ctx - @classmethod - def handles(cls, statement: Statement, ctx: LexingContext) -> bool: + def handles(self, statement: Statement) -> bool: return True @abstractmethod @@ -53,7 +52,7 @@ def lex(self): class StatementLexer(Lexer, ABC): token_type: str - def __init__(self, ctx: FileContext): + def __init__(self, ctx: 'FileContext|TestOrKeywordContext'): super().__init__(ctx) self.statement: Statement = [] @@ -91,8 +90,7 @@ def lex(self): class SectionHeaderLexer(SingleType, ABC): ctx: FileContext - @classmethod - def handles(cls, statement: Statement, ctx: FileContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value.startswith('*') @@ -163,8 +161,7 @@ def lex(self): # TODO: Try splitting to TestSettingLexer and KeywordSettingLexer. Same with Context. class TestOrKeywordSettingLexer(SettingLexer): - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: marker = statement[0].value return bool(marker and marker[0] == '[' and marker[-1] == ']') @@ -199,10 +196,10 @@ def _lex_as_keyword_call(self): class ForHeaderLexer(StatementLexer): + ctx: TestOrKeywordContext separators = ('IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP') - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'FOR' def lex(self): @@ -225,16 +222,14 @@ def lex(self): class IfHeaderLexer(TypeAndArguments): token_type = Token.IF - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'IF' and len(statement) <= 2 class InlineIfHeaderLexer(StatementLexer): token_type = Token.INLINE_IF - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: for token in statement: if token.value == 'IF': return True @@ -257,32 +252,28 @@ def lex(self): class ElseIfHeaderLexer(TypeAndArguments): token_type = Token.ELSE_IF - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return normalize_whitespace(statement[0].value) == 'ELSE IF' class ElseHeaderLexer(TypeAndArguments): token_type = Token.ELSE - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'ELSE' class TryHeaderLexer(TypeAndArguments): token_type = Token.TRY - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'TRY' class ExceptHeaderLexer(StatementLexer): token_type = Token.EXCEPT - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'EXCEPT' def lex(self): @@ -302,16 +293,14 @@ def lex(self): class FinallyHeaderLexer(TypeAndArguments): token_type = Token.FINALLY - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'FINALLY' class WhileHeaderLexer(StatementLexer): token_type = Token.WHILE - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'WHILE' def lex(self): @@ -324,40 +313,35 @@ def lex(self): class EndLexer(TypeAndArguments): token_type = Token.END - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'END' class ReturnLexer(TypeAndArguments): token_type = Token.RETURN_STATEMENT - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'RETURN' class ContinueLexer(TypeAndArguments): token_type = Token.CONTINUE - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'CONTINUE' class BreakLexer(TypeAndArguments): token_type = Token.BREAK - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value == 'BREAK' class SyntaxErrorLexer(TypeAndArguments): token_type = Token.ERROR - @classmethod - def handles(cls, statement: Statement, ctx: TestOrKeywordContext) -> bool: + def handles(self, statement: Statement) -> bool: return statement[0].value in {'ELSE', 'ELSE IF', 'EXCEPT', 'FINALLY', 'BREAK', 'CONTINUE', 'RETURN', 'END'} From 558fb75a912d54d51d9be357d0264220fec87c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 3 May 2023 16:12:19 +0300 Subject: [PATCH 0531/1592] Rename Statement type alias to StatementTokens. We also have concrete Statement type and having a type alias (list[Token]) with same name is confusing. Even VSCode got confused when renaming and renamed both. Related to #4740. --- src/robot/parsing/lexer/blocklexers.py | 52 +++++++++++----------- src/robot/parsing/lexer/statementlexers.py | 48 ++++++++++---------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index df3e4cd87e7..1a636fc4f86 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -26,7 +26,7 @@ InlineIfHeaderLexer, InvalidSectionHeaderLexer, KeywordCallLexer, KeywordSectionHeaderLexer, Lexer, ReturnLexer, SettingLexer, SettingSectionHeaderLexer, - Statement, SyntaxErrorLexer, TaskSectionHeaderLexer, + StatementTokens, SyntaxErrorLexer, TaskSectionHeaderLexer, TestCaseSectionHeaderLexer, TestOrKeywordSettingLexer, TryHeaderLexer, VariableLexer, VariableSectionHeaderLexer, WhileHeaderLexer) @@ -39,10 +39,10 @@ def __init__(self, ctx: LexingContext): super().__init__(ctx) self.lexers: 'list[Lexer]' = [] - def accepts_more(self, statement: Statement) -> bool: + def accepts_more(self, statement: StatementTokens) -> bool: return True - def input(self, statement: Statement): + def input(self, statement: StatementTokens): if self.lexers and self.lexers[-1].accepts_more(statement): lexer = self.lexers[-1] else: @@ -50,7 +50,7 @@ def input(self, statement: Statement): self.lexers.append(lexer) lexer.input(statement) - def lexer_for(self, statement: Statement) -> Lexer: + def lexer_for(self, statement: StatementTokens) -> Lexer: for cls in self.lexer_classes(): lexer = cls(self.ctx) if lexer.handles(statement): @@ -89,13 +89,13 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class SectionLexer(BlockLexer): ctx: FileContext - def accepts_more(self, statement: Statement) -> bool: + def accepts_more(self, statement: StatementTokens) -> bool: return not statement[0].value.startswith('*') class SettingSectionLexer(SectionLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return self.ctx.setting_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -104,7 +104,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class VariableSectionLexer(SectionLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return self.ctx.variable_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -113,7 +113,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class TestCaseSectionLexer(SectionLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return self.ctx.test_case_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -122,7 +122,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class TaskSectionLexer(SectionLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return self.ctx.task_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -131,7 +131,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class KeywordSectionLexer(SettingSectionLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return self.ctx.keyword_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -140,7 +140,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class CommentSectionLexer(SectionLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return self.ctx.comment_section(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -149,7 +149,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class ImplicitCommentSectionLexer(SectionLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return True def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -158,7 +158,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class InvalidSectionLexer(SectionLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return bool(statement and statement[0].value.startswith('*')) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -169,15 +169,15 @@ class TestOrKeywordLexer(BlockLexer): name_type: str _name_seen = False - def accepts_more(self, statement: Statement) -> bool: + def accepts_more(self, statement: StatementTokens) -> bool: return not statement[0].value - def input(self, statement: Statement): + def input(self, statement: StatementTokens): self._handle_name_or_indentation(statement) if statement: super().input(statement) - def _handle_name_or_indentation(self, statement: Statement): + def _handle_name_or_indentation(self, statement: StatementTokens): if not self._name_seen: name_token = statement.pop(0) name_token.type = self.name_type @@ -221,10 +221,10 @@ def __init__(self, ctx: TestOrKeywordContext): super().__init__(ctx) self._block_level = 0 - def accepts_more(self, statement: Statement) -> bool: + def accepts_more(self, statement: StatementTokens) -> bool: return self._block_level > 0 - def input(self, statement: Statement): + def input(self, statement: StatementTokens): super().input(statement) lexer = self.lexers[-1] if isinstance(lexer, (ForHeaderLexer, IfHeaderLexer, TryHeaderLexer, @@ -236,7 +236,7 @@ def input(self, statement: Statement): class ForLexer(NestedBlockLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return ForHeaderLexer(self.ctx).handles(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -246,7 +246,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class WhileLexer(NestedBlockLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return WhileHeaderLexer(self.ctx).handles(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -256,7 +256,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class TryLexer(NestedBlockLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return TryHeaderLexer(self.ctx).handles(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -267,7 +267,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class IfLexer(NestedBlockLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return IfHeaderLexer(self.ctx).handles(statement) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': @@ -278,24 +278,24 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': class InlineIfLexer(NestedBlockLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: if len(statement) <= 2: return False return InlineIfHeaderLexer(self.ctx).handles(statement) - def accepts_more(self, statement: Statement) -> bool: + def accepts_more(self, statement: StatementTokens) -> bool: return False def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (InlineIfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) - def input(self, statement: Statement): + def input(self, statement: StatementTokens): for part in self._split(statement): if part: super().input(part) - def _split(self, statement: Statement) -> 'Iterator[list[Token]]': + def _split(self, statement: StatementTokens) -> 'Iterator[StatementTokens]': current = [] expect_condition = False for token in statement: diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index eb9de544d92..bd735ce3dfb 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -24,7 +24,7 @@ from .tokens import Token -Statement = List[Token] +StatementTokens = List[Token] # TODO: Try making generic. @@ -33,15 +33,15 @@ class Lexer(ABC): def __init__(self, ctx: LexingContext): self.ctx = ctx - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return True @abstractmethod - def accepts_more(self, statement: Statement) -> bool: + def accepts_more(self, statement: StatementTokens) -> bool: raise NotImplementedError @abstractmethod - def input(self, statement: Statement): + def input(self, statement: StatementTokens): raise NotImplementedError @abstractmethod @@ -54,12 +54,12 @@ class StatementLexer(Lexer, ABC): def __init__(self, ctx: 'FileContext|TestOrKeywordContext'): super().__init__(ctx) - self.statement: Statement = [] + self.statement: StatementTokens = [] - def accepts_more(self, statement: Statement) -> bool: + def accepts_more(self, statement: StatementTokens) -> bool: return False - def input(self, statement: Statement): + def input(self, statement: StatementTokens): self.statement = statement def lex(self): @@ -90,7 +90,7 @@ def lex(self): class SectionHeaderLexer(SingleType, ABC): ctx: FileContext - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value.startswith('*') @@ -132,7 +132,7 @@ class CommentLexer(SingleType): class ImplicitCommentLexer(CommentLexer): ctx: FileContext - def input(self, statement: Statement): + def input(self, statement: StatementTokens): super().input(statement) if len(statement) == 1 and statement[0].value.lower().startswith('language:'): lang = statement[0].value.split(':', 1)[1].strip() @@ -161,7 +161,7 @@ def lex(self): # TODO: Try splitting to TestSettingLexer and KeywordSettingLexer. Same with Context. class TestOrKeywordSettingLexer(SettingLexer): - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: marker = statement[0].value return bool(marker and marker[0] == '[' and marker[-1] == ']') @@ -199,7 +199,7 @@ class ForHeaderLexer(StatementLexer): ctx: TestOrKeywordContext separators = ('IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP') - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'FOR' def lex(self): @@ -222,14 +222,14 @@ def lex(self): class IfHeaderLexer(TypeAndArguments): token_type = Token.IF - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'IF' and len(statement) <= 2 class InlineIfHeaderLexer(StatementLexer): token_type = Token.INLINE_IF - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: for token in statement: if token.value == 'IF': return True @@ -252,28 +252,28 @@ def lex(self): class ElseIfHeaderLexer(TypeAndArguments): token_type = Token.ELSE_IF - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return normalize_whitespace(statement[0].value) == 'ELSE IF' class ElseHeaderLexer(TypeAndArguments): token_type = Token.ELSE - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'ELSE' class TryHeaderLexer(TypeAndArguments): token_type = Token.TRY - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'TRY' class ExceptHeaderLexer(StatementLexer): token_type = Token.EXCEPT - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'EXCEPT' def lex(self): @@ -293,14 +293,14 @@ def lex(self): class FinallyHeaderLexer(TypeAndArguments): token_type = Token.FINALLY - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'FINALLY' class WhileHeaderLexer(StatementLexer): token_type = Token.WHILE - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'WHILE' def lex(self): @@ -313,35 +313,35 @@ def lex(self): class EndLexer(TypeAndArguments): token_type = Token.END - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'END' class ReturnLexer(TypeAndArguments): token_type = Token.RETURN_STATEMENT - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'RETURN' class ContinueLexer(TypeAndArguments): token_type = Token.CONTINUE - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'CONTINUE' class BreakLexer(TypeAndArguments): token_type = Token.BREAK - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'BREAK' class SyntaxErrorLexer(TypeAndArguments): token_type = Token.ERROR - def handles(self, statement: Statement) -> bool: + def handles(self, statement: StatementTokens) -> bool: return statement[0].value in {'ELSE', 'ELSE IF', 'EXCEPT', 'FINALLY', 'BREAK', 'CONTINUE', 'RETURN', 'END'} From 23c05731c7cba1ba86c965a50d566ccbbad8b2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 3 May 2023 17:31:02 +0300 Subject: [PATCH 0532/1592] Lexing tuning. - Add separate TestCaseSettingLexer and KeywordSettingLexer and remove TestOrKeywordSettingLexer. - Remove unnecessary TestOrKeywordContext base class. - Add ABC as an explicit base class in more places. To same extend related to #4740. --- src/robot/parsing/lexer/blocklexers.py | 34 ++++++++++++---------- src/robot/parsing/lexer/context.py | 16 ++++------ src/robot/parsing/lexer/statementlexers.py | 28 +++++++++++++----- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index 1a636fc4f86..b23f18f7df0 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -13,27 +13,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC from collections.abc import Iterator from robot.utils import normalize_whitespace -from .context import (FileContext, LexingContext, SuiteFileContext, - TestOrKeywordContext) +from .context import (FileContext, KeywordContext, LexingContext, SuiteFileContext, + TestCaseContext) from .statementlexers import (BreakLexer, CommentLexer, CommentSectionHeaderLexer, ContinueLexer, ElseHeaderLexer, ElseIfHeaderLexer, EndLexer, ExceptHeaderLexer, FinallyHeaderLexer, ForHeaderLexer, IfHeaderLexer, ImplicitCommentLexer, InlineIfHeaderLexer, InvalidSectionHeaderLexer, - KeywordCallLexer, KeywordSectionHeaderLexer, Lexer, - ReturnLexer, SettingLexer, SettingSectionHeaderLexer, - StatementTokens, SyntaxErrorLexer, TaskSectionHeaderLexer, - TestCaseSectionHeaderLexer, TestOrKeywordSettingLexer, + KeywordCallLexer, KeywordSectionHeaderLexer, + KeywordSettingLexer, Lexer, ReturnLexer, SettingLexer, + SettingSectionHeaderLexer, StatementTokens, + SyntaxErrorLexer, TaskSectionHeaderLexer, + TestCaseSectionHeaderLexer, TestCaseSettingLexer, TryHeaderLexer, VariableLexer, VariableSectionHeaderLexer, WhileHeaderLexer) from .tokens import Token -class BlockLexer(Lexer): +class BlockLexer(Lexer, ABC): def __init__(self, ctx: LexingContext): super().__init__(ctx) @@ -86,7 +88,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': InvalidSectionLexer, ImplicitCommentSectionLexer) -class SectionLexer(BlockLexer): +class SectionLexer(BlockLexer, ABC): ctx: FileContext def accepts_more(self, statement: StatementTokens) -> bool: @@ -165,7 +167,7 @@ def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (InvalidSectionHeaderLexer, CommentLexer) -class TestOrKeywordLexer(BlockLexer): +class TestOrKeywordLexer(BlockLexer, ABC): name_type: str _name_seen = False @@ -196,10 +198,10 @@ def __init__(self, ctx: SuiteFileContext): super().__init__(ctx.test_case_context()) def lex(self): - self._lex_with_priority(priority=TestOrKeywordSettingLexer) + self._lex_with_priority(priority=TestCaseSettingLexer) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': - return (TestOrKeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, + return (TestCaseSettingLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) @@ -210,14 +212,14 @@ def __init__(self, ctx: FileContext): super().__init__(ctx.keyword_context()) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': - return (TestOrKeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, - ReturnLexer, TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) + return (KeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, ReturnLexer, + TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) -class NestedBlockLexer(BlockLexer): - ctx: TestOrKeywordContext +class NestedBlockLexer(BlockLexer, ABC): + ctx: 'TestCaseContext|KeywordContext' - def __init__(self, ctx: TestOrKeywordContext): + def __init__(self, ctx: 'TestCaseContext|KeywordContext'): super().__init__(ctx) self._block_level = 0 diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 183b9564a22..4444a7ca1f0 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -129,15 +129,7 @@ def _get_invalid_section_error(self, header: str) -> str: f"'Settings', 'Variables', 'Keywords' and 'Comments'.") -# TODO: Try removing base class -class TestOrKeywordContext(LexingContext): - - @property - def template_set(self) -> bool: - return False - - -class TestCaseContext(TestOrKeywordContext): +class TestCaseContext(LexingContext): settings: TestCaseSettings @property @@ -145,5 +137,9 @@ def template_set(self) -> bool: return self.settings.template_set -class KeywordContext(TestOrKeywordContext): +class KeywordContext(LexingContext): settings: KeywordSettings + + @property + def template_set(self) -> bool: + return False diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index bd735ce3dfb..e2d3e857871 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -20,14 +20,13 @@ from robot.utils import normalize_whitespace from robot.variables import is_assign -from .context import FileContext, LexingContext, TestOrKeywordContext +from .context import FileContext, LexingContext, KeywordContext, TestCaseContext from .tokens import Token StatementTokens = List[Token] -# TODO: Try making generic. class Lexer(ABC): def __init__(self, ctx: LexingContext): @@ -52,7 +51,7 @@ def lex(self): class StatementLexer(Lexer, ABC): token_type: str - def __init__(self, ctx: 'FileContext|TestOrKeywordContext'): + def __init__(self, ctx: LexingContext): super().__init__(ctx) self.statement: StatementTokens = [] @@ -153,13 +152,28 @@ def lex(self): class SettingLexer(StatementLexer): + ctx: FileContext def lex(self): self.ctx.lex_setting(self.statement) -# TODO: Try splitting to TestSettingLexer and KeywordSettingLexer. Same with Context. -class TestOrKeywordSettingLexer(SettingLexer): +class TestCaseSettingLexer(StatementLexer): + ctx: TestCaseContext + + def lex(self): + self.ctx.lex_setting(self.statement) + + def handles(self, statement: StatementTokens) -> bool: + marker = statement[0].value + return bool(marker and marker[0] == '[' and marker[-1] == ']') + + +class KeywordSettingLexer(StatementLexer): + ctx: KeywordContext + + def lex(self): + self.ctx.lex_setting(self.statement) def handles(self, statement: StatementTokens) -> bool: marker = statement[0].value @@ -167,11 +181,12 @@ def handles(self, statement: StatementTokens) -> bool: class VariableLexer(TypeAndArguments): + ctx: FileContext token_type = Token.VARIABLE class KeywordCallLexer(StatementLexer): - ctx: TestOrKeywordContext + ctx: 'TestCaseContext|KeywordContext' def lex(self): if self.ctx.template_set: @@ -196,7 +211,6 @@ def _lex_as_keyword_call(self): class ForHeaderLexer(StatementLexer): - ctx: TestOrKeywordContext separators = ('IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP') def handles(self, statement: StatementTokens) -> bool: From 3a5f5a0879ffa9471276c9fee5b6945739ab508f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 3 May 2023 18:38:33 +0300 Subject: [PATCH 0533/1592] Lexing context tuning. Better class hierarchy. Related to #4740. --- src/robot/conf/languages.py | 2 +- src/robot/parsing/lexer/context.py | 44 ++++++++++++++--------------- src/robot/parsing/lexer/settings.py | 27 ++++++++++++------ 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 935467f225a..3e02d292b1a 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -38,7 +38,7 @@ class Languages: print(lang.name, lang.code) """ - def __init__(self, languages: 'Iterable[LanguageLike]|LanguageLike' = (), + def __init__(self, languages: 'Iterable[LanguageLike]|LanguageLike|None' = (), add_english: bool = True): """ :param languages: Initial language or list of languages. diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 4444a7ca1f0..2181e347955 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -13,43 +13,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import cast - from robot.conf import Languages, LanguageLike, LanguagesLike -from robot.parsing.lexer.settings import Settings from robot.utils import normalize_whitespace -from .settings import (InitFileSettings, Settings, SuiteFileSettings, +from .settings import (InitFileSettings, FileSettings, Settings, SuiteFileSettings, ResourceFileSettings, TestCaseSettings, KeywordSettings) from .tokens import Token -# TODO: Try making generic. -# TODO: Add separate __init__s for FileContext (accepts only lang) and Test/KwContext (accepts only settings) class LexingContext: - settings_class: 'type[Settings]' - - def __init__(self, settings: 'Settings|None' = None, lang: LanguagesLike = None): - if settings is None: - if not isinstance(lang, Languages): - lang = Languages(cast(LanguageLike, lang)) - self.languages = lang - self.settings = self.settings_class(self.languages) - else: - self.languages = settings.languages - self.settings = settings + + def __init__(self, settings: Settings, languages: Languages): + self.settings = settings + self.languages = languages def lex_setting(self, statement: 'list[Token]'): self.settings.lex(statement) class FileContext(LexingContext): + settings: FileSettings + + def __init__(self, lang: LanguagesLike = None): + languages = lang if isinstance(lang, Languages) else Languages(lang) + settings_class: 'type[FileSettings]' = type(self).__annotations__['settings'] + settings = settings_class(languages) + super().__init__(settings, languages) def add_language(self, lang: LanguageLike): self.languages.add_language(lang) def keyword_context(self) -> 'KeywordContext': - return KeywordContext(settings=KeywordSettings(self.languages)) + return KeywordContext(KeywordSettings(self.settings)) def setting_section(self, statement: 'list[Token]') -> bool: return self._handles_section(statement, 'Settings') @@ -89,11 +84,10 @@ def _normalize(self, marker: str) -> str: class SuiteFileContext(FileContext): - settings_class = SuiteFileSettings settings: SuiteFileSettings def test_case_context(self) -> 'TestCaseContext': - return TestCaseContext(settings=TestCaseSettings(self.settings, self.languages)) + return TestCaseContext(TestCaseSettings(self.settings)) def test_case_section(self, statement: 'list[Token]') -> bool: return self._handles_section(statement, 'Test Cases') @@ -108,7 +102,7 @@ def _get_invalid_section_error(self, header: str) -> str: class ResourceFileContext(FileContext): - settings_class = ResourceFileSettings + settings: ResourceFileSettings def _get_invalid_section_error(self, header: str) -> str: name = self._normalize(header) @@ -119,7 +113,7 @@ def _get_invalid_section_error(self, header: str) -> str: class InitFileContext(FileContext): - settings_class = InitFileSettings + settings: InitFileSettings def _get_invalid_section_error(self, header: str) -> str: name = self._normalize(header) @@ -132,6 +126,9 @@ def _get_invalid_section_error(self, header: str) -> str: class TestCaseContext(LexingContext): settings: TestCaseSettings + def __init__(self, settings: TestCaseSettings): + super().__init__(settings, settings.languages) + @property def template_set(self) -> bool: return self.settings.template_set @@ -140,6 +137,9 @@ def template_set(self) -> bool: class KeywordContext(LexingContext): settings: KeywordSettings + def __init__(self, settings: KeywordSettings): + super().__init__(settings, settings.languages) + @property def template_set(self) -> bool: return False diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 43fc67d413b..cf3873e26a1 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -88,7 +88,7 @@ def _validate(self, orig: str, name: str, statement: 'list[Token]'): f"got {len(statement)-1}.") def _get_non_existing_setting_message(self, name: str, normalized: str) -> str: - if self._is_valid_somewhere(normalized): + if self._is_valid_somewhere(normalized, Settings.__subclasses__()): return self._not_valid_here(name) return RecommendationFinder(normalize).find_and_format( name=normalized, @@ -96,9 +96,10 @@ def _get_non_existing_setting_message(self, name: str, normalized: str) -> str: message=f"Non-existing setting '{name}'." ) - def _is_valid_somewhere(self, normalized: str) -> bool: - for cls in Settings.__subclasses__(): - if normalized in cls.names or normalized in cls.aliases: + def _is_valid_somewhere(self, name: str, classes: 'list[type[Settings]]') -> bool: + for cls in classes: + if (name in cls.names or name in cls.aliases + or self._is_valid_somewhere(name, cls.__subclasses__())): return True return False @@ -140,7 +141,11 @@ def _lex_arguments(self, tokens: 'list[Token]'): token.type = Token.ARGUMENT -class SuiteFileSettings(Settings): +class FileSettings(Settings, ABC): + pass + + +class SuiteFileSettings(FileSettings): names = ( 'Documentation', 'Metadata', @@ -171,7 +176,7 @@ def _not_valid_here(self, name: str) -> str: return f"Setting '{name}' is not allowed in suite file." -class InitFileSettings(Settings): +class InitFileSettings(FileSettings): names = ( 'Documentation', 'Metadata', @@ -199,7 +204,7 @@ def _not_valid_here(self, name: str) -> str: return f"Setting '{name}' is not allowed in suite initialization file." -class ResourceFileSettings(Settings): +class ResourceFileSettings(FileSettings): names = ( 'Documentation', 'Keyword Tags', @@ -222,8 +227,8 @@ class TestCaseSettings(Settings): 'Timeout' ) - def __init__(self, parent: SuiteFileSettings, languages: Languages): - super().__init__(languages) + def __init__(self, parent: SuiteFileSettings): + super().__init__(parent.languages) self.parent = parent def _format_name(self, name: str) -> str: @@ -259,6 +264,10 @@ class KeywordSettings(Settings): 'Return' ) + def __init__(self, parent: FileSettings): + super().__init__(parent.languages) + self.parent = parent + def _format_name(self, name: str) -> str: return name[1:-1].strip() From 555b5a47f4125b4c9c2dabb07734ab6ac3737dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 3 May 2023 21:06:01 +0300 Subject: [PATCH 0534/1592] Use StatementTokens type alias more widely. Related to #4740. --- src/robot/parsing/lexer/__init__.py | 2 +- src/robot/parsing/lexer/blocklexers.py | 11 +++---- src/robot/parsing/lexer/context.py | 24 +++++++------- src/robot/parsing/lexer/settings.py | 37 +++++++++++----------- src/robot/parsing/lexer/statementlexers.py | 5 +-- src/robot/parsing/lexer/tokens.py | 6 +++- 6 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/robot/parsing/lexer/__init__.py b/src/robot/parsing/lexer/__init__.py index a9c47f0d0d5..26196da4535 100644 --- a/src/robot/parsing/lexer/__init__.py +++ b/src/robot/parsing/lexer/__init__.py @@ -14,4 +14,4 @@ # limitations under the License. from .lexer import get_tokens, get_resource_tokens, get_init_tokens -from .tokens import Token +from .tokens import StatementTokens, Token diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index b23f18f7df0..5bbaae7811b 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -27,12 +27,11 @@ InlineIfHeaderLexer, InvalidSectionHeaderLexer, KeywordCallLexer, KeywordSectionHeaderLexer, KeywordSettingLexer, Lexer, ReturnLexer, SettingLexer, - SettingSectionHeaderLexer, StatementTokens, - SyntaxErrorLexer, TaskSectionHeaderLexer, - TestCaseSectionHeaderLexer, TestCaseSettingLexer, - TryHeaderLexer, VariableLexer, VariableSectionHeaderLexer, - WhileHeaderLexer) -from .tokens import Token + SettingSectionHeaderLexer, SyntaxErrorLexer, + TaskSectionHeaderLexer, TestCaseSectionHeaderLexer, + TestCaseSettingLexer, TryHeaderLexer, VariableLexer, + VariableSectionHeaderLexer, WhileHeaderLexer) +from .tokens import StatementTokens, Token class BlockLexer(Lexer, ABC): diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 2181e347955..794c34eda9a 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -18,7 +18,7 @@ from .settings import (InitFileSettings, FileSettings, Settings, SuiteFileSettings, ResourceFileSettings, TestCaseSettings, KeywordSettings) -from .tokens import Token +from .tokens import StatementTokens, Token class LexingContext: @@ -27,7 +27,7 @@ def __init__(self, settings: Settings, languages: Languages): self.settings = settings self.languages = languages - def lex_setting(self, statement: 'list[Token]'): + def lex_setting(self, statement: StatementTokens): self.settings.lex(statement) @@ -46,25 +46,25 @@ def add_language(self, lang: LanguageLike): def keyword_context(self) -> 'KeywordContext': return KeywordContext(KeywordSettings(self.settings)) - def setting_section(self, statement: 'list[Token]') -> bool: + def setting_section(self, statement: StatementTokens) -> bool: return self._handles_section(statement, 'Settings') - def variable_section(self, statement: 'list[Token]') -> bool: + def variable_section(self, statement: StatementTokens) -> bool: return self._handles_section(statement, 'Variables') - def test_case_section(self, statement: 'list[Token]') -> bool: + def test_case_section(self, statement: StatementTokens) -> bool: return False - def task_section(self, statement: 'list[Token]') -> bool: + def task_section(self, statement: StatementTokens) -> bool: return False - def keyword_section(self, statement: 'list[Token]') -> bool: + def keyword_section(self, statement: StatementTokens) -> bool: return self._handles_section(statement, 'Keywords') - def comment_section(self, statement: 'list[Token]') -> bool: + def comment_section(self, statement: StatementTokens) -> bool: return self._handles_section(statement, 'Comments') - def lex_invalid_section(self, statement: 'list[Token]'): + def lex_invalid_section(self, statement: StatementTokens): header = statement[0] header.type = Token.INVALID_HEADER header.error = self._get_invalid_section_error(header.value) @@ -74,7 +74,7 @@ def lex_invalid_section(self, statement: 'list[Token]'): def _get_invalid_section_error(self, header: str) -> str: raise NotImplementedError - def _handles_section(self, statement: 'list[Token]', header: str) -> bool: + def _handles_section(self, statement: StatementTokens, header: str) -> bool: marker = statement[0].value return bool(marker and marker[0] == '*' and self.languages.headers.get(self._normalize(marker)) == header) @@ -89,10 +89,10 @@ class SuiteFileContext(FileContext): def test_case_context(self) -> 'TestCaseContext': return TestCaseContext(TestCaseSettings(self.settings)) - def test_case_section(self, statement: 'list[Token]') -> bool: + def test_case_section(self, statement: StatementTokens) -> bool: return self._handles_section(statement, 'Test Cases') - def task_section(self, statement: 'list[Token]') -> bool: + def task_section(self, statement: StatementTokens) -> bool: return self._handles_section(statement, 'Tasks') def _get_invalid_section_error(self, header: str) -> str: diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index cf3873e26a1..0810ad8a13c 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -18,7 +18,7 @@ from robot.conf import Languages from robot.utils import normalize, normalize_whitespace, RecommendationFinder -from .tokens import Token +from .tokens import StatementTokens, Token class Settings(ABC): @@ -59,9 +59,8 @@ def __init__(self, languages: Languages): self.settings: 'dict[str, list[Token]|None]' = {n: None for n in self.names} self.languages = languages - def lex(self, statement: 'list[Token]'): - setting = statement[0] - orig = self._format_name(setting.value) + def lex(self, statement: StatementTokens): + orig = self._format_name(statement[0].value) name = normalize_whitespace(orig).title() name = self.languages.settings.get(name, name) if name in self.aliases: @@ -69,14 +68,14 @@ def lex(self, statement: 'list[Token]'): try: self._validate(orig, name, statement) except ValueError as err: - self._lex_error(setting, statement[1:], err.args[0]) + self._lex_error(statement, err.args[0]) else: - self._lex_setting(setting, statement[1:], name) + self._lex_setting(statement, name) def _format_name(self, name: str) -> str: return name - def _validate(self, orig: str, name: str, statement: 'list[Token]'): + def _validate(self, orig: str, name: str, statement: StatementTokens): if name not in self.settings: message = self._get_non_existing_setting_message(orig, name) raise ValueError(message) @@ -107,16 +106,16 @@ def _is_valid_somewhere(self, name: str, classes: 'list[type[Settings]]') -> boo def _not_valid_here(self, name: str) -> str: raise NotImplementedError - def _lex_error(self, setting: Token, values: 'list[Token]', error: str): - setting.set_error(error) - for token in values: + def _lex_error(self, statement: StatementTokens, error: str): + statement[0].set_error(error) + for token in statement[1:]: token.type = Token.COMMENT - def _lex_setting(self, setting: Token, values: 'list[Token]', name: str): - self.settings[name] = values + def _lex_setting(self, statement: StatementTokens, name: str): # TODO: Change token type from 'FORCE TAGS' to 'TEST TAGS' in RF 7.0. - setting_type_map = {'Test Tags': 'FORCE TAGS', 'Name': 'SUITE NAME'} - setting.type = setting_type_map.get(name, name.upper()) + statement[0].type = {'Test Tags': Token.FORCE_TAGS, + 'Name': Token.SUITE_NAME}.get(name, name.upper()) + self.settings[name] = values = statement[1:] if name in self.name_and_arguments: self._lex_name_and_arguments(values) elif name in self.name_arguments_and_with_name: @@ -124,19 +123,19 @@ def _lex_setting(self, setting: Token, values: 'list[Token]', name: str): else: self._lex_arguments(values) - def _lex_name_and_arguments(self, tokens: 'list[Token]'): + def _lex_name_and_arguments(self, tokens: StatementTokens): if tokens: tokens[0].type = Token.NAME self._lex_arguments(tokens[1:]) - def _lex_name_arguments_and_with_name(self, tokens: 'list[Token]'): + def _lex_name_arguments_and_with_name(self, tokens: StatementTokens): self._lex_name_and_arguments(tokens) if len(tokens) > 1 and \ normalize_whitespace(tokens[-2].value) in ('WITH NAME', 'AS'): tokens[-2].type = Token.WITH_NAME tokens[-1].type = Token.NAME - def _lex_arguments(self, tokens: 'list[Token]'): + def _lex_arguments(self, tokens: StatementTokens): for token in tokens: token.type = Token.ARGUMENT @@ -242,12 +241,12 @@ def template_set(self) -> bool: parent_template = self.parent.settings['Test Template'] return self._has_value(template) or self._has_value(parent_template) - def _has_disabling_value(self, setting: 'list[Token]|None') -> bool: + def _has_disabling_value(self, setting: 'StatementTokens|None') -> bool: if setting is None: return False return setting == [] or setting[0].value.upper() == 'NONE' - def _has_value(self, setting: 'list[Token]|None') -> bool: + def _has_value(self, setting: 'StatementTokens|None') -> bool: return bool(setting and setting[0].value) def _not_valid_here(self, name: str) -> str: diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index e2d3e857871..dc231cd9625 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -21,10 +21,7 @@ from robot.variables import is_assign from .context import FileContext, LexingContext, KeywordContext, TestCaseContext -from .tokens import Token - - -StatementTokens = List[Token] +from .tokens import StatementTokens, Token class Lexer(ABC): diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 6ebc27324c3..7dd641922c0 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -14,11 +14,15 @@ # limitations under the License. from collections.abc import Iterator -from typing import cast +from typing import cast, List from robot.variables import VariableIterator +# Type alias to ease typing elsewhere +StatementTokens = List['Token'] + + class Token: """Token representing piece of Robot Framework data. From 8ce29eb7457dcb6aa813e784395787b5a6743343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 4 May 2023 14:41:42 +0300 Subject: [PATCH 0535/1592] UG: Document used extensions, media types (#4746), and so on. --- .../src/Appendices/Registrations.rst | 24 +++++++++++++++++++ doc/userguide/src/RobotFrameworkUserGuide.rst | 1 + 2 files changed, 25 insertions(+) create mode 100644 doc/userguide/src/Appendices/Registrations.rst diff --git a/doc/userguide/src/Appendices/Registrations.rst b/doc/userguide/src/Appendices/Registrations.rst new file mode 100644 index 00000000000..84414c8182c --- /dev/null +++ b/doc/userguide/src/Appendices/Registrations.rst @@ -0,0 +1,24 @@ +Registrations +============= + +This appendix lists file extensions, media types, and so on, that are +associated with Robot Framework. + +File extensions +--------------- + +- Robot Framework `suite files`_ use the :file:`.robot` extension. +- Robot Framework `resource files`_ use the :file:`.resource` extension. + +Media type +---------- + +The media type to use with Robot Framework data is `text/robotframework`. + +Remote server port +------------------ + +The default `remote server`__ port is 8270. The port has been `registered by IANA`__. + +__ `Remote library interface`_ +__ https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?search=8270 diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index ec1a1bec696..b205bdde38f 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -110,6 +110,7 @@ .. include:: Appendices/BooleanArguments.rst .. include:: Appendices/EvaluatingExpressions.rst .. include:: Appendices/ApiDocumentation.rst +.. include:: Appendices/Registrations.rst .. footer:: Generated by reStructuredText_. Syntax highlighting by Pygments_. From 941bbc922d62ac8a19b1c55be564380f25c3b059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 4 May 2023 14:43:45 +0300 Subject: [PATCH 0536/1592] UG: Remove useless appendix about API docs --- doc/userguide/src/Appendices/ApiDocumentation.rst | 7 ------- doc/userguide/src/RobotFrameworkUserGuide.rst | 1 - 2 files changed, 8 deletions(-) delete mode 100644 doc/userguide/src/Appendices/ApiDocumentation.rst diff --git a/doc/userguide/src/Appendices/ApiDocumentation.rst b/doc/userguide/src/Appendices/ApiDocumentation.rst deleted file mode 100644 index 070bc972706..00000000000 --- a/doc/userguide/src/Appendices/ApiDocumentation.rst +++ /dev/null @@ -1,7 +0,0 @@ -Internal API -============ - -`API documentation`_ is hosted separately -at the excellent `Read the Docs`_ service. If you are unsure how to use -certain API or is using them forward compatible, please send a question -to `mailing list`_. diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index b205bdde38f..cf8cbe5dbc4 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -109,7 +109,6 @@ .. include:: Appendices/TimeFormat.rst .. include:: Appendices/BooleanArguments.rst .. include:: Appendices/EvaluatingExpressions.rst -.. include:: Appendices/ApiDocumentation.rst .. include:: Appendices/Registrations.rst .. footer:: Generated by reStructuredText_. Syntax highlighting by Pygments_. From c926198fa8ab7733147e008c29c440dfa746cce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 4 May 2023 19:39:08 +0300 Subject: [PATCH 0537/1592] Fix expanding failed keywords with --expandkeywords. Fixes #4756. --- atest/robot/output/expand_keywords.robot | 7 ++++++- src/robot/reporting/expandkeywordmatcher.py | 13 ++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/atest/robot/output/expand_keywords.robot b/atest/robot/output/expand_keywords.robot index 931b4283e9f..475584c25ca 100644 --- a/atest/robot/output/expand_keywords.robot +++ b/atest/robot/output/expand_keywords.robot @@ -34,13 +34,18 @@ Tag as pattern Keywords with skip status are expanded s1-s9-t1-k2 s1-s9-t2-k2-k1 # NAME:BuiltIn.Skip +Keywords with fail status are expanded + [Documentation] Expanding happens regardless is test skipped or not. + s1-s1-t2-k2 s1-s2-t7-k1 s1-s7-t1-k1-k1-k1-k1-k1-k1 # NAME:BuiltIn.Fail + *** Keywords *** Run tests with expanding ${options} = Catenate ... --log log.html + ... --skiponfailure fail ... --expandkeywords name:MyKeyword ... --ExpandKeywords NAME:BuiltIn.Sleep - ... --ExpandKeywords NAME:BuiltIn.Fail # Failed and not run keywords aren't expanded so this doesn't match anything. + ... --ExpandKeywords NAME:BuiltIn.Fail ... --ExpandKeywords NAME:BuiltIn.Skip ... --expand "Name:???-Ä* K?ywörd Näm?" ... --expandkeywords name:<blink>NO</blink> diff --git a/src/robot/reporting/expandkeywordmatcher.py b/src/robot/reporting/expandkeywordmatcher.py index 8ded3dfcd19..c056dd9215b 100644 --- a/src/robot/reporting/expandkeywordmatcher.py +++ b/src/robot/reporting/expandkeywordmatcher.py @@ -13,13 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Sequence + +from robot.result import Keyword from robot.utils import MultiMatcher, is_list_like class ExpandKeywordMatcher: - def __init__(self, expand_keywords): - self.matched_ids = [] + def __init__(self, expand_keywords: 'str|Sequence[str]'): + self.matched_ids: 'list[str]' = [] if not expand_keywords: expand_keywords = [] elif not is_list_like(expand_keywords): @@ -29,7 +32,7 @@ def __init__(self, expand_keywords): self._match_name = MultiMatcher(names).match self._match_tags = MultiMatcher(tags).match_any - def match(self, kw): - if ((kw.passed or kw.skipped) - and (self._match_name(kw.name or '') or self._match_tags(kw.tags))): + def match(self, kw: Keyword): + if (self._match_name(kw.name or '') + or self._match_tags(kw.tags)) and not kw.not_run: self.matched_ids.append(kw.id) From 8cce3fc935ec225ca2e943b4f19296db84a6fb62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 4 May 2023 19:42:50 +0300 Subject: [PATCH 0538/1592] `not is_list_like(x)` -> `isinstance(x, str)` The standard `isinstance` is supported by type checkers. --- src/robot/reporting/expandkeywordmatcher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robot/reporting/expandkeywordmatcher.py b/src/robot/reporting/expandkeywordmatcher.py index c056dd9215b..0b9731955b2 100644 --- a/src/robot/reporting/expandkeywordmatcher.py +++ b/src/robot/reporting/expandkeywordmatcher.py @@ -16,7 +16,7 @@ from collections.abc import Sequence from robot.result import Keyword -from robot.utils import MultiMatcher, is_list_like +from robot.utils import MultiMatcher class ExpandKeywordMatcher: @@ -25,7 +25,7 @@ def __init__(self, expand_keywords: 'str|Sequence[str]'): self.matched_ids: 'list[str]' = [] if not expand_keywords: expand_keywords = [] - elif not is_list_like(expand_keywords): + elif isinstance(expand_keywords, str): expand_keywords = [expand_keywords] names = [n[5:] for n in expand_keywords if n[:5].lower() == 'name:'] tags = [p[4:] for p in expand_keywords if p[:4].lower() == 'tag:'] From 12652d5d05d4504b43c8fb29a48da27c51d224f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 5 May 2023 16:18:01 +0300 Subject: [PATCH 0539/1592] Enhance WHILE and IF validation failure messages. --- atest/robot/running/while/while_limit.robot | 2 +- atest/testdata/running/if/invalid_if.robot | 9 ++++---- .../running/while/invalid_while.robot | 16 +++++++------- atest/testdata/running/while/on_limit.robot | 6 +++--- .../testdata/running/while/while_limit.robot | 8 +++---- src/robot/parsing/model/statements.py | 21 ++++++++++++------- src/robot/running/bodyrunner.py | 2 +- utest/parsing/test_model.py | 3 ++- 8 files changed, 36 insertions(+), 31 deletions(-) diff --git a/atest/robot/running/while/while_limit.robot b/atest/robot/running/while/while_limit.robot index 8bbd6018ff9..866df1d1a7a 100644 --- a/atest/robot/running/while/while_limit.robot +++ b/atest/robot/running/while/while_limit.robot @@ -56,4 +56,4 @@ Limit used multiple times Invalid values after limit ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.body[0].condition} $variable < 2, limit=-1x, invalid, values + Should Be Equal ${tc.body[0].condition} $variable < 2, limit=2, invalid diff --git a/atest/testdata/running/if/invalid_if.robot b/atest/testdata/running/if/invalid_if.robot index 9886ad177b9..8a4365ab39c 100644 --- a/atest/testdata/running/if/invalid_if.robot +++ b/atest/testdata/running/if/invalid_if.robot @@ -69,7 +69,6 @@ Recommend $var syntax if invalid condition contains ${var} Fail Shouldn't be run END - IF without END [Documentation] FAIL IF must have closing END. IF ${True} @@ -98,7 +97,7 @@ ELSE IF without condition END ELSE IF with multiple conditions - [Documentation] FAIL ELSE IF cannot have more than one condition. + [Documentation] FAIL ELSE IF cannot have more than one condition, got '\${False}', 'ooops' and '\${True}'. IF 'maa' == 'maa' Fail Should not be run ELSE IF ${False} ooops ${True} @@ -216,16 +215,16 @@ Multiple errors ... - ELSE IF not allowed after ELSE. ... - Only one ELSE allowed. ... - IF must have closing END. - ... - ELSE IF cannot have more than one condition. + ... - ELSE IF cannot have more than one condition, got 'too' and 'many'. ... - ELSE IF branch cannot be empty. - ... - ELSE does not accept arguments, got 'oops'. + ... - ELSE does not accept arguments, got 'oops', 'i', 'did', 'it' and 'again'. ... - ELSE branch cannot be empty. ... - ELSE IF must have a condition. ... - ELSE IF branch cannot be empty. ... - ELSE branch cannot be empty. IF ELSE IF too many - ELSE oops + ELSE oops i did it again ELSE IF ELSE diff --git a/atest/testdata/running/while/invalid_while.robot b/atest/testdata/running/while/invalid_while.robot index 5e8eb25d803..f85eb904d61 100644 --- a/atest/testdata/running/while/invalid_while.robot +++ b/atest/testdata/running/while/invalid_while.robot @@ -1,33 +1,33 @@ *** Test Cases *** Multiple conditions - [Documentation] FAIL WHILE cannot have more than one condition, got 'Too', 'many', 'conditions' and '!'. + [Documentation] FAIL WHILE loop cannot have more than one condition, got 'Too', 'many', 'conditions' and '!'. WHILE Too many conditions ! Fail Not executed! END Invalid condition - [Documentation] FAIL Invalid WHILE condition: \ + [Documentation] FAIL Invalid WHILE loop condition: \ ... Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module WHILE bad Fail Not executed! END Non-existing ${variable} in condition - [Documentation] FAIL Invalid WHILE condition: \ + [Documentation] FAIL Invalid WHILE loop condition: \ ... Evaluating expression '\${bad} > 0' failed: Variable '\${bad}' not found. WHILE ${bad} > 0 Fail Not executed! END Non-existing $variable in condition - [Documentation] FAIL Invalid WHILE condition: \ + [Documentation] FAIL Invalid WHILE loop condition: \ ... Evaluating expression '$bad > 0' failed: Variable '$bad' not found. WHILE $bad > 0 Fail Not executed! END Recommend $var syntax if invalid condition contains ${var} - [Documentation] FAIL Invalid WHILE condition: \ + [Documentation] FAIL Invalid WHILE loop condition: \ ... Evaluating expression 'x == 'x'' failed: NameError: name 'x' is not defined nor importable as module ... ... Variables in the original expression '\${x} == 'x'' were resolved before the expression was evaluated. \ @@ -38,7 +38,7 @@ Recommend $var syntax if invalid condition contains ${var} END Invalid condition on second round - [Documentation] FAIL Invalid WHILE condition: \ + [Documentation] FAIL Invalid WHILE loop condition: \ ... Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module ... ... Variables in the original expression '\${condition}' were resolved before the expression was evaluated. \ @@ -76,7 +76,7 @@ Invalid condition causes normal error WHILE bad Fail Should not be run END - EXCEPT Invalid WHILE condition: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module + EXCEPT Invalid WHILE loop condition: Evaluating expression 'bad' failed: NameError: name 'bad' is not defined nor importable as module No Operation END @@ -85,6 +85,6 @@ Non-existing variable in condition causes normal error WHILE ${bad} Fail Should not be run END - EXCEPT Invalid WHILE condition: Evaluating expression '\${bad}' failed: Variable '\${bad}' not found. + EXCEPT Invalid WHILE loop condition: Evaluating expression '\${bad}' failed: Variable '\${bad}' not found. No Operation END diff --git a/atest/testdata/running/while/on_limit.robot b/atest/testdata/running/while/on_limit.robot index 5006272ab87..25a4d7e6815 100644 --- a/atest/testdata/running/while/on_limit.robot +++ b/atest/testdata/running/while/on_limit.robot @@ -67,7 +67,7 @@ Invalid on_limit END On limit without limit defined - [Documentation] FAIL WHILE on_limit option cannot be used without limit. + [Documentation] FAIL WHILE loop 'on_limit' option cannot be used without 'limit'. WHILE True on_limit=PaSS No Operation END @@ -85,7 +85,7 @@ On limit message without limit END Wrong WHILE argument - [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limit=5' and 'limit_exceed_messag=Custom error message'. + [Documentation] FAIL WHILE loop cannot have more than one condition, got '$variable < 2', 'limit=5' and 'limit_exceed_messag=Custom error message'. WHILE $variable < 2 limit=5 limit_exceed_messag=Custom error message Log ${variable} END @@ -135,7 +135,7 @@ On limit message with invalid variable END Wrong WHILE arguments - [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limite=5' and 'limit_exceed_messag=Custom error message'. + [Documentation] FAIL WHILE loop cannot have more than one condition, got '$variable < 2', 'limite=5' and 'limit_exceed_messag=Custom error message'. WHILE $variable < 2 limite=5 limit_exceed_messag=Custom error message Log ${variable} END diff --git a/atest/testdata/running/while/while_limit.robot b/atest/testdata/running/while/while_limit.robot index a913ace6b65..384fb09cde9 100644 --- a/atest/testdata/running/while/while_limit.robot +++ b/atest/testdata/running/while/while_limit.robot @@ -100,8 +100,8 @@ Invalid limit invalid value END Invalid limit mistyped prefix - [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2' and 'limitation=-1x'. - WHILE $variable < 2 limitation=-1x + [Documentation] FAIL WHILE loop cannot have more than one condition, got '$variable < 2' and 'limitation=2'. + WHILE $variable < 2 limitation=2 Log ${variable} END @@ -112,8 +112,8 @@ Limit used multiple times END Invalid values after limit - [Documentation] FAIL WHILE cannot have more than one condition, got '$variable < 2', 'limit=-1x', 'invalid' and 'values'. - WHILE $variable < 2 limit=-1x invalid values + [Documentation] FAIL WHILE loop cannot have more than one condition, got '$variable < 2', 'limit=2' and 'invalid'. + WHILE $variable < 2 limit=2 invalid Log ${variable} END diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 5d13e52168b..20201ee9c29 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -980,11 +980,12 @@ def assign(self) -> 'tuple[str, ...]': return self.get_values(Token.ASSIGN) def validate(self, ctx: 'ValidationContext'): - conditions = len(self.get_tokens(Token.ARGUMENT)) - if conditions == 0: + conditions = self.get_tokens(Token.ARGUMENT) + if not conditions: self.errors += (f'{self.type} must have a condition.',) - if conditions > 1: - self.errors += (f'{self.type} cannot have more than one condition.',) + if len(conditions) > 1: + self.errors += (f'{self.type} cannot have more than one condition, ' + f'got {seq2str(c.value for c in conditions)}.',) @Statement.register @@ -1181,13 +1182,17 @@ def on_limit_message(self) -> 'str|None': return self.get_option('on_limit_message') def validate(self, ctx: 'ValidationContext'): - values = self.get_values(Token.ARGUMENT) - if len(values) > 1: - self.errors += (f'WHILE cannot have more than one condition, got {seq2str(values)}.',) + conditions = self.get_tokens(Token.ARGUMENT) + if len(conditions) > 1: + self._add_error(f'cannot have more than one condition, got ' + f'{seq2str(c.value for c in conditions)}') if self.on_limit and not self.limit: - self.errors += ('WHILE on_limit option cannot be used without limit.',) + self._add_error("'on_limit' option cannot be used without 'limit'") self._validate_options() + def _add_error(self, error: str): + self.errors += (f'WHILE loop {error}.',) + @Statement.register class ReturnStatement(Statement): diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index f6c3d9581a7..2d564d2f912 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -439,7 +439,7 @@ def _should_run(self, condition, variables): resolve_variables=True) except Exception: msg = get_error_message() - raise DataError(f'Invalid WHILE condition: {msg}') + raise DataError(f'Invalid WHILE loop condition: {msg}') class IfRunner: diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 45e30794794..a538732b4dc 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -441,7 +441,8 @@ def test_invalid(self): Token(Token.ARGUMENT, 'many', 3, 20), Token(Token.ARGUMENT, 'values', 3, 28), Token(Token.ARGUMENT, '!', 3, 38)], - errors=('WHILE cannot have more than one condition, got \'too\', \'many\', \'values\' and \'!\'.',) + errors=("WHILE loop cannot have more than one condition, " + "got 'too', 'many', 'values' and '!'.",) ), end=End([ Token(Token.END, 'END', 5, 4) From 4be7b8883edc11a9538e8cbe0f9145ae16281b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 5 May 2023 17:25:57 +0300 Subject: [PATCH 0540/1592] Use ABC in more places to make base classes explicit --- src/robot/parsing/model/statements.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 20201ee9c29..19598e100ca 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -210,7 +210,7 @@ def __repr__(self) -> str: return f'{name}({tokens}{errors})' -class DocumentationOrMetadata(Statement): +class DocumentationOrMetadata(Statement, ABC): @property def value(self) -> str: @@ -269,7 +269,7 @@ def _has_trailing_backslash_or_newline(self, line: str) -> bool: return bool(match and len(match.group(1)) % 2 == 1) -class SingleValue(Statement): +class SingleValue(Statement, ABC): @property def value(self) -> 'str|None': @@ -279,14 +279,14 @@ def value(self) -> 'str|None': return None -class MultiValue(Statement): +class MultiValue(Statement, ABC): @property def values(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) -class Fixture(Statement): +class Fixture(Statement, ABC): @property def name(self) -> str: From bd0b582143452f509f7c75afc3ad9e381b058c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 5 May 2023 17:33:08 +0300 Subject: [PATCH 0541/1592] Small enhancements to external parsing API documentation (#1283) --- .../ParserInterface.rst | 22 +++++++++---------- src/robot/api/interfaces.py | 21 ++++++++++++++++-- src/robot/running/__init__.py | 16 +++++++++----- src/robot/running/builder/settings.py | 2 +- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst index 892762f3de8..78790c28c9c 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst @@ -1,7 +1,7 @@ Parser interface ================ -Robot Framework supports custom parsers that can handle custom data formats or +Robot Framework supports external parsers that can handle custom data formats or even override Robot Framework's own parser. .. note:: Custom parsers are new in Robot Framework 6.1. @@ -13,10 +13,10 @@ even override Robot Framework's own parser. Taking parsers into use ----------------------- -Parsers are taken into use from the command line using the :option:`--parser` +Parsers are taken into use from the command line with the :option:`--parser` option using exactly the same semantics as with listeners__. This includes -specifying a parser as a name or as a path, how arguments can be given to -parser classes, and so on:: +specifying parsers as names or paths, giving arguments to parser classes, and +so on:: robot --parser MyParser tests.custom robot --parser path/to/MyParser.py tests.custom @@ -37,7 +37,8 @@ This attribute specifies what file extension or extensions the parser supports. Both `EXTENSION` and `extension` names are accepted, and the former has precedence if both exist. That attribute can be either a string or a sequence of strings. Extensions are case-insensitive and can be specified with or without the leading -dot. +dot. If a parser is implemented as a class, it is possible to set this attribute +either as a class attribute or as an instance attribute. If a parser supports the :file:`.robot` extension, it will be used for parsing these files instead of the standard parser. @@ -62,7 +63,7 @@ accept two arguments. In that case the second argument is a TestDefaults_ object ~~~~~~~~~~~~~~~~~~~ The optional `parse_init` method is responsible for parsing `suite initialization -files`_ i.e. files in in format `__init__.ext` where `.ext` is an extension +files`_ i.e. files in format `__init__.ext` where `.ext` is an extension supported by the parser. The method must return a `TestSuite <running.TestSuite_>`__ object representing the whole directory. Suites created from child suite files and directories will be added to its child suites. @@ -72,8 +73,7 @@ depending on is it interested in test related default values or not. If it accepts defaults, it can manipulate the passed TestDefaults_ object and changes are seen when parsing child suite files. -This method is optional and only needed if a parser needs to support suite -initialization files. +This method is only needed if a parser needs to support suite initialization files. Optional base class ~~~~~~~~~~~~~~~~~~~ @@ -173,10 +173,10 @@ initialization files, uses TestDefaults_ and registers multiple extensions. Parser as preprocessor ~~~~~~~~~~~~~~~~~~~~~~ -The final parser acts as a preprocessor for Robot Framework data files that -supports headers in format `=== Test Cases ===` in addition to +The final example parser acts as a preprocessor for Robot Framework data files +that supports headers in format `=== Test Cases ===` in addition to `*** Test Cases ***`. In this kind of usage it is convenient to use -`TestSuite.from_string`__, `TestSuite.from_model`__ or +`TestSuite.from_string`__, `TestSuite.from_model`__ and `TestSuite.from_file_system`__ factory methods for constructing the returned suite. .. sourcecode:: python diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index b0f05cf66b5..199fbbd23d9 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -21,7 +21,9 @@ - :class:`HybridLibrary` for libraries using the `hybrid library API`__. - :class:`ListenerV2` for `listener interface version 2`__. - :class:`ListenerV3` for `listener interface version 3`__. -- :class:`Parser` for `custom parsers`__. +- :class:`Parser` for `custom parsers`__. Also + :class:`~robot.running.builder.settings.TestDefaults` used in ``Parser`` + type hints can be imported via this module if needed. - Type definitions used by the aforementioned classes. Main benefit of using these base classes is that editors can provide automatic @@ -35,7 +37,7 @@ .. note:: Using this module requires having the typing_extensions__ module installed when using Python 3.6 or 3.7. -New in Robot Framework 6.1. +This module is new in Robot Framework 6.1. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dynamic-library-api __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#hybrid-library-api @@ -586,6 +588,21 @@ class Parser(ABC): attribute can also be named ``EXTENSION``, which typically works better when a parser is implemented as a module. + Example:: + + from pathlib import Path + from robot.api import TestSuite + from robot.api.interfaces import Parser, TestDefaults + + + class ExampleParser(Parser): + extension = '.example' + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + suite = TestSuite(TestSuite.name_from_source(source), source=source) + # parse the source file and add tests to the created suite + return suite + The support for custom parsers is new in Robot Framework 6.1. """ extension: Union[str, Sequence[str]] diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index 18d1be21d77..551a54d9027 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -27,13 +27,19 @@ classmethod that uses it internally. * Classes used by :class:`~robot.running.model.TestSuite`, such as - :class:`~robot.running.model.TestCase` and :class:`~robot.running.model.Keyword`, - that are defined in the :mod:`robot.running.model` module. + :class:`~robot.running.model.TestCase`, :class:`~robot.running.model.Keyword` + and :class:`~robot.running.model.If` that are defined in the + :mod:`robot.running.model` module. These classes are typically only needed + in type hints. + +* :class:`~robot.running.builder.settings.TestDefaults` that is part of the + `external parsing API`__ and also typically needed only in type hints. + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#parser-interface :class:`~robot.running.model.TestSuite` and -:class:`~robot.running.builder.builders.TestSuiteBuilder` can be imported via -the :mod:`robot.api` package. If other classes are needed directly, they can be -imported via :mod:`robot.running`. +:class:`~robot.running.builder.builders.TestSuiteBuilder` can be imported also via +the :mod:`robot.api` package. .. note:: Prior to Robot Framework 6.1, only some classes in :mod:`robot.running.model` were exposed via :mod:`robot.running`. diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index ad596265393..6b08ca4984d 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -47,7 +47,7 @@ class TestDefaults: This class is part of the `public parser API`__. When implementing ``parse`` or ``parse_init`` method so that they accept two arguments, the second is an instance of this class. If the class is needed as a type hint, it can - be imported via ``robot.running` or `robot.api.interfaces``. + be imported via :mod:`robot.running` or :mod:`robot.api.interfaces`. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#parser-interface """ From 8f637508b9a8d2fa1ddc872780689ad8a18aedf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 5 May 2023 21:06:23 +0300 Subject: [PATCH 0542/1592] Add missing return type hint to Visitor.start_xxx methods. --- src/robot/model/visitor.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 9cb21fc08e6..cafe68a4fef 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -134,7 +134,7 @@ def visit_suite(self, suite: 'TestSuite'): suite.teardown.visit(self) self.end_suite(suite) - def start_suite(self, suite: 'TestSuite'): + def start_suite(self, suite: 'TestSuite') -> 'bool|None': """Called when a suite starts. Default implementation does nothing. Can return explicit ``False`` to stop visiting. @@ -159,7 +159,7 @@ def visit_test(self, test: 'TestCase'): test.teardown.visit(self) self.end_test(test) - def start_test(self, test: 'TestCase'): + def start_test(self, test: 'TestCase') -> 'bool|None': """Called when a test starts. Default implementation does nothing. Can return explicit ``False`` to stop visiting. @@ -190,7 +190,7 @@ def _possible_teardown(self, item: 'BodyItem'): if getattr(item, 'has_teardown', False): item.teardown.visit(self) # type: ignore - def start_keyword(self, keyword: 'Keyword'): + def start_keyword(self, keyword: 'Keyword') -> 'bool|None': """Called when a keyword starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -216,7 +216,7 @@ def visit_for(self, for_: 'For'): for_.body.visit(self) self.end_for(for_) - def start_for(self, for_: 'For'): + def start_for(self, for_: 'For') -> 'bool|None': """Called when a FOR loop starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -246,7 +246,7 @@ def visit_for_iteration(self, iteration: 'ForIteration'): iteration.body.visit(self) self.end_for_iteration(iteration) - def start_for_iteration(self, iteration: 'ForIteration'): + def start_for_iteration(self, iteration: 'ForIteration') -> 'bool|None': """Called when a FOR loop iteration starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -276,7 +276,7 @@ def visit_if(self, if_: 'If'): if_.body.visit(self) self.end_if(if_) - def start_if(self, if_: 'If'): + def start_if(self, if_: 'If') -> 'bool|None': """Called when an IF/ELSE structure starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -302,7 +302,7 @@ def visit_if_branch(self, branch: 'IfBranch'): branch.body.visit(self) self.end_if_branch(branch) - def start_if_branch(self, branch: 'IfBranch'): + def start_if_branch(self, branch: 'IfBranch') -> 'bool|None': """Called when an IF/ELSE branch starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -328,7 +328,7 @@ def visit_try(self, try_: 'Try'): try_.body.visit(self) self.end_try(try_) - def start_try(self, try_: 'Try'): + def start_try(self, try_: 'Try') -> 'bool|None': """Called when a TRY/EXCEPT structure starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -350,7 +350,7 @@ def visit_try_branch(self, branch: 'TryBranch'): branch.body.visit(self) self.end_try_branch(branch) - def start_try_branch(self, branch: 'TryBranch'): + def start_try_branch(self, branch: 'TryBranch') -> 'bool|None': """Called when TRY, EXCEPT, ELSE or FINALLY branches start. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -376,7 +376,7 @@ def visit_while(self, while_: 'While'): while_.body.visit(self) self.end_while(while_) - def start_while(self, while_: 'While'): + def start_while(self, while_: 'While') -> 'bool|None': """Called when a WHILE loop starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -406,7 +406,7 @@ def visit_while_iteration(self, iteration: 'WhileIteration'): iteration.body.visit(self) self.end_while_iteration(iteration) - def start_while_iteration(self, iteration: 'WhileIteration'): + def start_while_iteration(self, iteration: 'WhileIteration') -> 'bool|None': """Called when a WHILE loop iteration starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -428,7 +428,7 @@ def visit_return(self, return_: 'Return'): self._possible_body(return_) self.end_return(return_) - def start_return(self, return_: 'Return'): + def start_return(self, return_: 'Return') -> 'bool|None': """Called when a RETURN element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -450,7 +450,7 @@ def visit_continue(self, continue_: 'Continue'): self._possible_body(continue_) self.end_continue(continue_) - def start_continue(self, continue_: 'Continue'): + def start_continue(self, continue_: 'Continue') -> 'bool|None': """Called when a CONTINUE element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -472,7 +472,7 @@ def visit_break(self, break_: 'Break'): self._possible_body(break_) self.end_break(break_) - def start_break(self, break_: 'Break'): + def start_break(self, break_: 'Break') -> 'bool|None': """Called when a BREAK element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -498,7 +498,7 @@ def visit_error(self, error: 'Error'): self._possible_body(error) self.end_error(error) - def start_error(self, error: 'Error'): + def start_error(self, error: 'Error') -> 'bool|None': """Called when a ERROR element starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -523,7 +523,7 @@ def visit_message(self, message: 'Message'): if self.start_message(message) is not False: self.end_message(message) - def start_message(self, message: 'Message'): + def start_message(self, message: 'Message') -> 'bool|None': """Called when a message starts. By default, calls :meth:`start_body_item` which, by default, does nothing. @@ -539,7 +539,7 @@ def end_message(self, message: 'Message'): """ self.end_body_item(message) - def start_body_item(self, item: 'BodyItem'): + def start_body_item(self, item: 'BodyItem') -> 'bool|None': """Called, by default, when keywords, messages or control structures start. More specific :meth:`start_keyword`, :meth:`start_message`, `:meth:`start_for`, From bf98febd9a8ecd503cea381b8050da35bbdcd890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 5 May 2023 21:12:33 +0300 Subject: [PATCH 0543/1592] Release notes for 6.1b1 --- doc/releasenotes/rf-6.1b1.rst | 1189 +++++++++++++++++++++++++++++++++ 1 file changed, 1189 insertions(+) create mode 100644 doc/releasenotes/rf-6.1b1.rst diff --git a/doc/releasenotes/rf-6.1b1.rst b/doc/releasenotes/rf-6.1b1.rst new file mode 100644 index 00000000000..9ef22dff0d1 --- /dev/null +++ b/doc/releasenotes/rf-6.1b1.rst @@ -0,0 +1,1189 @@ +========================== +Robot Framework 6.1 beta 1 +========================== + +.. default-role:: code + +`Robot Framework`_ 6.1 is a new feature release with support for converting +Robot Framework data to JSON and back, a new external parser API, and various +other interesting new features both for normal users and for external tool +developers. This beta release is especially targeted for tool developers +interested to test the new APIs. It also contain all planned +`backwards incompatible changes`_ and `deprecated features`_, and everyone +interested to make sure their tests, tasks or tools are compatible, +should test it in their environment. + +All issues targeted for Robot Framework 6.1 can be found +from the `issue tracker milestone`_. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.1b1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.1 beta 1 was released on Friday May 5, 2023. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +JSON data format +---------------- + +The biggest new feature in Robot Framework 6.1 is the possibility to convert +test/task data to JSON and back (`#3902`_). This functionality has three main +use cases: + +- Transferring suites between processes and machines. A suite can be converted + to JSON in one machine and recreated somewhere else. +- Possibility to save a suite, possible a nested suite, constructed from data + on the file system into a single file that is faster to parse. +- Alternative data format for external tools generating tests or tasks. + +This feature is designed more for tool developers than for regular Robot Framework +users and we expect new interesting tools to emerge in the future. The feature +is not finalized yet, but the following things already work: + +1. You can serialize a suite structure into JSON by using `TestSuite.to_json`__ + method. When used without arguments, it returns JSON data as a string, but + it also accepts a path or an open file where to write JSON data along with + configuration options related to JSON formatting: + + .. sourcecode:: python + + from robot.api import TestSuite + + suite = TestSuite.from_file_system('path/to/tests') + suite.to_json('tests.rbt') + +2. You can create a suite based on JSON data using `TestSuite.from_json`__. + It works both with JSON strings and paths to JSON files: + + .. sourcecode:: python + + from robot.api import TestSuite + + suite = TestSuite.from_json('tests.rbt') + +3. When using the `robot` command normally, JSON files with the `.rbt` extension + are parsed automatically. This includes running individual JSON files like + `robot tests.rbt` and running directories containing `.rbt` files. + +We recommend everyone interested in this new functionality to test it and give +us feedback. It is a lot easier for us to make changes before the final release +is out and we need to take backwards compatibility into account. If you +encounter bugs or have enhancement ideas, you can comment the issue or start +discussion on the `#devel` channel on our Slack_. + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_json +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_json + +External parser API +------------------- + +The parser API is another important new interface targeted for tool developers +(`#1283`_). It makes it possible to create custom parsers that can handle their +own data formats or even override Robot Framework's own parser. + +Parsers are taken into use from the command like using the new `--parser` option +the same way as, for example, listeners. This includes specifying parsers as +names or paths, giving arguments to parser classes, and so on:: + + robot --parser MyParser tests.custom + robot --parser path/to/MyParser.py tests.custom + robot --parser Parser1:arg --parser Parser2:a1:a2 path/to/tests + +In simple cases parsers can be implemented as modules. They only thing they +need is an `EXTENSION` or `extension` attribute that specifies the extension +or extensions they support, and a `parse` method that gets the path of the +source file to parse as an argument: + +.. sourcecode:: python + + from robot.api import TestSuite + + EXTENSION = '.example' + + def parse(source): + suite = TestSuite(name='Example', source=source) + test = suite.tests.create(name='Test') + test.body.create_keyword(name='Log', args=['Hello!']) + return suite + +As the example demonstrates, the `parse` method must return a TestSuite__ +instance. In the above example the suite contains only some dummy data and +the source file is not actually parsed. + +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite + +Parsers can also be implemented as classes which makes it possible for them to +preserve state and allows passing arguments from the command like. The following +example illustrates that and, unlike the previous example, actually processes the +source file: + +.. sourcecode:: python + + from pathlib import Path + from robot.api import TestSuite + + + class ExampleParser: + + def __init__(self, extension: str): + self.extension = extension + + def parse(self, source: Path) -> TestSuite: + suite = TestSuite(TestSuite.name_from_source(source), source=source) + for line in source.read_text().splitlines(): + test = suite.tests.create(name=line) + test.body.create_keyword(name='Log', args=['Hello!']) + return suite + +As the earlier examples have demonstrated, parsers do not need to extend any +explicit base class or interface. There is, however, an optional Parser__ +base class that can be extended. The following example +does that and has also two other differences compared to earlier examples: + +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.api.html#robot.api.interfaces.Parser + +- The parser has optional `parse_init` file for parsing suite initialization files. +- Both `parse` and `parse_init` accept optional `defaults` argument. When this + second argument is present, the `parse` method gets a TestDefaults__ instance + that contains possible test related default values (setup, teardown, tags and + timeout) from initialization files. Also `parse_init` can get it and possible + changes are seen by subsequently called `parse` methods. + +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.builder.html#robot.running.builder.settings.TestDefaults + +.. sourcecode:: python + + from pathlib import Path + from robot.api import TestSuite + from robot.api.interfaces import Parser, TestDefaults + + + class ExampleParser(Parser): + extension = ('example', 'another') + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + """Create a suite and set possible defaults from init files to tests.""" + suite = TestSuite(TestSuite.name_from_source(source), source=source) + for line in source.read_text().splitlines(): + test = suite.tests.create(name=line, doc='Example') + test.body.create_keyword(name='Log', args=['Hello!']) + defaults.set_to(test) + return suite + + def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: + """Create a dummy suite and set some defaults. + + This method is called only if there is an initialization file with + a supported extension. + """ + defaults.tags = ('tags', 'from init') + defaults.setup = {'name': 'Log', 'args': ['Hello from init!']} + return TestSuite(TestSuite.name_from_source(source.parent), doc='Example', + source=source, metadata={'Example': 'Value'}) + +The final parser acts as a preprocessor for Robot Framework data files that +supports headers in format `=== Test Cases ===` in addition to +`*** Test Cases ***`. In this kind of usage it is convenient to use +`TestSuite.from_string`__, `TestSuite.from_model`__ or +`TestSuite.from_file_system`__ factory methods for constructing the returned suite. + +.. sourcecode:: python + + from pathlib import Path + from robot.running import TestDefaults, TestSuite + + class RobotPreprocessor: + extension = '.robot' + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + name = TestSuite.name_from_source(source) + data = source.read_text() + for header in 'Settings', 'Variables', 'Test Cases', 'Keywords': + data = data.replace(f'=== {header} ===', f'*** {header} ***') + return TestSuite.from_string(data, defaults=defaults).config(name=name) + +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_string +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_model +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_file_system + +User keywords with both embedded and normal arguments +----------------------------------------------------- + +User keywords can nowadays mix embedded arguments and normal arguments (`#4234`_). +For example, this kind of usage is possible: + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Number of horses is 2 + Number of dogs is 3 + + *** Keywords *** + Number of ${animals} is + [Arguments] ${count} + Log to console There are ${count} ${animals}. + +This only works with user keywords at least for now. If there is interest, +the support can be extended to library keywords in future releases. + +Possibility to flatten keyword structures during execution +---------------------------------------------------------- + +With nested keyword structures, especially with recursive keyword calls and with +WHILE and FOR loops, the log file can get hard do understand with many different +nesting levels. Such nested structures also increase the size of the output.xml +file. For example, even a simple keyword like: + +.. sourcecode:: robotframework + + *** Keywords *** + Example + Log Robot + Log Framework + +creates this much content in output.xml: + +.. sourcecode:: xml + + <kw name="Example"> + <kw name="Log" library="BuiltIn"> + <arg>Robot</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.663"/> + </kw> + <kw name="Log" library="BuiltIn"> + <arg>Framework</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + +We already have the `--flattenkeywords` option for "flattening" such structures +and it works great. When a keyword is flattened, its child keywords and control +structures are removed otherwise, but all their messages (`<msg>` elements) are +preserved. Using `--flattenkeywords` does not affect output.xml generated during +execution, but flattening happens when output.xml files are parsed and can save +huge amounts of memory. When `--flattenkeywords` is used with Rebot, it is +possible to create a new flattened output.xml. For example, the above structure +is converted into this if the `Example` keyword is flattened using `--flattenkeywords`: + +.. sourcecode:: xml + + <kw name="Keyword"> + <doc>_*Content flattened.*_</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + +Starting from Robot Framework 6.1, this kind of flattening can be done also +during execution and without using command line options. The only thing needed +is using the new keyword tag `robot:flatten` (`#4584`_) and flattening is done +automatically. For example, if the earlier `Keyword` is changed to: + +.. sourcecode:: robotframework + + *** Keywords *** + Example + [Tags] robot:flatten + Log Robot + Log Framework + +the result in output.xml will be this: + +.. sourcecode:: xml + + <kw name="Example"> + <tag>robot:flatten</tag> + <msg timestamp="20230317 00:54:34.772" level="INFO">Robot</msg> + <msg timestamp="20230317 00:54:34.772" level="INFO">Framework</msg> + <status status="PASS" starttime="20230317 00:54:34.771" endtime="20230317 00:54:34.772"/> + </kw> + +The main benefit of using `robot:flatten` instead of `--flattenkeywords` is that +it is used already during execution making the resulting output.xml file +smaller. `--flattenkeywords` has more configuration options than `robot:flatten`, +though, but `robot:flatten` can be enhanced in that regard later if there are +needs. + +Custom argument converters can access library +--------------------------------------------- + +Support for custom argument converters was added in Robot Framework 5.0 +(`#4088`__) and they have turned out to be really useful. This functionality +is now enhanced so that converters can easily get an access to the +library containing the keyword that is used and can thus do conversion +based on the library state (`#4510`_). This can be done simply by creating +a converter that accepts two values. The first value is the value used in +the data, exactly as earlier, and the second is the library instance or module: + +.. sourcecode:: python + + def converter(value, library): + ... + +Converters accepting only one argument keep working as earlier. There are no +plans to require changing them to accept two values. + +__ https://github.com/robotframework/robotframework/issues/4088 + +JSON variable file support +-------------------------- + +It has been possible to create variable files using YAML in addition to Python +for long time, and nowadays also JSON variable files are supported (`#4532`_). +For example, a JSON file containing: + +.. sourcecode:: json + + { + "STRING": "Hello, world!", + "INTEGER": 42 + } + +could be used like this: + +.. sourcecode:: robotframework + + *** Settings *** + Variables example.json + + *** Test Cases *** + Example + Should Be Equal ${STRING} Hello, world! + Should Be Equal ${INTEGER} ${42} + + +`WHILE` loop enhancements +------------------------- + +Robot Framework's WHILE__ loop has been enhanced in several different ways: + +- The biggest enhancement is that `WHILE` loops got an optional + `on_limit` configuration option that controls what to do if the configured + loop `limit` is reached (`#4562`_). By default execution fails, but setting + the option to `PASS` changes that. For example, the following loop runs ten + times and continues execution afterwards: + + .. sourcecode:: robotframework + + *** Test Cases *** + WHILE with 'limit' and 'on_limit' + WHILE True limit=10 on_limit=PASS + Log to console Hello! + END + Log to console Hello once more! + +- The loop condition is nowadays optional (`#4576`_). For example, the above + loop header could be simplified to this:: + + WHILE limit=10 on_limit=PASS + +- New `on_limit_message` configuration option can be used to set the message + that is used if the loop limit exceeds and the loop fails (`#4575`_). + +- A bug with the loop limit in teardowns has been fixed (`#4744`_). + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#while-loops + +`FOR IN ZIP` loop behavior if lists lengths differ can be configured +-------------------------------------------------------------------- + +Robot Framework's `FOR IN ZIP`__ loop behaves like Python's zip__ function so +that if lists lengths are not the same, items from longer ones are ignored. +For example, the following loop is executed only twice: + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#for-in-zip-loop +__ https://docs.python.org/3/library/functions.html#zip + +.. sourcecode:: robotframework + + *** Variables *** + @{ANIMALS} dog cat horse cow elephant + @{ELÄIMET} koira kissa + + *** Test Cases *** + Example + FOR ${en} ${fi} IN ZIP ${ANIMALS} ${ELÄIMET} + Log ${en} is ${fi} in Finnish + END + +This behavior can cause problems when iterating over items received from +the automated system. For example, the following test would pass regardless +how many things `Get something` returns as long as the returned items match +the expected values. The example succeeds if `Get something` returns ten items +if three first ones match. What's even worse, it succeeds also if `Get something` +returns nothing. + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Validate something expected 1 expected 2 expected 3 + + *** Keywords **** + Validate something + [Arguments] @{expected} + @{actual} = Get something + FOR ${act} ${exp} IN ZIP ${actual} ${expected} + Validate one thing ${act} ${exp} + END + +This situation is pretty bad because it can cause false positives where +automation succeeds but nothing is actually done. Python itself has this +same issue, and Python 3.10 added new optional `strict` argument to `zip` +(`PEP 681`__). In addition to that, Python has for long time had a separate +`zip_longest`__ function that loops over all values possibly filling-in +values to shorter lists. + +__ https://peps.python.org/pep-0618/ +__ https://docs.python.org/3/library/itertools.html#itertools.zip_longest + +To support the same features as Python, Robot Framework's `FOR IN ZIP` +loops now have an optional `mode` configuration option that accepts three +values (`#4682`_): + +- `STRICT`: Lists must have equal lengths. If not, execution fails. This is + the same as using `strict=True` with Python's `zip` function. +- `SHORTEST`: Items in longer lists are ignored. Infinitely long lists are supported + in this mode as long as one of the lists is exhausted. This is the current + default behavior. +- `LONGEST`: The longest list defines how many iterations there are. Missing + values in shorter lists are filled-in with value specified using the `fill` + option or `None` if it is not used. This is the same as using Python's + `zip_longest` function except that it has `fillvalue` argument instead of + `fill`. + +All these modes are illustrated by the following examples: + +.. sourcecode:: robotframework + + *** Variables *** + @{CHARACTERS} a b c d f + @{NUMBERS} 1 2 3 + + *** Test Cases *** + STRICT mode + [Documentation] This loop fails due to lists lengths being different. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=STRICT + Log ${c}: ${n} + END + + SHORTEST mode + [Documentation] This loop executes three times. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=SHORTEST + Log ${c}: ${n} + END + + LONGEST mode + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `None`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST + Log ${c}: ${n} + END + + LONGEST mode with custom fill value + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `-`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST fill=- + Log ${c}: ${n} + END + +This enhancement makes it easy to activate strict validation and avoid +false positives. The default behavior is still problematic, though, and +the plan is to change it to `STRICT` in `Robot Framework 7.0`__. +Those who want to keep using the `SHORTEST` mode need to enable it explicitly. + +__ https://github.com/robotframework/robotframework/issues/4686 + +New pseudo log level `CONSOLE` +------------------------------ + +There are often needs to log something to the console while tests or tasks +are running. Some keywords support it out-of-the-box and there is also +separate `Log To Console` keyword for that purpose. + +The new `CONSOLE` pseudo log level (`#4536`_) adds this support to *any* +keyword that accepts a log level such as `Log List` in Collections and +`Page Should Contain` in SeleniumLibrary. When this level is used, the message +is logged both to the console and on `INFO` level to the log file. + +Configuring virtual root suite when running multiple suites +----------------------------------------------------------- + +When execution multiple suites like `robot first.robot second.robot`, +Robot Framework creates a virtual root suite containing the executed +suites as child suites. Earlier this virtual suite could be +configured only by using command line options like `--name`, but now +it is possible to use normal suite initialization files (`__init__.robot`) +for that purpose (`#4015`_). If an initialization file is included +in the call like:: + + robot __init__.robot first.robot second.robot + +the root suite is configured based on data it contains. + +The most important feature this enhancement allows is specifying suite +setup and teardown to the root suite. Earlier that was not possible at all +when executing multiple suites like this. + +Support for asynchronous functions and methods as keywords +---------------------------------------------------------- + +It is nowadays possible to run use asynchronous functions (created using +`async def`) as keywords just like normal functions (`#4089`_). For example, +the following async functions could be used as keyword `Gather Something` and +`Async Sleep`: + +.. sourcecode:: python + + from asyncio import gather, sleep + + async def gather_something(): + print('start') + await gather(something(1), something(2), something(3)) + print('done') + + async def async_sleep(time: int): + await sleep(time) + +`zipapp` compatibility +---------------------- + +Robot Framework 6.1 is compatible with zipapp__ (`#4613`_). This makes it possible +to create standalone distributions using either only the `zipapp` module or +with a help from an external packaging tool like PDM__. + +__ https://docs.python.org/3/library/zipapp.html +__ https://pdm.fming.dev + +Backwards incompatible changes +============================== + +We try to avoid backwards incompatible changes in general and especially in +non-major version. They cannot always be avoided, though, and there are some +features and fixes in this release that are not fully backwards compatible. +These changes *should not* cause problems in normal usage, but especially +tools using Robot Framework may nevertheless be affected. + +Changes to output.xml +--------------------- + +Syntax errors such as invalid settings like `[Setpu]` or `END` in a wrong place +are nowadays reported better (`#4683`_). Part of that change was storing +invalid constructs in output.xml as `<error>` elements. Tools processing +output.xml files so that they go through all elements need to take `<error>` +elements into account, but tools just querying information using xpath +expression or otherwise should not be affected. + +Another change is that with `FOR IN ENUMERATE` loops the `<for>` element +may get `start` attribute (`#4684`_) and with `FOR IN ZIP` loops it may get +`mode` and `fill` attributes (`#4682`_). This affects tools processing +all possible attributes, but such tools ought to be very rare. + +Changes to `TestSuite` model structure +-------------------------------------- + +The aforementioned enhancements for handling invalid syntax better (`#4683`_) +required changes also to the TestSuite__ model structure. Syntax errors are +nowadays represented as Error__ objects and they can appear in the `body` of +TestCase__, Keyword__, and other such model objects. Tools interacting with +the `TestSuite` structure should take `Error` objects into account, but tools +using the `visitor API`__ should in general not be affected. + +Another related change is that `doc`, `tags`, `timeout` and `teardown` attributes +were removed from the `robot.running.Keyword`__ object (`#4589`_). They were +left there accidentally and were not used for anything by Robot Framework. +Tools accessing them need to be updated. + +Finally, the `TestSuite.source`__ attribute is nowadays a `pathlib.Path`__ +instance instead of a string (`#4596`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testsuite.TestSuite +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.control.Error +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testcase.TestCase +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.keyword.Keyword +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#module-robot.model.visitor +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.Keyword +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testsuite.TestSuite.source +__ https://docs.python.org/3/library/pathlib.html + +Changes to parsing model +------------------------ + +Invalid section headers like `*** Bad ***` are nowadays represented in the +parsing model as InvalidSection__ objects when they earlier were generic +Error__ objects (`#4689`_). + +New ReturnSetting__ object has been introduced as an alias for Return__. +This does not yet change anything, but in the future `Return` will be used +for other purposes and tools using it should be updated to use `ReturnSetting` +instead (`#4656`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.blocks.InvalidSection +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Error +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Return +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.ReturnSetting + +Changes to Libdoc spec files +---------------------------- + +Libdoc did not handle parameterized types like `list[int]` properly earlier. +Fixing that problem required storing information about nested types into +the spec files along with the top level type. In addition to the parameterized +types, also unions are now handled differently than earlier, but with normal +types there are no changes. With JSON spec files changes were pretty small, +but XML spec files required a bit bigger changes. What exactly was changed +and how is explained in comments of issue `#4538`_. + +Argument conversion changes +--------------------------- + +If an argument has multiple types, Robot Framework tries to do argument +conversion with all of them, from left to right, until one of them succeeds. +Earlier if a type was not recognized at all, the used value was returned +as-is without trying conversion with the remaining types. For example, if +a keyword like: + +.. sourcecode:: python + + def example(arg: Union[UnknownType, int]): + ... + +would be called like:: + + Example 42 + +the integer conversion would not be attempted and the keyword would get +string `42`. This was changed so that unrecognized types are just skipped +and in the above case integer conversion is nowadays done (`#4648`_). That +obviously changes the value the keyword gets to an integer. + +Another argument conversion change is that the `Any` type is now recognized +so that any value is accepted without conversion (`#4647`_). This change is +mostly backwards compatible, but in a special case where such an argument has +a default value like `arg: Any = 1` the behavior changes. Earlier when `Any` +was not recognized at all, conversion was attempted based on the default value +type. Nowadays when `Any` is recognized and explicitly not converted, +no conversion based on the default value is done either. The behavior change +can be avoided by using `arg: Union[int, Any] = 1` which is much better +typing in general. + +Changes affecting execution +--------------------------- + +Invalid settings in tests and keywords like `[Tasg]` are nowadays considered +syntax errors that cause failures at execution time (`#4683`_). They were +reported also earlier, but they did not affect execution. + +All invalid sections in resource files are considered to be syntax errors that +prevent importing the resource file (`#4689`_). Earlier having a `*** Test Cases ***` +header in a resource file caused such an error, but other invalid headers were +just reported as errors but imports succeeded. + +Deprecated features +=================== + +Python 3.7 support +------------------ + +Python 3.7 will reach its end-of-life in `June 2023`__. We have decided to +support it with Robot Framework 6.1 and subsequent 6.x releases, but +Robot Framework 7.0 will not support it anymore (`#4637`_). + +We have already earlier deprecated Python 3.6 that reached its end-of-life +already in `December 2021`__ the same way. The reason we still support it +is that it is the default Python version in Red Hat Enterprise Linux 8 +that is still `actively supported`__. + +__ https://peps.python.org/pep-0537/ +__ https://peps.python.org/pep-0494/ +__ https://endoflife.date/rhel + +Old elements in Libdoc spec files +--------------------------------- + +Libdoc spec files have been enhanced in latest releases. For backwards +compatibility reasons old information has been preserved, but all such data +will be removed in Robot Framework 7.0. For more details about what will be +removed see issue `#4667`__. + +__ https://github.com/robotframework/robotframework/issues/4667 + +Other deprecated features +------------------------- + +- Return__ node in the parsing model has been deprecated and ReturnSetting__ + should be used instead (`#4656`_). +- `name` argument of `TestSuite.from_model`__ has been deprecated and will be + removed in the future (`#4598`_). +- `accept_plain_values` argument of `robot.utils.timestr_to_secs` has been + deprecated and will be removed in the future (`#4522`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_model +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Return +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.ReturnSetting + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its ~50 member organizations. If your organization is using Robot Framework +and benefiting from it, consider joining the foundation to support its +development as well. + +Robot Framework 6.1 team funded by the foundation consists of +`Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the community has provided several great contributions: + +- `@sunday2 <https://github.com/sunday2>`__ implemented JSON variable file support + (`#4532`_) and fixed User Guide generation on Windows (`#4680`_). + +- `Tatu Aalto <https://github.com/aaltat>`__ added positional-only argument + support to the dynamic library API (`#4660`_). + +- `@otemek <https://github.com/otemek>`__ implemented possibility to give + a custom name to a suite using a new `Name` setting (`#4583`_). + +- `@franzhaas <https://github.com/franzhaas>`__ made Robot Framework + `zipapp <https://docs.python.org/3/library/zipapp.html>`__ compatible (`#4613`_). + +- `Ygor Pontelo <https://github.com/ygorpontelo>`__ added support for using + asynchronous functions and methods as keywords (`#4089`_). + +- `@asaout <https://github.com/asaout>`__ added `on_limit_message` option to WHILE + loops to control the failure message used if the loop limit is exceeded (`#4575`_). + +- `@turunenm <https://github.com/turunenm>`__ implemented `CONSOLE` pseudo log level + (`#4536`_). + +- `@Vincema <https://github.com/Vincema>`__ added support for long command line + options with hyphens like `--pre-run-modifier` (`#4547`_). + +Big thanks to Robot Framework Foundation for the continued support, to community +members listed above for their valuable contributions, and to everyone else who +has submitted bug reports, proposed enhancements, debugged problems, or otherwise +helped to make Robot Framework 6.1 such a great release! + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#1283`_ + - enhancement + - critical + - Possibility to use custom parser for test data + - beta 1 + * - `#3902`_ + - enhancement + - critical + - Support serializing executable suite into JSON + - alpha 1 + * - `#4234`_ + - enhancement + - critical + - Support user keywords with both embedded and normal arguments + - alpha 1 + * - `#4705`_ + - bug + - high + - Items are not converted when using generics like `list[int]` and passing object, not string + - beta 1 + * - `#4744`_ + - bug + - high + - WHILE limit doesn't work in teardown + - beta 1 + * - `#4015`_ + - enhancement + - high + - Support configuring virtual suite created when running multiple suites with `__init__.robot` + - alpha 1 + * - `#4089`_ + - enhancement + - high + - Support asynchronous functions and methods as keywords + - beta 1 + * - `#4510`_ + - enhancement + - high + - Make it possible for custom converters to get access to the library + - alpha 1 + * - `#4532`_ + - enhancement + - high + - JSON variable file support + - alpha 1 + * - `#4536`_ + - enhancement + - high + - Add new pseudo log level `CONSOLE` that logs to console and to log file + - alpha 1 + * - `#4562`_ + - enhancement + - high + - Possibility to continue execution after WHILE limit is reached + - beta 1 + * - `#4584`_ + - enhancement + - high + - New `robot:flatten` tag for "flattening" keyword structures + - alpha 1 + * - `#4613`_ + - enhancement + - high + - Make Robot Framework compatible with `zipapp` + - beta 1 + * - `#4637`_ + - enhancement + - high + - Deprecate Python 3.7 + - alpha 1 + * - `#4682`_ + - enhancement + - high + - Make `FOR IN ZIP` loop behavior if lists have different lengths configurable + - alpha 1 + * - `#4538`_ + - bug + - medium + - Libdoc doesn't handle parameterized types like `list[int]` properly + - alpha 1 + * - `#4571`_ + - bug + - medium + - Suite setup and teardown are executed even if all tests are skipped + - alpha 1 + * - `#4589`_ + - bug + - medium + - Remove unused attributes from `robot.running.Keyword` model object + - alpha 1 + * - `#4604`_ + - bug + - medium + - Listeners do not get source information for keywords executed with `Run Keyword` + - alpha 1 + * - `#4626`_ + - bug + - medium + - Inconsistent argument conversion when using `None` as default value with Python 3.11 and earlier + - alpha 1 + * - `#4635`_ + - bug + - medium + - Dialogs created by `Dialogs` on Windows don't have focus + - alpha 1 + * - `#4648`_ + - bug + - medium + - Argument conversion should be attempted with all possible types even if some type wouldn't be recognized + - alpha 1 + * - `#4670`_ + - bug + - medium + - Parsing model: `Documentation.from_params(...).value` doesn't work + - beta 1 + * - `#4680`_ + - bug + - medium + - User Guide generation broken on Windows + - alpha 1 + * - `#4689`_ + - bug + - medium + - Invalid sections are not represented properly in parsing model + - alpha 1 + * - `#4692`_ + - bug + - medium + - `ELSE IF` condition not passed to listeners + - alpha 1 + * - `#4695`_ + - bug + - medium + - Accessing `id` property of model objects may cause `ValueError` + - beta 1 + * - `#4716`_ + - bug + - medium + - Variable nodes with nested variables report a parsing error, but work properly in the runtime + - beta 1 + * - `#4756`_ + - bug + - medium + - Failed keywords inside skipped tests are not expanded even if they match `--expandkeywords` + - beta 1 + * - `#4210`_ + - enhancement + - medium + - Enhance error detection at parsing time + - alpha 1 + * - `#4547`_ + - enhancement + - medium + - Support long command line options with hyphens like `--pre-run-modifier` + - alpha 1 + * - `#4567`_ + - enhancement + - medium + - Add optional typed base class for dynamic library API + - alpha 1 + * - `#4568`_ + - enhancement + - medium + - Add optional typed base classes for listener API + - alpha 1 + * - `#4569`_ + - enhancement + - medium + - Add type information to the visitor API + - alpha 1 + * - `#4575`_ + - enhancement + - medium + - Add `on_limit_message` option on the WHILE loop + - beta 1 + * - `#4576`_ + - enhancement + - medium + - Make the WHILE loop condition optional + - beta 1 + * - `#4583`_ + - enhancement + - medium + - Possibility to give a custom name to a suite using `Name` setting + - beta 1 + * - `#4601`_ + - enhancement + - medium + - Add `robot.running.TestSuite.from_string` method + - alpha 1 + * - `#4647`_ + - enhancement + - medium + - Add explicit argument converter for `Any` that does no conversion + - alpha 1 + * - `#4660`_ + - enhancement + - medium + - Dynamic API: Support positional-only arguments + - beta 1 + * - `#4666`_ + - enhancement + - medium + - Add public API to query is Robot running and is dry-run active + - alpha 1 + * - `#4676`_ + - enhancement + - medium + - Propose using `$var` syntax if evaluation IF or WHILE condition using `${var}` fails + - alpha 1 + * - `#4683`_ + - enhancement + - medium + - Report syntax errors better in log file + - alpha 1 + * - `#4684`_ + - enhancement + - medium + - Handle start index with `FOR IN ENUMERATE` loops already in parser + - alpha 1 + * - `#4729`_ + - enhancement + - medium + - Leading and internal spaces should be preserved in documentation + - beta 1 + * - `#4740`_ + - enhancement + - medium + - Add type hints to parsing API + - beta 1 + * - `#4627`_ + - --- + - medium + - Support custom converters that accept only `*varargs` + - beta 1 + * - `#4611`_ + - bug + - low + - Some unit tests cannot be run independently + - alpha 1 + * - `#4634`_ + - bug + - low + - Dialogs created by `Dialogs` are not centered and their minimum size is too small + - alpha 1 + * - `#4638`_ + - bug + - low + - (:lady_beetle:) Using bare `Union` as annotation is not handled properly + - alpha 1 + * - `#4646`_ + - bug + - low + - (🐞) Bad error message when function is annotated with an empty tuple `()` + - alpha 1 + * - `#4663`_ + - bug + - low + - `BuiltIn.Log` documentation contains a defect + - alpha 1 + * - `#4736`_ + - bug + - low + - Backslash preventing newline in documentation can form escape sequence like `\n` + - beta 1 + * - `#4749`_ + - bug + - low + - Process: `Split/Join Command Line` do not work propertly with `pathlib.Path` objects + - beta 1 + * - `#4522`_ + - enhancement + - low + - Deprecate `accept_plain_values` argument used by `timestr_to_secs` + - alpha 1 + * - `#4596`_ + - enhancement + - low + - Make `TestSuite.source` attribute `pathlib.Path` instance + - alpha 1 + * - `#4598`_ + - enhancement + - low + - Deprecate `name` argument of `TestSuite.from_model` + - alpha 1 + * - `#4619`_ + - enhancement + - low + - Dialogs created by `Dialogs` should bind `Enter` key to `OK` button + - alpha 1 + * - `#4636`_ + - enhancement + - low + - Buttons in dialogs created by `Dialogs` should get keyboard shortcuts + - alpha 1 + * - `#4656`_ + - enhancement + - low + - Deprecate `Return` node in parsing model + - alpha 1 + * - `#4709`_ + - enhancement + - low + - Add `__repr__()` method to NormalizedDict + - beta 1 + +Altogether 61 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1>`__. + +.. _#1283: https://github.com/robotframework/robotframework/issues/1283 +.. _#3902: https://github.com/robotframework/robotframework/issues/3902 +.. _#4234: https://github.com/robotframework/robotframework/issues/4234 +.. _#4705: https://github.com/robotframework/robotframework/issues/4705 +.. _#4744: https://github.com/robotframework/robotframework/issues/4744 +.. _#4015: https://github.com/robotframework/robotframework/issues/4015 +.. _#4089: https://github.com/robotframework/robotframework/issues/4089 +.. _#4510: https://github.com/robotframework/robotframework/issues/4510 +.. _#4532: https://github.com/robotframework/robotframework/issues/4532 +.. _#4536: https://github.com/robotframework/robotframework/issues/4536 +.. _#4562: https://github.com/robotframework/robotframework/issues/4562 +.. _#4584: https://github.com/robotframework/robotframework/issues/4584 +.. _#4613: https://github.com/robotframework/robotframework/issues/4613 +.. _#4637: https://github.com/robotframework/robotframework/issues/4637 +.. _#4682: https://github.com/robotframework/robotframework/issues/4682 +.. _#4538: https://github.com/robotframework/robotframework/issues/4538 +.. _#4571: https://github.com/robotframework/robotframework/issues/4571 +.. _#4589: https://github.com/robotframework/robotframework/issues/4589 +.. _#4604: https://github.com/robotframework/robotframework/issues/4604 +.. _#4626: https://github.com/robotframework/robotframework/issues/4626 +.. _#4635: https://github.com/robotframework/robotframework/issues/4635 +.. _#4648: https://github.com/robotframework/robotframework/issues/4648 +.. _#4670: https://github.com/robotframework/robotframework/issues/4670 +.. _#4680: https://github.com/robotframework/robotframework/issues/4680 +.. _#4689: https://github.com/robotframework/robotframework/issues/4689 +.. _#4692: https://github.com/robotframework/robotframework/issues/4692 +.. _#4695: https://github.com/robotframework/robotframework/issues/4695 +.. _#4716: https://github.com/robotframework/robotframework/issues/4716 +.. _#4756: https://github.com/robotframework/robotframework/issues/4756 +.. _#4210: https://github.com/robotframework/robotframework/issues/4210 +.. _#4547: https://github.com/robotframework/robotframework/issues/4547 +.. _#4567: https://github.com/robotframework/robotframework/issues/4567 +.. _#4568: https://github.com/robotframework/robotframework/issues/4568 +.. _#4569: https://github.com/robotframework/robotframework/issues/4569 +.. _#4575: https://github.com/robotframework/robotframework/issues/4575 +.. _#4576: https://github.com/robotframework/robotframework/issues/4576 +.. _#4583: https://github.com/robotframework/robotframework/issues/4583 +.. _#4601: https://github.com/robotframework/robotframework/issues/4601 +.. _#4647: https://github.com/robotframework/robotframework/issues/4647 +.. _#4660: https://github.com/robotframework/robotframework/issues/4660 +.. _#4666: https://github.com/robotframework/robotframework/issues/4666 +.. _#4676: https://github.com/robotframework/robotframework/issues/4676 +.. _#4683: https://github.com/robotframework/robotframework/issues/4683 +.. _#4684: https://github.com/robotframework/robotframework/issues/4684 +.. _#4729: https://github.com/robotframework/robotframework/issues/4729 +.. _#4740: https://github.com/robotframework/robotframework/issues/4740 +.. _#4627: https://github.com/robotframework/robotframework/issues/4627 +.. _#4611: https://github.com/robotframework/robotframework/issues/4611 +.. _#4634: https://github.com/robotframework/robotframework/issues/4634 +.. _#4638: https://github.com/robotframework/robotframework/issues/4638 +.. _#4646: https://github.com/robotframework/robotframework/issues/4646 +.. _#4663: https://github.com/robotframework/robotframework/issues/4663 +.. _#4736: https://github.com/robotframework/robotframework/issues/4736 +.. _#4749: https://github.com/robotframework/robotframework/issues/4749 +.. _#4522: https://github.com/robotframework/robotframework/issues/4522 +.. _#4596: https://github.com/robotframework/robotframework/issues/4596 +.. _#4598: https://github.com/robotframework/robotframework/issues/4598 +.. _#4619: https://github.com/robotframework/robotframework/issues/4619 +.. _#4636: https://github.com/robotframework/robotframework/issues/4636 +.. _#4656: https://github.com/robotframework/robotframework/issues/4656 +.. _#4709: https://github.com/robotframework/robotframework/issues/4709 From adc60f55f97fd3cec62b4dbee1b4c07c90b810b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 5 May 2023 21:30:05 +0300 Subject: [PATCH 0544/1592] Updated version to 6.1b1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 08eb693bb22..1b092ded300 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1a2.dev1' +VERSION = '6.1b1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index aca3a8641e5..2e1e470cd14 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1a2.dev1' +VERSION = '6.1b1' def get_version(naked=False): From 780863b6b1e9bdcc6989bfb9ffa60214c63564d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 5 May 2023 21:56:40 +0300 Subject: [PATCH 0545/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 1b092ded300..d60c3307974 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1b1' +VERSION = '6.1b2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 2e1e470cd14..03f64da6fc9 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1b1' +VERSION = '6.1b2.dev1' def get_version(naked=False): From 1da62b80fa46bbc0c15b45cbdd8907d47b297061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 6 May 2023 13:10:07 +0300 Subject: [PATCH 0546/1592] Typo fixes. Thanks @leeuwe for proofreading! --- doc/releasenotes/rf-6.1b1.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/releasenotes/rf-6.1b1.rst b/doc/releasenotes/rf-6.1b1.rst index 9ef22dff0d1..db27143d391 100644 --- a/doc/releasenotes/rf-6.1b1.rst +++ b/doc/releasenotes/rf-6.1b1.rst @@ -8,7 +8,7 @@ Robot Framework 6.1 beta 1 Robot Framework data to JSON and back, a new external parser API, and various other interesting new features both for normal users and for external tool developers. This beta release is especially targeted for tool developers -interested to test the new APIs. It also contain all planned +interested to test the new APIs. It also contains all planned `backwards incompatible changes`_ and `deprecated features`_, and everyone interested to make sure their tests, tasks or tools are compatible, should test it in their environment. @@ -114,7 +114,7 @@ The parser API is another important new interface targeted for tool developers (`#1283`_). It makes it possible to create custom parsers that can handle their own data formats or even override Robot Framework's own parser. -Parsers are taken into use from the command like using the new `--parser` option +Parsers are taken into use from the command line using the new `--parser` option the same way as, for example, listeners. This includes specifying parsers as names or paths, giving arguments to parser classes, and so on:: @@ -264,7 +264,7 @@ Possibility to flatten keyword structures during execution ---------------------------------------------------------- With nested keyword structures, especially with recursive keyword calls and with -WHILE and FOR loops, the log file can get hard do understand with many different +WHILE and FOR loops, the log file can get hard to understand with many different nesting levels. Such nested structures also increase the size of the output.xml file. For example, even a simple keyword like: From 3109ea59b350603706996c0327c2268b2348b55a Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Mon, 8 May 2023 14:05:53 +0100 Subject: [PATCH 0547/1592] Add type hints for model.body (#4724) Part of #4570. Co-authored-by: serhiy <serhiy.pikho@jitsuin.com> --- src/robot/model/body.py | 104 ++++++++++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 36 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index fe84e586626..26c6e9a4f72 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -14,10 +14,21 @@ # limitations under the License. import re +from typing import TYPE_CHECKING, Any, Iterable, Mapping, Type, TypeVar, cast from .itemlist import ItemList from .modelobject import ModelObject, full_name +if TYPE_CHECKING: + from .control import ( + Break, Continue, Error, For, If, Return, Try, While, IfBranch, TryBranch) + from .keyword import Keyword + from .message import Message + from .testcase import TestCase + from .testsuite import TestSuite + +T = TypeVar("T", bound="BodyItem") + class BodyItem(ModelObject): KEYWORD = 'KEYWORD' @@ -43,7 +54,7 @@ class BodyItem(ModelObject): __slots__ = ['parent'] @property - def id(self): + def id(self) -> 'str': """Item id in format like ``s1-t3-k1``. See :attr:`TestSuite.id <robot.model.testsuite.TestSuite.id>` for @@ -57,15 +68,16 @@ def id(self): return 'k1' return self._get_id(self.parent) - def _get_id(self, parent): + def _get_id(self, parent: 'TestSuite|TestCase|BodyItem') -> str: steps = [] if getattr(parent, 'has_setup', False): - steps.append(parent.setup) + steps.append(parent.setup) # type: ignore # Use Protocol with RF 7 if hasattr(parent, 'body'): - steps.extend(step for step in parent.body.flatten() + steps.extend(step for step in + parent.body.flatten() # type: ignore # Use Protocol with RF 7 if step.type != self.MESSAGE) if getattr(parent, 'has_teardown', False): - steps.append(parent.teardown) + steps.append(parent.teardown) # type: ignore # Use Protocol with RF 7 index = steps.index(self) if self in steps else len(steps) parent_id = parent.id return f'{parent_id}-k{index + 1}' if parent_id else f'k{index + 1}' @@ -74,25 +86,39 @@ def to_dict(self): raise NotImplementedError -class BaseBody(ItemList): +class BaseBody(ItemList[BodyItem]): """Base class for Body and Branches objects.""" __slots__ = [] # Set using 'BaseBody.register' when these classes are created. - keyword_class = None - for_class = None - while_class = None - if_class = None - try_class = None - return_class = None - continue_class = None - break_class = None - message_class = None - error_class = None - - def __init__(self, parent=None, items=None): + + if TYPE_CHECKING: + keyword_class = Keyword + for_class = For + while_class = While + if_class = If + try_class = Try + return_class = Return + continue_class = Continue + break_class = Break + message_class = Message + error_class = Error + else: + keyword_class = None + for_class = None + while_class = None + if_class = None + try_class = None + return_class = None + continue_class = None + break_class = None + message_class = None + error_class = None + + def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None, + items: 'Iterable[BodyItem|Mapping]' = ()): super().__init__(BodyItem, {'parent': parent}, items) - def _item_from_dict(self, data): + def _item_from_dict(self, data: Mapping) -> BodyItem: item_type = data.get('type', None) if not item_type: item_class = self.keyword_class @@ -102,10 +128,11 @@ def _item_from_dict(self, data): item_class = self.try_class else: item_class = getattr(self, item_type.lower() + '_class') + item_class = cast(Type[BodyItem], item_class) return item_class.from_dict(data) @classmethod - def register(cls, item_class): + def register(cls, item_class: Type[T]) -> Type[T]: name_parts = re.findall('([A-Z][a-z]+)', item_class.__name__) + ['class'] name = '_'.join(name_parts).lower() if not hasattr(cls, name): @@ -120,39 +147,40 @@ def create(self): f"Use item specific methods like 'create_keyword' instead." ) - def create_keyword(self, *args, **kwargs): - return self._create(self.keyword_class, 'create_keyword', args, kwargs) - - def _create(self, cls, name, args, kwargs): + def _create(self, cls: 'Type[T]', name: str, args: Iterable[Any], + kwargs: Mapping[str, Any]) -> T: if cls is None: raise TypeError(f"'{full_name(self)}' object does not support '{name}'.") return self.append(cls(*args, **kwargs)) - def create_for(self, *args, **kwargs): + def create_keyword(self, *args, **kwargs) -> 'Keyword': + return self._create(self.keyword_class, 'create_keyword', args, kwargs) + + def create_for(self, *args, **kwargs) -> 'For': return self._create(self.for_class, 'create_for', args, kwargs) - def create_if(self, *args, **kwargs): + def create_if(self, *args, **kwargs) -> 'If': return self._create(self.if_class, 'create_if', args, kwargs) - def create_try(self, *args, **kwargs): + def create_try(self, *args, **kwargs) -> 'Try': return self._create(self.try_class, 'create_try', args, kwargs) - def create_while(self, *args, **kwargs): + def create_while(self, *args, **kwargs) -> 'While': return self._create(self.while_class, 'create_while', args, kwargs) - def create_return(self, *args, **kwargs): + def create_return(self, *args, **kwargs) -> 'Return': return self._create(self.return_class, 'create_return', args, kwargs) - def create_continue(self, *args, **kwargs): + def create_continue(self, *args, **kwargs) -> 'Continue': return self._create(self.continue_class, 'create_continue', args, kwargs) - def create_break(self, *args, **kwargs): + def create_break(self, *args, **kwargs) -> 'Break': return self._create(self.break_class, 'create_break', args, kwargs) - def create_message(self, *args, **kwargs): + def create_message(self, *args, **kwargs) -> 'Message': return self._create(self.message_class, 'create_message', args, kwargs) - def create_error(self, *args, **kwargs): + def create_error(self, *args, **kwargs) -> 'Error': return self._create(self.error_class, 'create_error', args, kwargs) def filter(self, keywords=None, messages=None, predicate=None): @@ -199,7 +227,7 @@ def _filter(self, types, predicate): items = [item for item in items if predicate(item)] return items - def flatten(self): + def flatten(self) -> 'list[BodyItem]': """Return steps so that IF and TRY structures are flattened. Basically the IF/ELSE and TRY/EXCEPT root elements are replaced @@ -209,6 +237,7 @@ def flatten(self): steps = [] for item in self: if item.type in roots: + item = cast('Try|If', item) steps.extend(item.body) else: steps.append(item) @@ -227,11 +256,14 @@ class Branches(BaseBody): """A list-like object representing IF and TRY branches.""" __slots__ = ['branch_class'] - def __init__(self, branch_class, parent=None, items=None): + def __init__(self, branch_class: 'Type[IfBranch|TryBranch]', + parent: 'TestSuite|TestCase|BodyItem|None' = None, + items: 'Iterable[BodyItem|Mapping]' = ()): + self.branch_class = branch_class super().__init__(parent, items) - def _item_from_dict(self, data): + def _item_from_dict(self, data: Mapping) -> BodyItem: return self.branch_class.from_dict(data) def create_branch(self, *args, **kwargs): From 84b95a8f7bda9dc88eb2c6fcbb2992eb55168f45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 May 2023 16:06:10 +0300 Subject: [PATCH 0548/1592] Bump actions/setup-python from 4.5.0 to 4.6.0 (#4743) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.5.0...v4.6.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 8705812ce45..7cc2f67985f 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: '3.10' architecture: 'x64' @@ -49,7 +49,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 6bb89665b2a..3d5bc9a83ce 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: '3.11' 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@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 304cc84f45b..96e311f303a 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.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 9d3c872442d..7f96e7a1e4e 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From b2a3c4e00c8d173217e591b7fd690f5e0714e7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 6 May 2023 15:46:38 +0300 Subject: [PATCH 0549/1592] Add type hints to NormalizedDict and fix NormalizedDict.copy. copy() always returned NormalizedDict instances, not instances of possible subclasses. --- src/robot/model/metadata.py | 17 ++++++---- src/robot/utils/normalizing.py | 57 ++++++++++++++++++--------------- utest/utils/test_normalizing.py | 18 +++++++++-- 3 files changed, 57 insertions(+), 35 deletions(-) diff --git a/src/robot/model/metadata.py b/src/robot/model/metadata.py index 9528e687390..db8ceadab1d 100644 --- a/src/robot/model/metadata.py +++ b/src/robot/model/metadata.py @@ -13,20 +13,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import is_string, NormalizedDict +from collections.abc import Iterable, Mapping +from robot.utils import NormalizedDict -class Metadata(NormalizedDict): - def __init__(self, initial=None): +class Metadata(NormalizedDict[str]): + + def __init__(self, initial: 'Mapping[str, str]|Iterable[tuple[str, str]]|None' = None): super().__init__(initial, ignore='_') - def __setitem__(self, key, value): - if not is_string(key): + def __setitem__(self, key: str, value: str): + if not isinstance(key, str): key = str(key) - if not is_string(value): + if not isinstance(value, str): value = str(value) super().__setitem__(key, value) def __str__(self): - return '{%s}' % ', '.join('%s: %s' % (k, self[k]) for k in self) + items = ', '.join(f'{key}: {self[key]}' for key in self) + return f'{{{items}}}' diff --git a/src/robot/utils/normalizing.py b/src/robot/utils/normalizing.py index bffed1ff79c..085c5624f9b 100644 --- a/src/robot/utils/normalizing.py +++ b/src/robot/utils/normalizing.py @@ -14,8 +14,12 @@ # limitations under the License. import re -from collections.abc import Mapping, MutableMapping, Sequence -from typing import overload +from collections.abc import Iterable, Iterator, Mapping, Sequence +from typing import Any, MutableMapping, overload, TypeVar + + +V = TypeVar('V') +Self = TypeVar('Self', bound='NormalizedDict') @overload @@ -23,15 +27,18 @@ def normalize(string: str, ignore: 'Sequence[str]' = (), caseless: bool = True, spaceless: bool = True) -> str: ... + @overload def normalize(string: bytes, ignore: 'Sequence[bytes]' = (), caseless: bool = True, spaceless: bool = True) -> bytes: ... + +# TODO: Remove bytes support in RF 7.0. There shouldn't be needs for that with Python 3. def normalize(string, ignore=(), caseless=True, spaceless=True): - """Normalizes given string according to given spec. + """Normalize the ``string`` according to the given spec. - By default string is turned to lower case and all whitespace is removed. + By default, string is turned to lower case and all whitespace is removed. Additional characters can be removed by giving them in ``ignore`` list. """ empty = '' if isinstance(string, str) else b'' @@ -55,67 +62,67 @@ def normalize_whitespace(string): return re.sub(r'\s', ' ', string, flags=re.UNICODE) -class NormalizedDict(MutableMapping): +class NormalizedDict(MutableMapping[str, V]): """Custom dictionary implementation automatically normalizing keys.""" - def __init__(self, initial=None, ignore=(), caseless=True, spaceless=True): + def __init__(self, initial: 'Mapping[str, V]|Iterable[tuple[str, V]]|None' = None, + ignore: 'Sequence[str]' = (), caseless: bool = True, + spaceless: bool = True): """Initialized with possible initial value and normalizing spec. Initial values can be either a dictionary or an iterable of name/value - pairs. In the latter case items are added in the given order. + pairs. Normalizing spec has exact same semantics as with the :func:`normalize` function. """ - self._data = {} - self._keys = {} + self._data: 'dict[str, V]' = {} + self._keys: 'dict[str, str]' = {} self._normalize = lambda s: normalize(s, ignore, caseless, spaceless) if initial: - items = initial.items() if hasattr(initial, 'items') else initial - for key, value in items: - self[key] = value + self.update(initial) @property - def normalized_keys(self): - return self._keys + def normalized_keys(self) -> 'tuple[str, ...]': + return tuple(self._keys) - def __getitem__(self, key): + def __getitem__(self, key: str) -> V: return self._data[self._normalize(key)] - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: V): norm_key = self._normalize(key) self._data[norm_key] = value self._keys.setdefault(norm_key, key) - def __delitem__(self, key): + def __delitem__(self, key: str): norm_key = self._normalize(key) del self._data[norm_key] del self._keys[norm_key] - def __iter__(self): + def __iter__(self) -> 'Iterator[str]': return (self._keys[norm_key] for norm_key in sorted(self._keys)) - def __len__(self): + def __len__(self) -> int: return len(self._data) - def __str__(self): + def __str__(self) -> str: items = ', '.join(f'{key!r}: {self[key]!r}' for key in self) return f'{{{items}}}' - def __repr__(self): + def __repr__(self) -> str: name = type(self).__name__ params = str(self) if self else '' return f'{name}({params})' - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: if not isinstance(other, Mapping): return False if not isinstance(other, NormalizedDict): other = NormalizedDict(other) return self._data == other._data - def copy(self): - copy = NormalizedDict() + def copy(self: Self) -> Self: + copy = type(self)() copy._data = self._data.copy() copy._keys = self._keys.copy() copy._normalize = self._normalize @@ -123,7 +130,7 @@ def copy(self): # Speed-ups. Following methods are faster than default implementations. - def __contains__(self, key): + def __contains__(self, key: str) -> bool: return self._normalize(key) in self._data def clear(self): diff --git a/utest/utils/test_normalizing.py b/utest/utils/test_normalizing.py index 15502b66eb5..55c6d0d0297 100644 --- a/utest/utils/test_normalizing.py +++ b/utest/utils/test_normalizing.py @@ -81,10 +81,16 @@ def test_initial_values_as_name_value_pairs(self): assert_equal(nd['K EY'], 'value') assert_equal(nd['foo'], 'bar') + def test_initial_values_as_generator(self): + nd = NormalizedDict((item for item in [('key', 'value'), ('F O\tO', 'bar')])) + assert_equal(nd['key'], 'value') + assert_equal(nd['K EY'], 'value') + assert_equal(nd['foo'], 'bar') + def test_setdefault(self): nd = NormalizedDict({'a': NormalizedDict()}) - nd.setdefault('a', 'whatever').setdefault('B', []).append(1) - nd.setdefault('A', 'everwhat').setdefault('b', []).append(2) + nd.setdefault('a').setdefault('B', []).append(1) + nd.setdefault('A', 'whatever').setdefault('b', []).append(2) assert_equal(nd['a']['b'], [1, 2]) assert_equal(list(nd), ['a']) assert_equal(list(nd['a']), ['B']) @@ -162,7 +168,7 @@ def test_popitem_empty(self): def test_len(self): nd = NormalizedDict() assert_equal(len(nd), 0) - nd['a'] = nd['b'] = nd['c'] = 1 + nd['a'] = nd['b'] = nd['B'] = nd['c'] = 'x' assert_equal(len(nd), 3) def test_truth_value(self): @@ -183,6 +189,11 @@ def test_copy(self): assert_equal(cd._keys, {'a': 'a', 'b': 'B'}) assert_equal(cd._data, {'a': 1, 'b': 2}) + def test_copy_with_subclass(self): + class SubClass(NormalizedDict): + pass + assert_true(isinstance(SubClass().copy(), SubClass)) + def test_str(self): nd = NormalizedDict({'a': 1, 'B': 2, 'c': '3', 'd': '"', 'E': 5, 'F': 6}) expected = "{'a': 1, 'B': 2, 'c': '3', 'd': '\"', 'E': 5, 'F': 6}" @@ -242,6 +253,7 @@ def test_iter(self): def test_keys_are_sorted(self): nd = NormalizedDict((c, None) for c in 'aBcDeFg123XyZ___') assert_equal(list(nd.keys()), list('123_aBcDeFgXyZ')) + assert_equal(list(nd), list('123_aBcDeFgXyZ')) def test_keys_values_and_items_are_returned_in_same_order(self): nd = NormalizedDict() From e086ba0c949da900b554e1085f82a20d0af2b0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 8 May 2023 17:01:50 +0300 Subject: [PATCH 0550/1592] Enhance typing of robot.model.body. Related to issue #4570 and PR #4724. Also fix typing of ItemList.append. --- src/robot/model/body.py | 35 +++++++++++++++++++++-------------- src/robot/model/itemlist.py | 2 +- src/robot/model/keyword.py | 6 ++++++ 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 26c6e9a4f72..24cfc51b64f 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -14,19 +14,20 @@ # limitations under the License. import re -from typing import TYPE_CHECKING, Any, Iterable, Mapping, Type, TypeVar, cast +from typing import Any, Callable, cast, Iterable, Mapping, Type, TYPE_CHECKING, TypeVar from .itemlist import ItemList -from .modelobject import ModelObject, full_name +from .modelobject import full_name, ModelObject if TYPE_CHECKING: - from .control import ( - Break, Continue, Error, For, If, Return, Try, While, IfBranch, TryBranch) + from .control import (Break, Continue, Error, For, If, IfBranch, Return, + Try, TryBranch, While) from .keyword import Keyword from .message import Message from .testcase import TestCase from .testsuite import TestSuite + T = TypeVar("T", bound="BodyItem") @@ -54,16 +55,21 @@ class BodyItem(ModelObject): __slots__ = ['parent'] @property - def id(self) -> 'str': + def id(self) -> 'str|None': """Item id in format like ``s1-t3-k1``. See :attr:`TestSuite.id <robot.model.testsuite.TestSuite.id>` for more information. + + ``id`` is ``None`` only in these special cases: + + - Keyword uses a placeholder for ``setup`` or ``teardown`` when + a ``setup`` or ``teardown`` is not actually used. + - With :class:`~robot.model.control.If` and :class:`~robot.model.control.Try` + instances representing IF/TRY structure roots. """ # This algorithm must match the id creation algorithm in the JavaScript side # or linking to warnings and errors won't work. - if not self: - return None if not self.parent: return 'k1' return self._get_id(self.parent) @@ -71,13 +77,13 @@ def id(self) -> 'str': def _get_id(self, parent: 'TestSuite|TestCase|BodyItem') -> str: steps = [] if getattr(parent, 'has_setup', False): - steps.append(parent.setup) # type: ignore # Use Protocol with RF 7 + steps.append(parent.setup) # type: ignore - Use Protocol with RF 7. if hasattr(parent, 'body'): steps.extend(step for step in - parent.body.flatten() # type: ignore # Use Protocol with RF 7 + parent.body.flatten() # type: ignore - Use Protocol with RF 7. if step.type != self.MESSAGE) if getattr(parent, 'has_teardown', False): - steps.append(parent.teardown) # type: ignore # Use Protocol with RF 7 + steps.append(parent.teardown) # type: ignore - Use Protocol with RF 7. index = steps.index(self) if self in steps else len(steps) parent_id = parent.id return f'{parent_id}-k{index + 1}' if parent_id else f'k{index + 1}' @@ -183,7 +189,8 @@ def create_message(self, *args, **kwargs) -> 'Message': def create_error(self, *args, **kwargs) -> 'Error': return self._create(self.error_class, 'create_error', args, kwargs) - def filter(self, keywords=None, messages=None, predicate=None): + def filter(self, keywords: 'bool|None' = None, messages: 'bool|None' = None, + predicate: 'Callable[[T], bool]|None' = None): """Filter body items based on type and/or custom predicate. To include or exclude items based on types, give matching arguments @@ -263,8 +270,8 @@ def __init__(self, branch_class: 'Type[IfBranch|TryBranch]', self.branch_class = branch_class super().__init__(parent, items) - def _item_from_dict(self, data: Mapping) -> BodyItem: + def _item_from_dict(self, data: Mapping) -> 'IfBranch|TryBranch': return self.branch_class.from_dict(data) - def create_branch(self, *args, **kwargs): - return self.append(self.branch_class(*args, **kwargs)) + def create_branch(self, *args, **kwargs) -> 'IfBranch|TryBranch': + return self._create(self.branch_class, 'create_branch', args, kwargs) diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index 0e48a451913..f6135ff8fb8 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -58,7 +58,7 @@ def create(self, *args, **kwargs) -> T: """Create a new item using the provided arguments.""" return self.append(self._item_class(*args, **kwargs)) - def append(self, item: 'T|Mapping'): + def append(self, item: 'T|Mapping') -> T: item = self._check_type_and_set_attrs(item) self._items.append(item) return item diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index 26e6cdcfade..11087f1fcca 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -52,6 +52,12 @@ def name(self) -> str: def name(self, name: str): self._name = name + @property + def id(self) -> 'str|None': + if not self: + return None + return super().id + def visit(self, visitor: 'SuiteVisitor'): """:mod:`Visitor interface <robot.model.visitor>` entry-point.""" if self: From 57196473c6f3af9f7b1e18bb746fa8f4bc372980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 9 May 2023 11:08:35 +0300 Subject: [PATCH 0551/1592] ModelObject enhancements - Add typing. Part of #4570. - Enhance `config` to convert values to tuples if original attribute is a tuple. This preserves tuples returned from `to_dict` in `to_json/from_json` roundtrip (#3902). Alternative would be using `@setter` with all attributes containing tuples, but it's easier to handle them here. - Enhance `config` to require attributes to exist. This enhances error reporting. --- src/robot/model/modelobject.py | 105 +++++++++++++++++++++----------- utest/model/test_modelobject.py | 85 ++++++++++++++++++++------ 2 files changed, 137 insertions(+), 53 deletions(-) diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index 71d9b0a6df4..6fce1bfc9ee 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -15,10 +15,15 @@ import copy import json -import os -import pathlib +from io import IOBase +from pathlib import Path +from typing import Any, Dict, overload, Type, TypeVar -from robot.utils import SetterAwareType, type_name +from robot.utils import get_error_message, SetterAwareType, type_name + + +T = TypeVar('T', bound='ModelObject') +DataDict = Dict[str, Any] class ModelObject(metaclass=SetterAwareType): @@ -26,28 +31,26 @@ class ModelObject(metaclass=SetterAwareType): __slots__ = [] @classmethod - def from_dict(cls, data): + def from_dict(cls: Type[T], data: DataDict) -> T: """Create this object based on data in a dictionary. Data can be got from the :meth:`to_dict` method or created externally. """ try: return cls().config(**data) - except AttributeError as err: + except (AttributeError, TypeError) as err: raise ValueError(f"Creating '{full_name(cls)}' object from dictionary " f"failed: {err}") @classmethod - def from_json(cls, source): + def from_json(cls: Type[T], source: 'str|bytes|IOBase|Path') -> T: """Create this object based on JSON data. The data is given as the ``source`` parameter. It can be: - a string (or bytes) containing the data directly, - an open file object where to read the data, or - - a path (string or `pathlib.Path`__) to a UTF-8 encoded file to read. - - __ https://docs.python.org/3/library/pathlib.html + - a path (``pathlib.Path`` or string) to a UTF-8 encoded file to read. The JSON data is first converted to a Python dictionary and the object created using the :meth:`from_dict` method. @@ -58,19 +61,30 @@ def from_json(cls, source): """ try: data = JsonLoader().load(source) - except ValueError as err: + except (TypeError, ValueError) as err: raise ValueError(f'Loading JSON data failed: {err}') return cls.from_dict(data) - def to_dict(self): + def to_dict(self) -> DataDict: """Serialize this object into a dictionary. The object can be later restored by using the :meth:`from_dict` method. """ raise NotImplementedError - def to_json(self, file=None, *, ensure_ascii=False, indent=0, - separators=(',', ':')): + @overload + def to_json(self, file: None = None, *, ensure_ascii: bool = False, + indent: int = 0, separators: 'tuple[str, str]' = (',', ':')) -> str: + ... + + @overload + def to_json(self, file: 'IOBase|Path|str', *, ensure_ascii: bool = False, + indent: int = 0, separators: 'tuple[str, str]' = (',', ':')) -> str: + ... + + def to_json(self, file: 'None|IOBase|Path|str' = None, *, + ensure_ascii: bool = False, indent: int = 0, + separators: 'tuple[str, str]' = (',', ':')) -> 'None|str': """Serialize this object into JSON. The object is first converted to a Python dictionary using the @@ -81,7 +95,8 @@ def to_json(self, file=None, *, ensure_ascii=False, indent=0, - ``None`` (default) to return the data as a string, - an open file object where to write the data, or - - a path to a file where to write the data using UTF-8 encoding. + - a path (``pathlib.Path`` or string) to a file where to write + the data using UTF-8 encoding. JSON formatting can be configured using optional parameters that are passed directly to the underlying json__ module. Notice that @@ -92,7 +107,7 @@ def to_json(self, file=None, *, ensure_ascii=False, indent=0, return JsonDumper(ensure_ascii=ensure_ascii, indent=indent, separators=separators).dump(self.to_dict(), file) - def config(self, **attributes): + def config(self: T, **attributes) -> T: """Configure model object with given attributes. ``obj.config(name='Example', doc='Something')`` is equivalent to setting @@ -100,18 +115,30 @@ def config(self, **attributes): New in Robot Framework 4.0. """ - for name in attributes: + for name, value in attributes.items(): + try: + orig = getattr(self, name) + except AttributeError: + raise AttributeError(f"'{full_name(self)}' object does not have " + f"attribute '{name}'") + # Preserve tuples. Main motivation is converting lists with `from_json`. + if isinstance(orig, tuple) and not isinstance(value, tuple): + try: + value = tuple(value) + except TypeError: + raise TypeError(f"'{full_name(self)}' object attribute '{name}' " + f"is 'tuple', got '{type_name(value)}'.") try: - setattr(self, name, attributes[name]) + setattr(self, name, value) except AttributeError as err: # Ignore error setting attribute if the object already has it. - # Avoids problems with `to/from_dict` roundtrip with body items - # having un-settable `type` attribute that is needed in dict data. - if getattr(self, name, object()) != attributes[name]: + # Avoids problems with `from_dict` with body items having + # un-settable `type` attribute that is needed in dict data. + if value != orig: raise AttributeError(f"Setting attribute '{name}' failed: {err}") return self - def copy(self, **attributes): + def copy(self: T, **attributes) -> T: """Return a shallow copy of this object. :param attributes: Attributes to be set to the returned copy. @@ -128,7 +155,7 @@ def copy(self, **attributes): setattr(copied, name, attributes[name]) return copied - def deepcopy(self, **attributes): + def deepcopy(self: T, **attributes) -> T: """Return a deep copy of this object. :param attributes: Attributes to be set to the returned copy. @@ -145,19 +172,19 @@ def deepcopy(self, **attributes): setattr(copied, name, attributes[name]) return copied - def __repr__(self): + def __repr__(self) -> str: arguments = [(name, getattr(self, name)) for name in self.repr_args] args_repr = ', '.join(f'{name}={value!r}' for name, value in arguments if self._include_in_repr(name, value)) return f"{full_name(self)}({args_repr})" - def _include_in_repr(self, name, value): + def _include_in_repr(self, name: str, value: Any) -> bool: return True -def full_name(obj): - typ = type(obj) if not isinstance(obj, type) else obj - parts = typ.__module__.split('.') + [typ.__name__] +def full_name(obj_or_cls): + cls = type(obj_or_cls) if not isinstance(obj_or_cls, type) else obj_or_cls + parts = cls.__module__.split('.') + [cls.__name__] if len(parts) > 1 and parts[0] == 'robot': parts[2:-1] = [] return '.'.join(parts) @@ -165,13 +192,13 @@ def full_name(obj): class JsonLoader: - def load(self, source): + def load(self, source: 'str|bytes|IOBase|Path') -> DataDict: try: data = self._load(source) - except (json.JSONDecodeError, TypeError) as err: - raise ValueError(f'Invalid JSON data: {err}') + except (json.JSONDecodeError, TypeError): + raise ValueError(f'Invalid JSON data: {get_error_message()}') if not isinstance(data, dict): - raise ValueError(f"Expected dictionary, got {type_name(data)}.") + raise TypeError(f"Expected dictionary, got {type_name(data)}.") return data def _load(self, source): @@ -183,11 +210,11 @@ def _load(self, source): return json.loads(source) def _is_path(self, source): - if isinstance(source, os.PathLike): + if isinstance(source, Path): return True if not isinstance(source, str) or '{' in source: return False - return os.path.isfile(source) + return Path(source).is_file() class JsonDumper: @@ -195,10 +222,18 @@ class JsonDumper: def __init__(self, **config): self.config = config - def dump(self, data, output=None): + @overload + def dump(self, data: DataDict, output: None = None) -> 'str': + ... + + @overload + def dump(self, data: DataDict, output: 'str|Path|IOBase') -> None: + ... + + def dump(self, data: DataDict, output: 'None|IOBase|Path|str' = None) -> 'None|str': if not output: return json.dumps(data, **self.config) - elif isinstance(output, (str, pathlib.Path)): + elif isinstance(output, (str, Path)): with open(output, 'w', encoding='UTF-8') as file: json.dump(data, file, **self.config) elif hasattr(output, 'write'): diff --git a/utest/model/test_modelobject.py b/utest/model/test_modelobject.py index 218c5ea4ba9..3ff0839e43e 100644 --- a/utest/model/test_modelobject.py +++ b/utest/model/test_modelobject.py @@ -6,13 +6,21 @@ import tempfile from robot.model.modelobject import ModelObject -from robot.utils.asserts import assert_equal, assert_raises, assert_raises_with_msg +from robot.utils import get_error_message +from robot.utils.asserts import assert_equal, assert_raises_with_msg class Example(ModelObject): - def __init__(self, **attrs): - self.__dict__.update(attrs) + def __init__(self, a=None, b=None, c=None): + self.a = a + self.b = b + self.c = c + + def __setattr__(self, name, value): + if value == 'fail': + raise AttributeError('Ooops!') + self.__dict__[name] = value def to_dict(self): return self.__dict__ @@ -24,15 +32,47 @@ def test_default(self): assert_equal(repr(ModelObject()), 'robot.model.ModelObject()') def test_module_when_extending(self): - class X(ModelObject): - pass - assert_equal(repr(X()), '%s.X()' % __name__) + assert_equal(repr(Example()), f'{__name__}.Example()') def test_repr_args(self): class X(ModelObject): repr_args = ('z', 'x') x, y, z = 1, 2, 3 - assert_equal(repr(X()), '%s.X(z=3, x=1)' % __name__) + assert_equal(repr(X()), f'{__name__}.X(z=3, x=1)') + + +class TestConfig(unittest.TestCase): + + def test_basics(self): + x = Example().config(a=1, c=3) + assert_equal(x.a, 1) + assert_equal(x.b, None) + assert_equal(x.c, 3) + + def test_attributes_must_exist(self): + assert_raises_with_msg( + AttributeError, + f"'{__name__}.Example' object does not have attribute 'bad'", + Example().config, bad='attr' + ) + + def test_setting_attribute_fails(self): + assert_raises_with_msg( + AttributeError, + "Setting attribute 'a' failed: Ooops!", + Example().config, a='fail' + ) + + def test_preserve_tuples(self): + x = Example(a=(1, 2, 3)).config(a=range(5)) + assert_equal(x.a, (0, 1, 2, 3, 4)) + + def test_failure_converting_to_tuple(self): + assert_raises_with_msg( + TypeError, + f"'{__name__}.Example' object attribute 'a' is 'tuple', got 'None'.", + Example(a=()).config, a=None + ) class TestFromDictAndJson(unittest.TestCase): @@ -57,12 +97,18 @@ def test_other_attributes(self): assert_equal(obj.b, 42) def test_not_accepted_attribute(self): - class X(ModelObject): - __slots__ = ['a'] - assert_equal(X.from_dict({'a': 1}).a, 1) - error = assert_raises(ValueError, X.from_dict, {'b': 'bad'}) - assert_equal(str(error).split(':')[0], - f"Creating '{__name__}.X' object from dictionary failed") + assert_raises_with_msg( + ValueError, + f"Creating '{__name__}.Example' object from dictionary failed: " + f"'{__name__}.Example' object does not have attribute 'nonex'", + Example.from_dict, {'nonex': 'attr'} + ) + assert_raises_with_msg( + ValueError, + f"Creating '{__name__}.Example' object from dictionary failed: " + f"Setting attribute 'a' failed: Ooops!", + Example.from_dict, {'a': 'fail'} + ) def test_json_as_bytes(self): obj = Example.from_json(b'{"a": null, "b": 42}') @@ -90,28 +136,31 @@ def test_json_as_path(self): def test_invalid_json_type(self): error = self._get_json_load_error(None) assert_raises_with_msg( - ValueError, f"Loading JSON data failed: Invalid JSON data: {error}", + ValueError, + f"Loading JSON data failed: Invalid JSON data: {error}", ModelObject.from_json, None ) def test_invalid_json_syntax(self): error = self._get_json_load_error('bad') assert_raises_with_msg( - ValueError, f"Loading JSON data failed: Invalid JSON data: {error}", + ValueError, + f"Loading JSON data failed: Invalid JSON data: {error}", ModelObject.from_json, 'bad' ) def test_invalid_json_content(self): assert_raises_with_msg( - ValueError, "Loading JSON data failed: Expected dictionary, got list.", + ValueError, + "Loading JSON data failed: Expected dictionary, got list.", ModelObject.from_json, '["bad"]' ) def _get_json_load_error(self, value): try: json.loads(value) - except (json.JSONDecodeError, TypeError) as err: - return str(err) + except Exception: + return get_error_message() class TestToJson(unittest.TestCase): From 613a00a67bbb0692ccde39d4000c5b2eb53dd548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 9 May 2023 11:22:11 +0300 Subject: [PATCH 0552/1592] Enhance `robot.model.control`. - Add type hints. Part of #4570. - Make attributes containing normal sequences (i.e. not our custom objects like Tags) explicitly tuples in `__init__`. - Perserve tuples as tuples, instead of converting to lists, also in `to_dict` (related to #3902). That means less work and smaller memory usage. Earlier change to `ModelObject.config` makes sure tuples are preserved over `to_json/from_json` roundtrip. - Use tuples attributes also with `robot.running.model.UserKeyword`. That module will get types and be enhanced in the near future. --- src/robot/model/control.py | 196 ++++++++++++++++++-------------- src/robot/model/testcase.py | 2 +- src/robot/running/model.py | 23 ++-- utest/model/test_control.py | 4 +- utest/running/test_run_model.py | 32 +++--- 5 files changed, 137 insertions(+), 120 deletions(-) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index f27065fed7e..4b1d7ac01a1 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -13,10 +13,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys +from typing import Any, cast, Iterable, Sequence +if sys.version_info >= (3, 8): + from typing import Literal + from robot.utils import setter from .body import Body, BodyItem, Branches from .keyword import Keywords +from .modelobject import DataDict +from .testcase import TestCase +from .testsuite import TestSuite +from .visitor import SuiteVisitor @Body.register @@ -31,19 +40,22 @@ class For(BodyItem): repr_args = ('variables', 'flavor', 'values', 'start', 'mode', 'fill') __slots__ = ['variables', 'flavor', 'values', 'start', 'mode', 'fill'] - def __init__(self, variables=(), flavor='IN', values=(), start=None, mode=None, - fill=None, parent=None): - self.variables = variables + def __init__(self, variables: Sequence[str] = (), + flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', + values: Sequence[str] = (), start: 'str|None' = None, + mode: 'str|None' = None, fill: 'str|None' = None, + parent: 'TestSuite|TestCase|BodyItem|None' = None): + self.variables = tuple(variables) self.flavor = flavor - self.values = values + self.values = tuple(values) self.start = start self.mode = mode self.fill = fill self.parent = parent - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property @@ -55,7 +67,7 @@ def keywords(self): def keywords(self, keywords): Keywords.raise_deprecation_error() - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_for(self) def __str__(self): @@ -67,20 +79,20 @@ def __str__(self): parts.append(f'{name}={value}') return ' '.join(parts) - def _include_in_repr(self, name, value): + def _include_in_repr(self, name: str, value: Any) -> bool: return name not in ('start', 'mode', 'fill') or value is not None - def to_dict(self): + def to_dict(self) -> DataDict: data = {'type': self.type, - 'variables': list(self.variables), + 'variables': self.variables, 'flavor': self.flavor, - 'values': list(self.values), - 'body': self.body.to_dicts()} + 'values': self.values} for name, value in [('start', self.start), ('mode', self.mode), ('fill', self.fill)]: if value is not None: data[name] = value + data['body'] = self.body.to_dicts() return data @@ -92,23 +104,24 @@ class While(BodyItem): repr_args = ('condition', 'limit', 'on_limit', 'on_limit_message') __slots__ = ['condition', 'limit', 'on_limit', 'on_limit_message'] - def __init__(self, condition=None, limit=None, on_limit=None, - on_limit_message=None, parent=None): + def __init__(self, condition: 'str|None' = None, limit: 'str|None' = None, + on_limit: 'str|None' = None, on_limit_message: 'str|None' = None, + parent: 'TestSuite|TestCase|BodyItem|None' = None): self.condition = condition self.on_limit = on_limit self.limit = limit self.on_limit_message = on_limit_message self.parent = parent - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: return self.body_class(self, body) - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_while(self) - def __str__(self): + def __str__(self) -> str: parts = ['WHILE'] if self.condition is not None: parts.append(self.condition) @@ -120,17 +133,16 @@ def __str__(self): parts.append(f'on_limit_message={self.on_limit_message}') return ' '.join(parts) - def _include_in_repr(self, name, value): + def _include_in_repr(self, name: str, value: Any) -> bool: return name == 'condition' or value is not None - def to_dict(self): - data = {'type': self.type} - if self.condition: - data['condition'] = self.condition - if self.limit: - data['limit'] = self.limit - if self.on_limit_message: - data['on_limit_message'] = self.on_limit_message + def to_dict(self) -> DataDict: + data: DataDict = {'type': self.type} + for name, value in [('condition', self.condition), + ('limit', self.limit), + ('on_limit_message', self.on_limit_message)]: + if value is not None: + data[name] = value data['body'] = self.body.to_dicts() return data @@ -141,18 +153,19 @@ class IfBranch(BodyItem): repr_args = ('type', 'condition') __slots__ = ['type', 'condition'] - def __init__(self, type=BodyItem.IF, condition=None, parent=None): + def __init__(self, type: str = BodyItem.IF, condition: 'str|None' = None, + parent: 'TestSuite|TestCase|BodyItem|None' = None): self.type = type self.condition = condition self.parent = parent - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property - def id(self): + def id(self) -> str: """Branch id omits IF/ELSE root from the parent id part.""" if not self.parent: return 'k1' @@ -160,17 +173,17 @@ def id(self): return self._get_id(self.parent) return self._get_id(self.parent.parent) - def __str__(self): + def __str__(self) -> str: if self.type == self.IF: - return 'IF %s' % self.condition + return f'IF {self.condition}' if self.type == self.ELSE_IF: - return 'ELSE IF %s' % self.condition + return f'ELSE IF {self.condition}' return 'ELSE' - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_if_branch(self) - def to_dict(self): + def to_dict(self) -> DataDict: data = {'type': self.type, 'condition': self.condition, 'body': self.body.to_dicts()} @@ -185,26 +198,27 @@ class If(BodyItem): type = BodyItem.IF_ELSE_ROOT branch_class = IfBranch branches_class = Branches - __slots__ = ['parent'] + __slots__ = [] - def __init__(self, parent=None): + def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None): self.parent = parent - self.body = None + self.body = () @setter - def body(self, branches): + def body(self, branches: 'Iterable[BodyItem|DataDict]') -> Branches: return self.branches_class(self.branch_class, self, branches) @property - def id(self): + def id(self) -> None: """Root IF/ELSE id is always ``None``.""" return None - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_if(self) - def to_dict(self): - return {'type': self.type, 'body': self.body.to_dicts()} + def to_dict(self) -> DataDict: + return {'type': self.type, + 'body': self.body.to_dicts()} class TryBranch(BodyItem): @@ -213,23 +227,24 @@ class TryBranch(BodyItem): repr_args = ('type', 'patterns', 'pattern_type', 'variable') __slots__ = ['type', 'patterns', 'pattern_type', 'variable'] - def __init__(self, type=BodyItem.TRY, patterns=(), pattern_type=None, - variable=None, parent=None): + def __init__(self, type: str = BodyItem.TRY, patterns: Sequence[str] = (), + pattern_type: 'str|None' = None, variable: 'str|None' = None, + parent: 'TestSuite|TestCase|BodyItem|None' = None): if (patterns or pattern_type or variable) and type != BodyItem.EXCEPT: raise TypeError(f"'{type}' branches do not accept patterns or variables.") self.type = type - self.patterns = patterns + self.patterns = tuple(patterns) self.pattern_type = pattern_type self.variable = variable self.parent = parent - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property - def id(self): + def id(self) -> str: """Branch id omits TRY/EXCEPT root from the parent id part.""" if not self.parent: return 'k1' @@ -237,7 +252,7 @@ def id(self): return self._get_id(self.parent) return self._get_id(self.parent.parent) - def __str__(self): + def __str__(self) -> str: if self.type != BodyItem.EXCEPT: return self.type parts = ['EXCEPT', *self.patterns] @@ -247,16 +262,16 @@ def __str__(self): parts.extend(['AS', self.variable]) return ' '.join(parts) - def _include_in_repr(self, name, value): - return name == 'type' or value + def _include_in_repr(self, name: str, value: Any) -> bool: + return bool(value) - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_try_branch(self) - def to_dict(self): - data = {'type': self.type} + def to_dict(self) -> DataDict: + data: DataDict = {'type': self.type} if self.type == self.EXCEPT: - data['patterns'] = list(self.patterns) + data['patterns'] = self.patterns if self.pattern_type: data['pattern_type'] = self.pattern_type if self.variable: @@ -273,47 +288,49 @@ class Try(BodyItem): branches_class = Branches __slots__ = [] - def __init__(self, parent=None): + def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None): self.parent = parent - self.body = None + self.body = () @setter - def body(self, branches): + def body(self, branches: 'Iterable[TryBranch|DataDict]') -> Branches: return self.branches_class(self.branch_class, self, branches) @property - def try_branch(self): + def try_branch(self) -> TryBranch: if self.body and self.body[0].type == BodyItem.TRY: - return self.body[0] + return cast(TryBranch, self.body[0]) raise TypeError("No 'TRY' branch or 'TRY' branch is not first.") @property - def except_branches(self): - return [branch for branch in self.body if branch.type == BodyItem.EXCEPT] + def except_branches(self) -> 'list[TryBranch]': + return [cast(TryBranch, branch) for branch in self.body + if branch.type == BodyItem.EXCEPT] @property - def else_branch(self): + def else_branch(self) -> 'TryBranch|None': for branch in self.body: if branch.type == BodyItem.ELSE: - return branch + return cast(TryBranch, branch) return None @property def finally_branch(self): if self.body and self.body[-1].type == BodyItem.FINALLY: - return self.body[-1] + return cast(TryBranch, self.body[-1]) return None @property - def id(self): + def id(self) -> None: """Root TRY/EXCEPT id is always ``None``.""" return None - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_try(self) - def to_dict(self): - return {'type': self.type, 'body': self.body.to_dicts()} + def to_dict(self) -> DataDict: + return {'type': self.type, + 'body': self.body.to_dicts()} @Body.register @@ -323,15 +340,17 @@ class Return(BodyItem): repr_args = ('values',) __slots__ = ['values'] - def __init__(self, values=(), parent=None): - self.values = values + def __init__(self, values: Sequence[str] = (), + parent: 'TestSuite|TestCase|BodyItem|None' = None): + self.values = tuple(values) self.parent = parent - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_return(self) - def to_dict(self): - return {'type': self.type, 'values': list(self.values)} + def to_dict(self) -> DataDict: + return {'type': self.type, + 'values': self.values} @Body.register @@ -340,13 +359,13 @@ class Continue(BodyItem): type = BodyItem.CONTINUE __slots__ = [] - def __init__(self, parent=None): + def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None): self.parent = parent - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_continue(self) - def to_dict(self): + def to_dict(self) -> DataDict: return {'type': self.type} @@ -356,13 +375,13 @@ class Break(BodyItem): type = BodyItem.BREAK __slots__ = [] - def __init__(self, parent=None): + def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None): self.parent = parent - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_break(self) - def to_dict(self): + def to_dict(self) -> DataDict: return {'type': self.type} @@ -373,14 +392,17 @@ class Error(BodyItem): For example, an invalid setting like ``[Setpu]`` or ``END`` in wrong place. """ type = BodyItem.ERROR + repr_args = ('values',) __slots__ = ['values'] - def __init__(self, values=(), parent=None): - self.values = values + def __init__(self, values: Sequence[str] = (), + parent: 'TestSuite|TestCase|BodyItem|None' = None): + self.values = tuple(values) self.parent = parent - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_error(self) - def to_dict(self): - return {'type': self.type, 'values': list(self.values)} + def to_dict(self) -> DataDict: + return {'type': self.type, + 'values': self.values} diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index f84b5df4e5d..d5695a4b8d9 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -184,7 +184,7 @@ def to_dict(self) -> 'dict[str, Any]': if self.doc: data['doc'] = self.doc if self.tags: - data['tags'] = list(self.tags) + data['tags'] = tuple(self.tags) if self.timeout: data['timeout'] = self.timeout if self.lineno: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 9f5a81fbceb..2933c3aa433 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -745,20 +745,15 @@ def source(self): def to_dict(self): data = {'name': self.name} - if self.args: - data['args'] = list(self.args) - if self.doc: - data['doc'] = self.doc - if self.tags: - data['tags'] = list(self.tags) - if self.return_: - data['return_'] = self.return_ - if self.timeout: - data['timeout'] = self.timeout - if self.lineno: - data['lineno'] = self.lineno - if self.error: - data['error'] = self.error + for name, value in [('args', self.args), + ('doc', self.doc), + ('tags', tuple(self.tags)), + ('return_', self.return_), + ('timeout', self.timeout), + ('lineno', self.lineno), + ('error', self.error)]: + if value: + data[name] = value data['body'] = self.body.to_dicts() if self.has_teardown: data['teardown'] = self.teardown.to_dict() diff --git a/utest/model/test_control.py b/utest/model/test_control.py index 0715aad18e9..8a11db83b36 100644 --- a/utest/model/test_control.py +++ b/utest/model/test_control.py @@ -27,13 +27,13 @@ def test_string_reprs(self): "For(variables=('${x}', '${y}'), flavor='IN ENUMERATE', values=('a', 'b'))"), (For(['${x}'], 'IN ENUMERATE', ['@{stuff}'], start='1'), 'FOR ${x} IN ENUMERATE @{stuff} start=1', - "For(variables=['${x}'], flavor='IN ENUMERATE', values=['@{stuff}'], start='1')"), + "For(variables=('${x}',), flavor='IN ENUMERATE', values=('@{stuff}',), start='1')"), (For(('${x}', '${y}'), 'IN ZIP', ('${xs}', '${ys}'), mode='LONGEST', fill='-'), 'FOR ${x} ${y} IN ZIP ${xs} ${ys} mode=LONGEST fill=-', "For(variables=('${x}', '${y}'), flavor='IN ZIP', values=('${xs}', '${ys}'), mode='LONGEST', fill='-')"), (For(['${ü}'], 'IN', ['föö']), 'FOR ${ü} IN föö', - "For(variables=['${ü}'], flavor='IN', values=['föö'])") + "For(variables=('${ü}',), flavor='IN', values=('föö',))") ]: assert_equal(str(for_), exp_str) assert_equal(repr(for_), 'robot.model.' + exp_repr) diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index cb05fa3ffaa..d0baeddc097 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -278,13 +278,13 @@ def test_keyword(self): name='Setup', lineno=1) def test_for(self): - self._verify(For(), type='FOR', variables=[], flavor='IN', values=[], body=[]) + self._verify(For(), type='FOR', variables=(), flavor='IN', values=(), body=[]) self._verify(For(['${i}'], 'IN RANGE', ['10'], lineno=2), - type='FOR', variables=['${i}'], flavor='IN RANGE', values=['10'], + type='FOR', variables=('${i}',), flavor='IN RANGE', values=('10',), body=[], lineno=2) self._verify(For(['${i}', '${a}'], 'IN ENUMERATE', ['cat', 'dog'], start='1'), - type='FOR', variables=['${i}', '${a}'], flavor='IN ENUMERATE', - values=['cat', 'dog'], body=[], start='1') + type='FOR', variables=('${i}', '${a}'), flavor='IN ENUMERATE', + values=('cat', 'dog'), start='1', body=[]) def test_while(self): self._verify(While(), type='WHILE', body=[]) @@ -321,9 +321,9 @@ def test_try(self): def test_try_branch(self): self._verify(TryBranch(), type='TRY', body=[]) - self._verify(TryBranch(Try.EXCEPT), type='EXCEPT', patterns=[], body=[]) + self._verify(TryBranch(Try.EXCEPT), type='EXCEPT', patterns=(), body=[]) self._verify(TryBranch(Try.EXCEPT, ['Pa*'], 'glob', '${err}'), type='EXCEPT', - patterns=['Pa*'], pattern_type='glob', variable='${err}', body=[]) + patterns=('Pa*',), pattern_type='glob', variable='${err}', body=[]) self._verify(TryBranch(Try.ELSE, lineno=7), type='ELSE', body=[], lineno=7) self._verify(TryBranch(Try.FINALLY, lineno=8), type='FINALLY', body=[], lineno=8) @@ -336,14 +336,14 @@ def test_try_structure(self): self._verify(root, type='TRY/EXCEPT ROOT', body=[{'type': 'TRY', 'body': [{'name': 'K1'}]}, - {'type': 'EXCEPT', 'patterns': [], 'body': [{'name': 'K2'}]}, + {'type': 'EXCEPT', 'patterns': (), 'body': [{'name': 'K2'}]}, {'type': 'ELSE', 'body': [{'name': 'K3'}]}, {'type': 'FINALLY', 'body': [{'name': 'K4'}]}]) def test_return_continue_break(self): - self._verify(Return(), type='RETURN', values=[]) + self._verify(Return(), type='RETURN', values=()) self._verify(Return(('x', 'y'), lineno=9, error='E'), - type='RETURN', values=['x', 'y'], lineno=9, error='E') + type='RETURN', values=('x', 'y'), lineno=9, error='E') self._verify(Continue(), type='CONTINUE') self._verify(Continue(lineno=10, error='E'), type='CONTINUE', lineno=10, error='E') @@ -352,13 +352,13 @@ def test_return_continue_break(self): type='BREAK', lineno=11, error='E') def test_error(self): - self._verify(Error(), type='ERROR', values=[]) - self._verify(Error(('bad', 'things')), type='ERROR', values=['bad', 'things']) + self._verify(Error(), type='ERROR', values=()) + self._verify(Error(('bad', 'things')), type='ERROR', values=('bad', 'things')) def test_test(self): self._verify(TestCase(), name='', body=[]) self._verify(TestCase('N', 'D', 'T', '1s', lineno=12), - name='N', doc='D', tags=['T'], timeout='1s', lineno=12, body=[]) + name='N', doc='D', tags=('T',), timeout='1s', lineno=12, body=[]) self._verify(TestCase(template='K'), name='', body=[], template='K') def test_test_structure(self): @@ -400,12 +400,12 @@ def test_suite_structure(self): def test_user_keyword(self): self._verify(UserKeyword(), name='', body=[]) - self._verify(UserKeyword('N', 'a', 'd', 't', 'r', 't', 1, error='E'), + self._verify(UserKeyword('N', ('a',), 'd', 't', ('r',), 't', 1, error='E'), name='N', - args=['a'], + args=('a',), doc='d', - tags=['t'], - return_='r', + tags=('t',), + return_=('r',), timeout='t', lineno=1, error='E', From 104457c7acd774b3504ff856fc2d3aea1efba8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 9 May 2023 12:53:20 +0300 Subject: [PATCH 0553/1592] Type hint enhancements related to #4570. Most importantly, use DataDict type alias with `to/from_dict` data. --- src/robot/model/body.py | 18 +++++++++--------- src/robot/model/itemlist.py | 27 ++++++++++++++------------- src/robot/model/keyword.py | 24 +++++++++++++++--------- src/robot/model/testcase.py | 14 +++++++------- src/robot/model/testsuite.py | 17 +++++++++-------- 5 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 24cfc51b64f..7df9977b9ae 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -14,10 +14,10 @@ # limitations under the License. import re -from typing import Any, Callable, cast, Iterable, Mapping, Type, TYPE_CHECKING, TypeVar +from typing import Any, Callable, cast, Iterable, Type, TYPE_CHECKING, TypeVar from .itemlist import ItemList -from .modelobject import full_name, ModelObject +from .modelobject import DataDict, full_name, ModelObject if TYPE_CHECKING: from .control import (Break, Continue, Error, For, If, IfBranch, Return, @@ -88,7 +88,7 @@ def _get_id(self, parent: 'TestSuite|TestCase|BodyItem') -> str: parent_id = parent.id return f'{parent_id}-k{index + 1}' if parent_id else f'k{index + 1}' - def to_dict(self): + def to_dict(self) -> DataDict: raise NotImplementedError @@ -121,10 +121,10 @@ class BaseBody(ItemList[BodyItem]): error_class = None def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None, - items: 'Iterable[BodyItem|Mapping]' = ()): + items: 'Iterable[BodyItem|DataDict]' = ()): super().__init__(BodyItem, {'parent': parent}, items) - def _item_from_dict(self, data: Mapping) -> BodyItem: + def _item_from_dict(self, data: DataDict) -> BodyItem: item_type = data.get('type', None) if not item_type: item_class = self.keyword_class @@ -153,8 +153,8 @@ def create(self): f"Use item specific methods like 'create_keyword' instead." ) - def _create(self, cls: 'Type[T]', name: str, args: Iterable[Any], - kwargs: Mapping[str, Any]) -> T: + def _create(self, cls: 'Type[T]', name: str, args: 'tuple[Any]', + kwargs: 'dict[str, Any]') -> T: if cls is None: raise TypeError(f"'{full_name(self)}' object does not support '{name}'.") return self.append(cls(*args, **kwargs)) @@ -265,12 +265,12 @@ class Branches(BaseBody): def __init__(self, branch_class: 'Type[IfBranch|TryBranch]', parent: 'TestSuite|TestCase|BodyItem|None' = None, - items: 'Iterable[BodyItem|Mapping]' = ()): + items: 'Iterable[BodyItem|DataDict]' = ()): self.branch_class = branch_class super().__init__(parent, items) - def _item_from_dict(self, data: Mapping) -> 'IfBranch|TryBranch': + def _item_from_dict(self, data: DataDict) -> 'IfBranch|TryBranch': return self.branch_class.from_dict(data) def create_branch(self, *args, **kwargs) -> 'IfBranch|TryBranch': diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index f6135ff8fb8..3a7b48f62be 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -14,12 +14,13 @@ # limitations under the License. from functools import total_ordering -from collections.abc import Mapping -from typing import (Iterable, Iterator, MutableSequence, overload, TYPE_CHECKING, +from typing import (Any, Iterable, Iterator, MutableSequence, overload, TYPE_CHECKING, Type, TypeVar) from robot.utils import type_name +from .modelobject import DataDict + if TYPE_CHECKING: from .visitor import SuiteVisitor @@ -46,8 +47,8 @@ class ItemList(MutableSequence[T]): __slots__ = ['_item_class', '_common_attrs', '_items'] def __init__(self, item_class: Type[T], - common_attrs: 'Mapping|None' = None, - items: 'Iterable[T|Mapping]' = ()): + common_attrs: 'dict[str, Any]|None' = None, + items: 'Iterable[T|DataDict]' = ()): self._item_class = item_class self._common_attrs = common_attrs self._items: 'list[T]' = [] @@ -58,14 +59,14 @@ def create(self, *args, **kwargs) -> T: """Create a new item using the provided arguments.""" return self.append(self._item_class(*args, **kwargs)) - def append(self, item: 'T|Mapping') -> T: + def append(self, item: 'T|DataDict') -> T: item = self._check_type_and_set_attrs(item) self._items.append(item) return item - def _check_type_and_set_attrs(self, item: 'T|Mapping') -> T: + def _check_type_and_set_attrs(self, item: 'T|DataDict') -> T: if not isinstance(item, self._item_class): - if isinstance(item, Mapping): + if isinstance(item, dict): item = self._item_from_dict(item) else: raise TypeError(f'Only {type_name(self._item_class)} objects ' @@ -75,15 +76,15 @@ def _check_type_and_set_attrs(self, item: 'T|Mapping') -> T: setattr(item, attr, value) return item - def _item_from_dict(self, data: Mapping) -> T: + def _item_from_dict(self, data: DataDict) -> T: if hasattr(self._item_class, 'from_dict'): return self._item_class.from_dict(data) # type: ignore return self._item_class(**data) - def extend(self, items: 'Iterable[T|Mapping]'): + def extend(self, items: 'Iterable[T|DataDict]'): self._items.extend(self._check_type_and_set_attrs(i) for i in items) - def insert(self, index: int, item: 'T|Mapping'): + def insert(self, index: int, item: 'T|DataDict'): item = self._check_type_and_set_attrs(item) self._items.insert(index, item) @@ -125,11 +126,11 @@ def _create_new_from(self: Self, items: Iterable[T]) -> Self: return new @overload - def __setitem__(self, index: int, item: 'T|Mapping'): + def __setitem__(self, index: int, item: 'T|DataDict'): ... @overload - def __setitem__(self, index: slice, item: 'Iterable[T|Mapping]'): + def __setitem__(self, index: slice, item: 'Iterable[T|DataDict]'): ... def __setitem__(self, index, item): @@ -209,7 +210,7 @@ def __imul__(self: Self, count: int) -> Self: def __rmul__(self: Self, count: int) -> Self: return self * count - def to_dicts(self) -> 'list[dict]': + def to_dicts(self) -> 'list[DataDict]': """Return list of items converted to dictionaries. Items are converted to dictionaries using the ``to_dict`` method, if diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index 11087f1fcca..2ca3ef0257d 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -13,11 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Sequence, Type, TYPE_CHECKING +from typing import cast, Sequence, Type, TYPE_CHECKING import warnings from .body import Body, BodyItem from .itemlist import ItemList +from .modelobject import DataDict if TYPE_CHECKING: from .testcase import TestCase @@ -70,8 +71,8 @@ def __str__(self) -> str: parts = list(self.assign) + [self.name] + list(self.args) return ' '.join(str(p) for p in parts) - def to_dict(self) -> 'dict[str, Any]': - data: 'dict[str, Any]' = {'name': self.name} + def to_dict(self) -> DataDict: + data: DataDict = {'name': self.name} if self.args: data['args'] = list(self.args) if self.assign: @@ -79,7 +80,8 @@ def to_dict(self) -> 'dict[str, Any]': return data -class Keywords(ItemList[Keyword]): +# FIXME: Remote in RF 7. +class Keywords(ItemList[BodyItem]): """A list-like object representing keywords in a suite, a test or a keyword. Read-only and deprecated since Robot Framework 4.0. @@ -90,8 +92,8 @@ class Keywords(ItemList[Keyword]): "Use 'body', 'setup' or 'teardown' instead." ) - def __init__(self, parent: 'TestSuite|None' = None, - keywords: 'Sequence[Keyword]|Keywords' = ()): + def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None, + keywords: Sequence[BodyItem] = ()): warnings.warn(self.deprecation_message, UserWarning) ItemList.__init__(self, object, {'parent': parent}) if keywords: @@ -99,7 +101,9 @@ def __init__(self, parent: 'TestSuite|None' = None, @property def setup(self) -> 'Keyword|None': - return self[0] if (self and self[0].type == 'SETUP') else None + if self and self[0].type == 'SETUP': + return cast(Keyword, self[0]) + return None @setup.setter def setup(self, kw): @@ -110,7 +114,9 @@ def create_setup(self, *args, **kwargs): @property def teardown(self) -> 'Keyword|None': - return self[-1] if (self and self[-1].type == 'TEARDOWN') else None + if self and self[-1].type == 'TEARDOWN': + return cast(Keyword, self[-1]) + return None @teardown.setter def teardown(self, kw: Keyword): @@ -125,7 +131,7 @@ def all(self) -> 'Keywords': return self @property - def normal(self) -> 'list[Keyword]': + def normal(self) -> 'list[BodyItem]': """Iterates over normal keywords, omitting setup and teardown.""" return [kw for kw in self if kw.type not in ('SETUP', 'TEARDOWN')] diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index d5695a4b8d9..5c3c5274641 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -14,15 +14,15 @@ # limitations under the License. from pathlib import Path -from typing import Any, Iterable, Mapping, Sequence, Type, TYPE_CHECKING +from typing import Any, Iterable, Sequence, Type, TYPE_CHECKING from robot.utils import setter -from .body import Body +from .body import Body, BodyItem from .fixture import create_fixture from .itemlist import ItemList from .keyword import Keyword, Keywords -from .modelobject import ModelObject +from .modelobject import DataDict, ModelObject from .tags import Tags if TYPE_CHECKING: @@ -55,7 +55,7 @@ def __init__(self, name: str = '', doc: str = '', tags: Sequence[str] = (), self._teardown: 'Keyword|None' = None @setter - def body(self, body: 'Iterable[Keyword|Mapping]') -> Body: + def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: """Test body as a :class:`~robot.model.body.Body` object.""" return self.body_class(self, body) @@ -94,7 +94,7 @@ def setup(self) -> Keyword: return self._setup @setup.setter - def setup(self, setup: 'Keyword|Mapping|None'): + def setup(self, setup: 'Keyword|DataDict|None'): self._setup = create_fixture(setup, self, Keyword.SETUP) @property @@ -122,7 +122,7 @@ def teardown(self) -> Keyword: return self._teardown @teardown.setter - def teardown(self, teardown: 'Keyword|Mapping|None'): + def teardown(self, teardown: 'Keyword|DataDict|None'): self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) @property @@ -202,7 +202,7 @@ class TestCases(ItemList[TestCase]): def __init__(self, test_class: Type[TestCase] = TestCase, parent: 'TestSuite|None' = None, - tests: 'Sequence[TestCase|Mapping]' = ()): + tests: 'Sequence[TestCase|DataDict]' = ()): super().__init__(test_class, {'parent': parent}, tests) def _check_type_and_set_attrs(self, test): diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 4f35c10d8b5..3a02d9ccbc3 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -25,7 +25,7 @@ from .itemlist import ItemList from .keyword import Keyword, Keywords from .metadata import Metadata -from .modelobject import ModelObject +from .modelobject import DataDict, ModelObject from .tagsetter import TagSetter from .testcase import TestCase, TestCases from .visitor import SuiteVisitor @@ -42,7 +42,8 @@ class TestSuite(ModelObject): repr_args = ('name',) __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', '_my_visitors'] - def __init__(self, name: str = '', doc: str = '', metadata: 'Mapping|None' = None, + def __init__(self, name: str = '', doc: str = '', + metadata: 'Mapping[str, str]|None' = None, source: 'Path|str|None' = None, rpa: 'bool|None' = None, parent: 'TestSuite|None' = None): self._name = name @@ -102,16 +103,16 @@ def longname(self) -> str: return f'{self.parent.longname}.{self.name}' @setter - def metadata(self, metadata: 'Mapping|None') -> Metadata: + def metadata(self, metadata: 'Mapping[str, str]|None') -> Metadata: """Free suite metadata as dictionary-like ``Metadata`` object.""" return Metadata(metadata) @setter - def suites(self, suites: 'Sequence[TestSuite|Mapping]') -> 'TestSuites': + def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> 'TestSuites': return TestSuites(self.__class__, self, suites) @setter - def tests(self, tests: 'Sequence[TestCase|Mapping]') -> TestCases: + def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases: return TestCases(self.test_class, self, tests) @property @@ -145,7 +146,7 @@ def setup(self) -> Keyword: return self._setup @setup.setter - def setup(self, setup: 'Keyword|Mapping|None'): + def setup(self, setup: 'Keyword|DataDict|None'): self._setup = create_fixture(setup, self, Keyword.SETUP) @property @@ -173,7 +174,7 @@ def teardown(self) -> Keyword: return self._teardown @teardown.setter - def teardown(self, teardown: 'Keyword|Mapping|None'): + def teardown(self, teardown: 'Keyword|DataDict|None'): self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) @property @@ -333,5 +334,5 @@ class TestSuites(ItemList[TestSuite]): def __init__(self, suite_class: Type[TestSuite] = TestSuite, parent: 'TestSuite|None' = None, - suites: 'Sequence[TestSuite|Mapping]' = ()): + suites: 'Sequence[TestSuite|DataDict]' = ()): super().__init__(suite_class, {'parent': parent}, suites) From 01d547822b460cda793b0c25ead5af3e92708130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 9 May 2023 15:43:40 +0300 Subject: [PATCH 0554/1592] Make FixtureDict optional items properly optional. FixtureDict is used by TestDefaults that is part of the Parser API (#1283). Looking forward for nicer TypedDict syntax for making items optional in Python 3.11. --- src/robot/running/builder/settings.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index 6b08ca4984d..3b2d58966ad 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -23,14 +23,17 @@ from typing import TypedDict - class FixtureDict(TypedDict): + class OptionalItems(TypedDict, total=False): + args: 'Sequence[str]' + lineno: int + + + class FixtureDict(OptionalItems): """Dictionary containing setup or teardown info. :attr:`args` and :attr:`lineno` are optional. """ name: str - args: 'Sequence[str]' - lineno: int else: class FixtureDict(dict): From c70311c0dad7bc4e3cd9a03f1f18a504c6b81e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 9 May 2023 17:18:08 +0300 Subject: [PATCH 0555/1592] Parsing API (#1283): Support multipart extensions like `.feature.md`. Also support such paths with `TestSuite.name_from_source`. --- atest/robot/parsing/custom_parsers.robot | 8 +++ atest/testdata/parsing/custom/CustomParser.py | 1 + .../parsing/custom/tests.multi.part.ext | 2 + .../ParserInterface.rst | 12 ++-- src/robot/model/testsuite.py | 51 +++++++++++++- src/robot/parsing/suitestructure.py | 69 +++++++++++++------ utest/model/test_testsuite.py | 31 ++++++--- 7 files changed, 139 insertions(+), 35 deletions(-) create mode 100644 atest/testdata/parsing/custom/tests.multi.part.ext diff --git a/atest/robot/parsing/custom_parsers.robot b/atest/robot/parsing/custom_parsers.robot index ce0ba787d75..ea512d4823c 100644 --- a/atest/robot/parsing/custom_parsers.robot +++ b/atest/robot/parsing/custom_parsers.robot @@ -22,6 +22,14 @@ Directory with init Run Tests --parser ${DIR}/CustomParser.py:init=True ${DIR} Validate Directory Suite init=True +Extension with multiple parts + Run Tests --parser ${DIR}/CustomParser.py:multi.part.ext ${DIR} + Validate Suite ${SUITE} Custom ${DIR} custom=False + ... Passing=PASS + ... Test in Robot file=PASS + Validate Suite ${SUITE.suites[0]} Tests ${DIR}/tests.multi.part.ext + ... Passing=PASS + Override Robot parser Run Tests --parser ${DIR}/CustomParser.py:.robot ${DIR}/tests.robot Validate Suite ${SUITE} Tests ${DIR}/tests.robot diff --git a/atest/testdata/parsing/custom/CustomParser.py b/atest/testdata/parsing/custom/CustomParser.py index 3b489d02d9d..61ba62cb734 100644 --- a/atest/testdata/parsing/custom/CustomParser.py +++ b/atest/testdata/parsing/custom/CustomParser.py @@ -26,6 +26,7 @@ def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: if self.bad_return: return 'bad' suite = custom.parse(source) + suite.name = TestSuite.name_from_source(source, self.extension) for test in suite.tests: defaults.set_to(test) return suite diff --git a/atest/testdata/parsing/custom/tests.multi.part.ext b/atest/testdata/parsing/custom/tests.multi.part.ext new file mode 100644 index 00000000000..23dac5e9f4b --- /dev/null +++ b/atest/testdata/parsing/custom/tests.multi.part.ext @@ -0,0 +1,2 @@ +Passing + No operation diff --git a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst index 78790c28c9c..5e2f70f76e0 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst @@ -35,13 +35,16 @@ what attributes and methods they must contain. This attribute specifies what file extension or extensions the parser supports. Both `EXTENSION` and `extension` names are accepted, and the former has precedence -if both exist. That attribute can be either a string or a sequence of strings. +if both exist. The attribute can be either a string or a sequence of strings. Extensions are case-insensitive and can be specified with or without the leading dot. If a parser is implemented as a class, it is possible to set this attribute either as a class attribute or as an instance attribute. -If a parser supports the :file:`.robot` extension, it will be used for parsing -these files instead of the standard parser. +Also extensions containing multiple parts like :file:`.example.ext` or +:file:`.robot.zip` are supported. + +.. note:: If a parser supports the :file:`.robot` extension, it will be used + for parsing these files instead of the standard parser. `parse` method ~~~~~~~~~~~~~~ @@ -128,7 +131,8 @@ from each line it contains. self.extension = extension def parse(self, source: Path) -> TestSuite: - suite = TestSuite(TestSuite.name_from_source(source), source=source) + name = TestSuite.name_from_source(source, self.extension) + suite = TestSuite(name, source=source) for line in source.read_text().splitlines(): test = suite.tests.create(name=line) test.body.create_keyword(name='Log', args=['Hello!']) diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 3a02d9ccbc3..a2d073b4573 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Iterator, Sequence, Type -from robot.utils import setter +from robot.utils import seq2str, setter from .configurer import SuiteConfigurer from .filter import Filter, EmptySuiteRemover @@ -59,17 +59,62 @@ def __init__(self, name: str = '', doc: str = '', self._my_visitors: 'list[SuiteVisitor]' = [] @staticmethod - def name_from_source(source: 'Path|str|None') -> str: + def name_from_source(source: 'Path|str|None', extension: Sequence[str] = ()) -> str: + """Create suite name based on the given ``source``. + + This method is used by Robot Framework itself when it builds suites. + External parsers and other tools that want to produce suites with + names matching names created by Robot Framework can use this method as + well. This method is also used if :attr:`name` is not set and someone + accessess it. + + The algorithm is as follows: + + - If the source is ``None`` or empty, return an empty string. + - Get the base name of the source. Read more below. + - Remove possible prefix separated with ``__``. + - Convert underscrores to spaces. + - If the name is all lower case, title case it. + + The base name of files is got by calling `Path.stem`__ that drops + the file extension. It typically works fine, but gives wrong result + if the extension has multiple parts like in ``tests.robot.zip``. + That problem can be avoided by giving valid file extension or extensions + as the optional ``extension`` argument. + + Examples:: + + TestSuite.name_from_source(source) + TestSuite.name_from_source(source, extension='.robot.zip') + TestSuite.name_from_source(source, ('.robot', '.robot.zip')) + + __ https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.stem + """ if not source: return '' if not isinstance(source, Path): source = Path(source) - name = source.name if source.is_dir() else source.stem + name = TestSuite._get_base_name(source, extension) if '__' in name: name = name.split('__', 1)[1] or name name = name.replace('_', ' ').strip() return name.title() if name.islower() else name + @staticmethod + def _get_base_name(path: Path, extensions: Sequence[str]) -> str: + if path.is_dir(): + return path.name + if not extensions: + return path.stem + if isinstance(extensions, str): + extensions = [extensions] + for ext in extensions: + ext = '.' + ext.lower().lstrip('.') + if path.name.endswith(ext): + return path.name[:-len(ext)] + raise ValueError(f"File '{path}' does not have extension " + f"{seq2str(extensions, lastsep=' or ')}.") + @property def _visitors(self) -> 'list[SuiteVisitor]': parent_visitors = self.parent._visitors if self.parent else [] diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 80969dcea05..46990d5fcf5 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -15,7 +15,7 @@ from abc import ABC, abstractmethod from pathlib import Path -from typing import Iterable, Sequence +from typing import Iterable, Iterator, Sequence from robot.errors import DataError from robot.model import SuiteNamePatterns @@ -23,20 +23,50 @@ from robot.utils import get_error_message +class ValidExtensions: + + def __init__(self, extensions: Iterable[str]): + self.extensions = {ext.lstrip('.').lower() for ext in extensions} + + def match(self, path: Path) -> bool: + for ext in self._extensions_from(path): + if ext in self.extensions: + return True + return False + + def get_extension(self, path: Path) -> str: + for ext in self._extensions_from(path): + if ext in self.extensions: + return ext + return path.suffix.lower()[1:] + + def _extensions_from(self, path: Path) -> Iterator[str]: + suffixes = path.suffixes + while suffixes: + yield ''.join(suffixes).lower()[1:] + suffixes.pop(0) + + class SuiteStructure(ABC): source: 'Path|None' init_file: 'Path|None' children: 'list[SuiteStructure]|None' - def __init__(self, source: 'Path|None', init_file: 'Path|None' = None, + def __init__(self, extensions: ValidExtensions, source: 'Path|None', + init_file: 'Path|None' = None, children: 'Sequence[SuiteStructure]|None' = None): + self._extensions = extensions self.source = source self.init_file = init_file self.children = list(children) if children is not None else None @property - @abstractmethod def extension(self) -> 'str|None': + source = self._get_source_file() + return self._extensions.get_extension(source) if source else None + + @abstractmethod + def _get_source_file(self) -> 'Path|None': raise NotImplementedError @abstractmethod @@ -47,12 +77,11 @@ def visit(self, visitor: 'SuiteStructureVisitor'): class SuiteFile(SuiteStructure): source: Path - def __init__(self, source: Path): - super().__init__(source) + def __init__(self, extensions: ValidExtensions, source: Path): + super().__init__(extensions, source) - @property - def extension(self) -> str: - return self.source.suffix[1:].lower() + def _get_source_file(self) -> Path: + return self.source def visit(self, visitor: 'SuiteStructureVisitor'): visitor.visit_file(self) @@ -61,18 +90,18 @@ def visit(self, visitor: 'SuiteStructureVisitor'): class SuiteDirectory(SuiteStructure): children: 'list[SuiteStructure]' - def __init__(self, source: 'Path|None' = None, init_file: 'Path|None' = None, + def __init__(self, extensions: ValidExtensions, source: 'Path|None' = None, + init_file: 'Path|None' = None, children: Sequence[SuiteStructure] = ()): - super().__init__(source, init_file, children) + super().__init__(extensions, source, init_file, children) + + def _get_source_file(self) -> 'Path|None': + return self.init_file @property def is_multi_source(self) -> bool: return self.source is None - @property - def extension(self) -> 'str|None': - return self.init_file.suffix[1:].lower() if self.init_file else None - def add(self, child: 'SuiteStructure'): self.children.append(child) @@ -104,7 +133,7 @@ class SuiteStructureBuilder: def __init__(self, extensions: Iterable[str] = ('.robot', '.rbt'), included_suites: Iterable[str] = ()): - self.extensions = {'.' + ext.lstrip('.').lower() for ext in extensions} + self.extensions = ValidExtensions(extensions) self.included_suites = SuiteNamePatterns( self._create_included_suites(included_suites) ) @@ -123,12 +152,12 @@ def build(self, *paths: Path) -> SuiteStructure: def _build(self, path: Path, included_suites: SuiteNamePatterns) -> SuiteStructure: if path.is_file(): - return SuiteFile(path) + return SuiteFile(self.extensions, path) return self._build_directory(path, included_suites) def _build_directory(self, path: Path, included_suites: SuiteNamePatterns) -> SuiteStructure: - structure = SuiteDirectory(path) + structure = SuiteDirectory(self.extensions, path) # If a directory is included, also its children are included. if self._is_suite_included(path.name, included_suites): included_suites = SuiteNamePatterns() @@ -159,7 +188,7 @@ def _list_dir(self, path: Path) -> 'list[Path]': def _is_init_file(self, path: Path) -> bool: return (path.stem.lower() == '__init__' - and path.suffix.lower() in self.extensions + and self.extensions.match(path) and path.is_file()) def _is_included(self, path: Path, included_suites: SuiteNamePatterns) -> bool: @@ -169,12 +198,12 @@ def _is_included(self, path: Path, included_suites: SuiteNamePatterns) -> bool: return path.name not in self.ignored_dirs if not path.is_file(): return False - if path.suffix.lower() not in self.extensions: + if not self.extensions.match(path): return False return self._is_suite_included(path.stem, included_suites) def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: - structure = SuiteDirectory() + structure = SuiteDirectory(self.extensions) for path in paths: if self._is_init_file(path): if structure.init_file: diff --git a/utest/model/test_testsuite.py b/utest/model/test_testsuite.py index 49480fbf11f..004e456551c 100644 --- a/utest/model/test_testsuite.py +++ b/utest/model/test_testsuite.py @@ -44,18 +44,33 @@ def test_name_from_source(self): for inp, exp in [(None, ''), ('', ''), ('name', 'Name'), ('name.robot', 'Name'), ('naMe', 'naMe'), ('na_me', 'Na Me'), ('na_M_e_', 'na M e'), ('prefix__name', 'Name'), ('__n', 'N'), ('naMe__', 'naMe')]: - assert_equal(TestSuite(source=inp).name, exp) + assert_equal(TestSuite.name_from_source(inp), exp) + suite = TestSuite(source=inp) + assert_equal(suite.name, exp) + suite.suites.create(name='xxx') + assert_equal(suite.name, exp or 'xxx') + suite.name = 'new name' + assert_equal(suite.name, 'new name') if inp: assert_equal(TestSuite(source=Path(inp)).name, exp) assert_equal(TestSuite(source=Path(inp).resolve()).name, exp) - def test_suite_name_from_source(self): - suite = TestSuite(source='example.robot') - assert_equal(suite.name, 'Example') - suite.suites.create(name='child') - assert_equal(suite.name, 'Example') - suite.name = 'new name' - assert_equal(suite.name, 'new name') + def test_name_from_source_with_extensions(self): + for ext, exp in [('z', 'X.Y'), ('.z', 'X.Y'), ('y.z', 'X'), + (['x', 'y', 'z'], 'X.Y')]: + assert_equal(TestSuite.name_from_source('x.y.z', ext), exp) + + def test_name_from_source_with_bad_extensions(self): + assert_raises_with_msg( + ValueError, + "File 'x.y' does not have extension 'z'.", + TestSuite.name_from_source, 'x.y', extension='z' + ) + assert_raises_with_msg( + ValueError, + "File 'x.y' does not have extension 'a', 'b' or 'c'.", + TestSuite.name_from_source, 'x.y', ('a', 'b', 'c') + ) def test_suite_name_from_child_suites(self): suite = TestSuite() From 9325f44c60634d1bab77ddc8226c46147d7296f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 9 May 2023 17:35:27 +0300 Subject: [PATCH 0556/1592] Workaround Path.is_file() possibly raising on Windows, again --- src/robot/model/modelobject.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index 6fce1bfc9ee..b8f9300c092 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -214,7 +214,10 @@ def _is_path(self, source): return True if not isinstance(source, str) or '{' in source: return False - return Path(source).is_file() + try: + return Path(source).is_file() + except OSError: # Can happen on Windows w/ Python < 3.10. + return False class JsonDumper: From cb7e22c39e4a0d96028a1c40c0baa7253b5599e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 11 May 2023 22:31:37 +0300 Subject: [PATCH 0557/1592] Add typing to robot.running.model. Part of #4570. Typing from the base model still leaks in some places like `TestSuite.tests`. Need to handle that separately with generics or otherwise. --- src/robot/model/__init__.py | 2 +- src/robot/model/body.py | 22 +- src/robot/model/control.py | 59 +++--- src/robot/model/keyword.py | 6 +- src/robot/model/testcase.py | 11 +- src/robot/model/testsuite.py | 6 +- src/robot/running/model.py | 362 ++++++++++++++++++-------------- utest/running/test_imports.py | 6 +- utest/running/test_run_model.py | 14 +- utest/running/test_running.py | 4 +- 10 files changed, 275 insertions(+), 217 deletions(-) diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index c6e454b0fff..6fb53c1a1ef 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -32,7 +32,7 @@ from .itemlist import ItemList from .keyword import Keyword, Keywords from .message import Message, Messages -from .modelobject import ModelObject +from .modelobject import DataDict, ModelObject from .modifier import ModelModifier from .namepatterns import SuiteNamePatterns, TestNamePatterns from .statistics import Statistics diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 7df9977b9ae..07c0f707847 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -14,12 +14,13 @@ # limitations under the License. import re -from typing import Any, Callable, cast, Iterable, Type, TYPE_CHECKING, TypeVar +from typing import Any, Callable, cast, Iterable, Type, TYPE_CHECKING, TypeVar, Union from .itemlist import ItemList from .modelobject import DataDict, full_name, ModelObject if TYPE_CHECKING: + from robot.running.model import UserKeyword, ResourceFile from .control import (Break, Continue, Error, For, If, IfBranch, Return, Try, TryBranch, While) from .keyword import Keyword @@ -28,6 +29,8 @@ from .testsuite import TestSuite +BodyItemParent = Union['TestSuite', 'TestCase', 'UserKeyword', 'For', 'If', 'IfBranch', + 'Try', 'TryBranch', 'While', None] T = TypeVar("T", bound="BodyItem") @@ -68,13 +71,13 @@ def id(self) -> 'str|None': - With :class:`~robot.model.control.If` and :class:`~robot.model.control.Try` instances representing IF/TRY structure roots. """ - # This algorithm must match the id creation algorithm in the JavaScript side - # or linking to warnings and errors won't work. - if not self.parent: - return 'k1' return self._get_id(self.parent) - def _get_id(self, parent: 'TestSuite|TestCase|BodyItem') -> str: + def _get_id(self, parent: 'BodyItemParent|ResourceFile') -> str: + if not parent: + return 'k1' + # This algorithm must match the id creation algorithm in the JavaScript side + # or linking to warnings and errors won't work. steps = [] if getattr(parent, 'has_setup', False): steps.append(parent.setup) # type: ignore - Use Protocol with RF 7. @@ -85,7 +88,7 @@ def _get_id(self, parent: 'TestSuite|TestCase|BodyItem') -> str: if getattr(parent, 'has_teardown', False): steps.append(parent.teardown) # type: ignore - Use Protocol with RF 7. index = steps.index(self) if self in steps else len(steps) - parent_id = parent.id + parent_id = getattr(parent, 'id', None) return f'{parent_id}-k{index + 1}' if parent_id else f'k{index + 1}' def to_dict(self) -> DataDict: @@ -120,7 +123,7 @@ class BaseBody(ItemList[BodyItem]): message_class = None error_class = None - def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None, + def __init__(self, parent: BodyItemParent = None, items: 'Iterable[BodyItem|DataDict]' = ()): super().__init__(BodyItem, {'parent': parent}, items) @@ -264,9 +267,8 @@ class Branches(BaseBody): __slots__ = ['branch_class'] def __init__(self, branch_class: 'Type[IfBranch|TryBranch]', - parent: 'TestSuite|TestCase|BodyItem|None' = None, + parent: BodyItemParent = None, items: 'Iterable[BodyItem|DataDict]' = ()): - self.branch_class = branch_class super().__init__(parent, items) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 4b1d7ac01a1..4f5e1214340 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -14,17 +14,15 @@ # limitations under the License. import sys -from typing import Any, cast, Iterable, Sequence +from typing import Any, cast, Sequence if sys.version_info >= (3, 8): from typing import Literal from robot.utils import setter -from .body import Body, BodyItem, Branches +from .body import Body, BodyItem, BodyItemParent, Branches from .keyword import Keywords from .modelobject import DataDict -from .testcase import TestCase -from .testsuite import TestSuite from .visitor import SuiteVisitor @@ -42,9 +40,11 @@ class For(BodyItem): def __init__(self, variables: Sequence[str] = (), flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', - values: Sequence[str] = (), start: 'str|None' = None, - mode: 'str|None' = None, fill: 'str|None' = None, - parent: 'TestSuite|TestCase|BodyItem|None' = None): + values: Sequence[str] = (), + start: 'str|None' = None, + mode: 'str|None' = None, + fill: 'str|None' = None, + parent: BodyItemParent = None): self.variables = tuple(variables) self.flavor = flavor self.values = tuple(values) @@ -55,7 +55,7 @@ def __init__(self, variables: Sequence[str] = (), self.body = () @setter - def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property @@ -104,9 +104,11 @@ class While(BodyItem): repr_args = ('condition', 'limit', 'on_limit', 'on_limit_message') __slots__ = ['condition', 'limit', 'on_limit', 'on_limit_message'] - def __init__(self, condition: 'str|None' = None, limit: 'str|None' = None, - on_limit: 'str|None' = None, on_limit_message: 'str|None' = None, - parent: 'TestSuite|TestCase|BodyItem|None' = None): + def __init__(self, condition: 'str|None' = None, + limit: 'str|None' = None, + on_limit: 'str|None' = None, + on_limit_message: 'str|None' = None, + parent: BodyItemParent = None): self.condition = condition self.on_limit = on_limit self.limit = limit @@ -115,7 +117,7 @@ def __init__(self, condition: 'str|None' = None, limit: 'str|None' = None, self.body = () @setter - def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) def visit(self, visitor: SuiteVisitor): @@ -153,15 +155,16 @@ class IfBranch(BodyItem): repr_args = ('type', 'condition') __slots__ = ['type', 'condition'] - def __init__(self, type: str = BodyItem.IF, condition: 'str|None' = None, - parent: 'TestSuite|TestCase|BodyItem|None' = None): + def __init__(self, type: str = BodyItem.IF, + condition: 'str|None' = None, + parent: BodyItemParent = None): self.type = type self.condition = condition self.parent = parent self.body = () @setter - def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property @@ -200,12 +203,12 @@ class If(BodyItem): branches_class = Branches __slots__ = [] - def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None): + def __init__(self, parent: BodyItemParent = None): self.parent = parent self.body = () @setter - def body(self, branches: 'Iterable[BodyItem|DataDict]') -> Branches: + def body(self, branches: 'Sequence[BodyItem|DataDict]') -> Branches: return self.branches_class(self.branch_class, self, branches) @property @@ -227,9 +230,11 @@ class TryBranch(BodyItem): repr_args = ('type', 'patterns', 'pattern_type', 'variable') __slots__ = ['type', 'patterns', 'pattern_type', 'variable'] - def __init__(self, type: str = BodyItem.TRY, patterns: Sequence[str] = (), - pattern_type: 'str|None' = None, variable: 'str|None' = None, - parent: 'TestSuite|TestCase|BodyItem|None' = None): + def __init__(self, type: str = BodyItem.TRY, + patterns: Sequence[str] = (), + pattern_type: 'str|None' = None, + variable: 'str|None' = None, + parent: BodyItemParent = None): if (patterns or pattern_type or variable) and type != BodyItem.EXCEPT: raise TypeError(f"'{type}' branches do not accept patterns or variables.") self.type = type @@ -240,7 +245,7 @@ def __init__(self, type: str = BodyItem.TRY, patterns: Sequence[str] = (), self.body = () @setter - def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property @@ -288,12 +293,12 @@ class Try(BodyItem): branches_class = Branches __slots__ = [] - def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None): + def __init__(self, parent: BodyItemParent = None): self.parent = parent self.body = () @setter - def body(self, branches: 'Iterable[TryBranch|DataDict]') -> Branches: + def body(self, branches: 'Sequence[TryBranch|DataDict]') -> Branches: return self.branches_class(self.branch_class, self, branches) @property @@ -341,7 +346,7 @@ class Return(BodyItem): __slots__ = ['values'] def __init__(self, values: Sequence[str] = (), - parent: 'TestSuite|TestCase|BodyItem|None' = None): + parent: BodyItemParent = None): self.values = tuple(values) self.parent = parent @@ -359,7 +364,7 @@ class Continue(BodyItem): type = BodyItem.CONTINUE __slots__ = [] - def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None): + def __init__(self, parent: BodyItemParent = None): self.parent = parent def visit(self, visitor: SuiteVisitor): @@ -375,7 +380,7 @@ class Break(BodyItem): type = BodyItem.BREAK __slots__ = [] - def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None): + def __init__(self, parent: BodyItemParent = None): self.parent = parent def visit(self, visitor: SuiteVisitor): @@ -396,7 +401,7 @@ class Error(BodyItem): __slots__ = ['values'] def __init__(self, values: Sequence[str] = (), - parent: 'TestSuite|TestCase|BodyItem|None' = None): + parent: BodyItemParent = None): self.values = tuple(values) self.parent = parent diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index 2ca3ef0257d..a42e251011f 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -16,7 +16,7 @@ from typing import cast, Sequence, Type, TYPE_CHECKING import warnings -from .body import Body, BodyItem +from .body import Body, BodyItem, BodyItemParent from .itemlist import ItemList from .modelobject import DataDict @@ -38,7 +38,7 @@ class Keyword(BodyItem): def __init__(self, name: str = '', args: Sequence[str] = (), assign: Sequence[str] = (), type: str = BodyItem.KEYWORD, - parent: 'TestSuite|TestCase|BodyItem|None' = None): + parent: BodyItemParent = None): self._name = name self.args = args self.assign = assign @@ -92,7 +92,7 @@ class Keywords(ItemList[BodyItem]): "Use 'body', 'setup' or 'teardown' instead." ) - def __init__(self, parent: 'TestSuite|TestCase|BodyItem|None' = None, + def __init__(self, parent: BodyItemParent = None, keywords: Sequence[BodyItem] = ()): warnings.warn(self.deprecation_message, UserWarning) ItemList.__init__(self, object, {'parent': parent}) diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index 5c3c5274641..ab1ccdd719e 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -14,7 +14,7 @@ # limitations under the License. from pathlib import Path -from typing import Any, Iterable, Sequence, Type, TYPE_CHECKING +from typing import Any, Sequence, Type, TYPE_CHECKING from robot.utils import setter @@ -41,8 +41,11 @@ class TestCase(ModelObject): repr_args = ('name',) __slots__ = ['parent', 'name', 'doc', 'timeout', 'lineno', '_setup', '_teardown'] - def __init__(self, name: str = '', doc: str = '', tags: Sequence[str] = (), - timeout: 'str|None' = None, lineno: 'int|None' = None, + def __init__(self, name: str = '', + doc: str = '', + tags: Sequence[str] = (), + timeout: 'str|None' = None, + lineno: 'int|None' = None, parent: 'TestSuite|None' = None): self.name = name self.doc = doc @@ -55,7 +58,7 @@ def __init__(self, name: str = '', doc: str = '', tags: Sequence[str] = (), self._teardown: 'Keyword|None' = None @setter - def body(self, body: 'Iterable[BodyItem|DataDict]') -> Body: + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Test body as a :class:`~robot.model.body.Body` object.""" return self.body_class(self, body) diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index a2d073b4573..ef78b8603dc 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -42,9 +42,11 @@ class TestSuite(ModelObject): repr_args = ('name',) __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', '_my_visitors'] - def __init__(self, name: str = '', doc: str = '', + def __init__(self, name: str = '', + doc: str = '', metadata: 'Mapping[str, str]|None' = None, - source: 'Path|str|None' = None, rpa: 'bool|None' = None, + source: 'Path|str|None' = None, + rpa: 'bool|None' = None, parent: 'TestSuite|None' = None): self._name = name self.doc = doc diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 2933c3aa433..492ccb63a10 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -34,14 +34,17 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface """ +import sys import warnings from pathlib import Path -from typing import TYPE_CHECKING +from typing import Any, cast, Mapping, Sequence, TYPE_CHECKING, Union +if sys.version_info >= (3, 8): + from typing import Literal from robot import model from robot.conf import RobotSettings from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword -from robot.model import BodyItem, create_fixture, Keywords, ModelObject +from robot.model import BodyItem, create_fixture, DataDict, Keywords, ModelObject from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, Error as ErrorResult, Return as ReturnResult) @@ -56,12 +59,25 @@ from .builder import TestDefaults +BodyItemParent = Union['TestSuite', 'TestCase', 'UserKeyword', 'For', 'If', 'IfBranch', + 'Try', 'TryBranch', 'While', None] + + class Body(model.Body): - __slots__ = [] + __slots__ = () + + +class WithSource: + __slots__ = () + parent: BodyItemParent + + @property + def source(self) -> 'Path|None': + return self.parent.source if self.parent is not None else None @Body.register -class Keyword(model.Keyword): +class Keyword(model.Keyword, WithSource): """Represents an executable keyword call. A keyword call consists only of a keyword name, arguments and possible @@ -75,16 +91,16 @@ class Keyword(model.Keyword): """ __slots__ = ['lineno'] - def __init__(self, name='', args=(), assign=(), type=BodyItem.KEYWORD, parent=None, - lineno=None): + def __init__(self, name: str = '', + args: Sequence[str] = (), + assign: Sequence[str] = (), + type: str = BodyItem.KEYWORD, + parent: BodyItemParent = None, + lineno: 'int|None' = None): super().__init__(name, args, assign, type, parent) self.lineno = lineno - @property - def source(self): - return self.parent.source if self.parent is not None else None - - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -95,21 +111,24 @@ def run(self, context, run=True, templated=None): @Body.register -class For(model.For): +class For(model.For, WithSource): __slots__ = ['lineno', 'error'] body_class = Body - def __init__(self, variables=(), flavor='IN', values=(), start=None, mode=None, - fill=None, parent=None, lineno=None, error=None): + def __init__(self, variables: Sequence[str] = (), + flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', + values: Sequence[str] = (), + start: 'str|None' = None, + mode: 'str|None' = None, + fill: 'str|None' = None, + parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: 'str|None' = None): super().__init__(variables, flavor, values, start, mode, fill, parent) self.lineno = lineno self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -122,21 +141,22 @@ def run(self, context, run=True, templated=False): @Body.register -class While(model.While): +class While(model.While, WithSource): __slots__ = ['lineno', 'error'] body_class = Body - def __init__(self, condition=None, limit=None, on_limit_message=None, - parent=None, lineno=None, error=None): - super().__init__(condition, limit, on_limit_message, parent) + def __init__(self, condition: 'str|None' = None, + limit: 'str|None' = None, + on_limit: 'str|None' = None, + on_limit_message: 'str|None' = None, + parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: 'str|None' = None): + super().__init__(condition, limit, on_limit, on_limit_message, parent) self.lineno = lineno self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -148,19 +168,18 @@ def run(self, context, run=True, templated=False): return WhileRunner(context, run, templated).run(self) -class IfBranch(model.IfBranch): +class IfBranch(model.IfBranch, WithSource): __slots__ = ['lineno'] body_class = Body - def __init__(self, type=BodyItem.IF, condition=None, parent=None, lineno=None): + def __init__(self, type: str = BodyItem.IF, + condition: 'str|None' = None, + parent: BodyItemParent = None, + lineno: 'int|None' = None): super().__init__(type, condition, parent) self.lineno = lineno - @property - def source(self): - return self.parent.source if self.parent is not None else None - - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -168,23 +187,21 @@ def to_dict(self): @Body.register -class If(model.If): +class If(model.If, WithSource): __slots__ = ['lineno', 'error'] branch_class = IfBranch - def __init__(self, parent=None, lineno=None, error=None): + def __init__(self, parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: 'str|None' = None): super().__init__(parent) self.lineno = lineno self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - def run(self, context, run=True, templated=False): return IfRunner(context, run, templated).run(self) - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -193,20 +210,20 @@ def to_dict(self): return data -class TryBranch(model.TryBranch): +class TryBranch(model.TryBranch, WithSource): __slots__ = ['lineno'] body_class = Body - def __init__(self, type=BodyItem.TRY, patterns=(), pattern_type=None, - variable=None, parent=None, lineno=None): + def __init__(self, type: str = BodyItem.TRY, + patterns: Sequence[str] = (), + pattern_type: 'str|None' = None, + variable: 'str|None' = None, + parent: BodyItemParent = None, + lineno: 'int|None' = None): super().__init__(type, patterns, pattern_type, variable, parent) self.lineno = lineno - @property - def source(self): - return self.parent.source if self.parent is not None else None - - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -214,23 +231,21 @@ def to_dict(self): @Body.register -class Try(model.Try): +class Try(model.Try, WithSource): __slots__ = ['lineno', 'error'] branch_class = TryBranch - def __init__(self, parent=None, lineno=None, error=None): + def __init__(self, parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: 'str|None' = None): super().__init__(parent) self.lineno = lineno self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - def run(self, context, run=True, templated=False): return TryRunner(context, run, templated).run(self) - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -240,18 +255,17 @@ def to_dict(self): @Body.register -class Return(model.Return): +class Return(model.Return, WithSource): __slots__ = ['lineno', 'error'] - def __init__(self, values=(), parent=None, lineno=None, error=None): + def __init__(self, values: Sequence[str] = (), + parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: 'str|None' = None): super().__init__(values, parent) self.lineno = lineno self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - def run(self, context, run=True, templated=False): with StatusReporter(self, ReturnResult(self.values), context, run): if run: @@ -260,7 +274,7 @@ def run(self, context, run=True, templated=False): if not context.dry_run: raise ReturnFromKeyword(self.values) - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -270,18 +284,16 @@ def to_dict(self): @Body.register -class Continue(model.Continue): +class Continue(model.Continue, WithSource): __slots__ = ['lineno', 'error'] - def __init__(self, parent=None, lineno=None, error=None): + def __init__(self, parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: 'str|None' = None): super().__init__(parent) self.lineno = lineno self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - def run(self, context, run=True, templated=False): with StatusReporter(self, ContinueResult(), context, run): if run: @@ -290,7 +302,7 @@ def run(self, context, run=True, templated=False): if not context.dry_run: raise ContinueLoop() - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -300,18 +312,16 @@ def to_dict(self): @Body.register -class Break(model.Break): +class Break(model.Break, WithSource): __slots__ = ['lineno', 'error'] - def __init__(self, parent=None, lineno=None, error=None): + def __init__(self, parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: 'str|None' = None): super().__init__(parent) self.lineno = lineno self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - def run(self, context, run=True, templated=False): with StatusReporter(self, BreakResult(), context, run): if run: @@ -320,7 +330,7 @@ def run(self, context, run=True, templated=False): if not context.dry_run: raise BreakLoop() - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -330,24 +340,23 @@ def to_dict(self): @Body.register -class Error(model.Error): +class Error(model.Error, WithSource): __slots__ = ['lineno', 'error'] - def __init__(self, values=(), parent=None, lineno=None, error=None): + def __init__(self, values: Sequence[str] = (), + parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: str = ''): super().__init__(values, parent) self.lineno = lineno self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - def run(self, context, run=True, templated=False): with StatusReporter(self, ErrorResult(self.values), context, run): if run: raise DataError(self.error) - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno @@ -365,19 +374,21 @@ class TestCase(model.TestCase): body_class = Body #: Internal usage only. fixture_class = Keyword #: Internal usage only. - def __init__(self, name='', doc='', tags=None, timeout=None, template=None, - lineno=None, error=None): - super().__init__(name, doc, tags, timeout, lineno) + def __init__(self, name: str = '', + doc: str = '', + tags: Sequence[str] = (), + timeout: 'str|None' = None, + lineno: 'int|None' = None, + parent: 'TestSuite|None' = None, + template: 'str|None' = None, + error: 'str|None' = None): + super().__init__(name, doc, tags, timeout, lineno, parent) #: Name of the keyword that has been used as a template when building the test. # ``None`` if template is not used. self.template = template self.error = error - @property - def source(self): - return self.parent.source if self.parent is not None else None - - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() if self.template: data['template'] = self.template @@ -395,8 +406,13 @@ class TestSuite(model.TestSuite): test_class = TestCase #: Internal usage only. fixture_class = Keyword #: Internal usage only. - def __init__(self, name='', doc='', metadata=None, source=None, rpa=None): - super().__init__(name, doc, metadata, source, rpa) + def __init__(self, name: str = '', + doc: str = '', + metadata: 'Mapping[str, str]|None' = None, + source: 'Path|str|None' = None, + rpa: 'bool|None' = None, + parent: 'TestSuite|None' = None): + super().__init__(name, doc, metadata, source, rpa, parent) #: :class:`ResourceFile` instance containing imports, variables and #: keywords the suite owns. When data is parsed from the file system, #: this data comes from the same test case file that creates the suite. @@ -476,8 +492,8 @@ def from_string(cls, string: str, *, defaults: 'TestDefaults|None' = None, model = get_model(string, data_only=True, **config) return cls.from_model(model, defaults=defaults) - def configure(self, randomize_suites=False, randomize_tests=False, - randomize_seed=None, **options): + def configure(self, randomize_suites: bool = False, randomize_tests: bool = False, + randomize_seed: 'int|None' = None, **options): """A shortcut to configure a suite using one method call. Can only be used with the root test suite. @@ -496,10 +512,11 @@ def configure(self, randomize_suites=False, randomize_tests=False, and keywords have to make it possible to set multiple attributes in one call. """ - model.TestSuite.configure(self, **options) + super().configure(**options) self.randomize(randomize_suites, randomize_tests, randomize_seed) - def randomize(self, suites=True, tests=True, seed=None): + def randomize(self, suites: bool = True, tests: bool = True, + seed: 'int|None' = None): """Randomizes the order of suites and/or tests, recursively. :param suites: Boolean controlling should suites be randomized. @@ -580,7 +597,7 @@ def run(self, settings=None, **options): output.close(runner.result) return runner.result - def to_dict(self): + def to_dict(self) -> DataDict: data = super().to_dict() data['resource'] = self.resource.to_dict() return data @@ -589,29 +606,29 @@ def to_dict(self): class Variable(ModelObject): repr_args = ('name', 'value') - def __init__(self, name, value=(), parent=None, lineno=None, error=None): + def __init__(self, name: str = '', + value: Sequence[str] = (), + parent: 'ResourceFile|None' = None, + lineno: 'int|None' = None, + error: 'str|None' = None): self.name = name - self.value = value + self.value = tuple(value) self.parent = parent self.lineno = lineno self.error = error @property - def source(self): + def source(self) -> 'Path|None': return self.parent.source if self.parent is not None else None - def report_invalid_syntax(self, message, level='ERROR'): + def report_invalid_syntax(self, message: str, level: str = 'ERROR'): source = self.source or '<unknown>' line = f' on line {self.lineno}' if self.lineno else '' LOGGER.write(f"Error in file '{source}'{line}: " f"Setting variable '{self.name}' failed: {message}", level) - @classmethod - def from_dict(cls, data): - return cls(**data) - - def to_dict(self): - data = {'name': self.name, 'value': list(self.value)} + def to_dict(self) -> DataDict: + data = {'name': self.name, 'value': self.value} if self.lineno: data['lineno'] = self.lineno if self.error: @@ -623,8 +640,10 @@ class ResourceFile(ModelObject): repr_args = ('source',) __slots__ = ('_source', 'parent', 'doc') - def __init__(self, source=None, parent=None, doc=''): - self._source = source + def __init__(self, source: 'Path|str|None' = None, + parent: 'TestSuite|None' = None, + doc: str = ''): + self.source = source self.parent = parent self.doc = doc self.imports = [] @@ -632,7 +651,7 @@ def __init__(self, source=None, parent=None, doc=''): self.keywords = [] @property - def source(self): + def source(self) -> 'Path|None': if self._source: return self._source if self.parent: @@ -640,27 +659,27 @@ def source(self): return None @source.setter - def source(self, source): - if not isinstance(source, (Path, type(None))): + def source(self, source: 'Path|str|None'): + if isinstance(source, str): source = Path(source) self._source = source @setter - def imports(self, imports): + def imports(self, imports: Sequence['Import']) -> 'Imports': return Imports(self, imports) @setter - def variables(self, variables): - return model.ItemList(Variable, {'parent': self}, items=variables) + def variables(self, variables: Sequence['Variable']) -> 'Variables': + return Variables(self, variables) @setter - def keywords(self, keywords): - return model.ItemList(UserKeyword, {'parent': self}, items=keywords) + def keywords(self, keywords: Sequence['UserKeyword']) -> 'UserKeywords': + return UserKeywords(self, keywords) - def to_dict(self): + def to_dict(self) -> DataDict: data = {} if self._source: - data['source'] = str(self.source) + data['source'] = str(self._source) if self.doc: data['doc'] = self.doc if self.imports: @@ -678,27 +697,33 @@ class UserKeyword(ModelObject): __slots__ = ['name', 'args', 'doc', 'return_', 'timeout', 'lineno', 'parent', 'error', '_teardown'] - def __init__(self, name='', args=(), doc='', tags=(), return_=None, - timeout=None, lineno=None, parent=None, error=None): + def __init__(self, name: str = '', + args: Sequence[str] = (), + doc: str = '', + tags: Sequence[str] = (), + return_: Sequence[str] = (), + timeout: 'str|None' = None, + lineno: 'int|None' = None, + parent: 'ResourceFile|None' = None, + error: 'str|None' = None): self.name = name - self.args = args + self.args = tuple(args) self.doc = doc self.tags = tags - self.return_ = return_ or () + self.return_ = tuple(return_) self.timeout = timeout self.lineno = lineno self.parent = parent self.error = error - self.body = None + self.body = [] self._teardown = None @setter - def body(self, body): - """Child keywords as a :class:`~.Body` object.""" + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return Body(self, body) @property - def keywords(self): + def keywords(self) -> Keywords: """Deprecated since Robot Framework 4.0. Use :attr:`body` or :attr:`teardown` instead. @@ -713,17 +738,19 @@ def keywords(self, keywords): Keywords.raise_deprecation_error() @property - def teardown(self): + def teardown(self) -> Keyword: if self._teardown is None: self._teardown = create_fixture(None, self, Keyword.TEARDOWN) - return self._teardown + # Would be better to enhance `create_fixture` so that its return + # type would match argument type. + return cast(Keyword, self._teardown) @teardown.setter - def teardown(self, teardown): + def teardown(self, teardown: 'Keyword|DataDict|None'): self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) @property - def has_teardown(self): + def has_teardown(self) -> bool: """Check does a keyword have a teardown without creating a teardown object. A difference between using ``if uk.has_teardown:`` and ``if uk.teardown:`` @@ -736,15 +763,15 @@ def has_teardown(self): return bool(self._teardown) @setter - def tags(self, tags): + def tags(self, tags: Sequence[str]) -> model.Tags: return model.Tags(tags) @property - def source(self): + def source(self) -> 'Path|None': return self.parent.source if self.parent is not None else None - def to_dict(self): - data = {'name': self.name} + def to_dict(self) -> DataDict: + data: DataDict = {'name': self.name} for name, value in [('args', self.args), ('doc', self.doc), ('tags', tuple(self.tags)), @@ -766,76 +793,83 @@ class Import(ModelObject): RESOURCE = 'RESOURCE' VARIABLES = 'VARIABLES' - def __init__(self, type, name, args=(), alias=None, parent=None, lineno=None): + def __init__(self, type: "Literal['LIBRARY', 'RESOURCE', 'VARIABLES']", + name: str, + args: Sequence[str] = (), + alias: 'str|None' = None, + parent: 'ResourceFile|None' = None, + lineno: 'int|None' = None): if type not in (self.LIBRARY, self.RESOURCE, self.VARIABLES): raise ValueError(f"Invalid import type: Expected '{self.LIBRARY}', " f"'{self.RESOURCE}' or '{self.VARIABLES}', got '{type}'.") self.type = type self.name = name - self.args = args + self.args = tuple(args) self.alias = alias self.parent = parent self.lineno = lineno @property - def source(self) -> Path: + def source(self) -> 'Path|None': return self.parent.source if self.parent is not None else None @property - def directory(self) -> Path: + def directory(self) -> 'Path|None': source = self.source return source.parent if source and not source.is_dir() else source @property - def setting_name(self): + def setting_name(self) -> str: return self.type.title() - def select(self, library, resource, variables): + def select(self, library: Any, resource: Any, variables: Any) -> Any: return {self.LIBRARY: library, self.RESOURCE: resource, self.VARIABLES: variables}[self.type] - def report_invalid_syntax(self, message, level='ERROR'): + def report_invalid_syntax(self, message: str, level: str = 'ERROR'): source = self.source or '<unknown>' line = f' on line {self.lineno}' if self.lineno else '' LOGGER.write(f"Error in file '{source}'{line}: {message}", level) @classmethod - def from_dict(cls, data): + def from_dict(cls, data) -> 'Import': return cls(**data) - def to_dict(self): - data = {'type': self.type, 'name': self.name} + def to_dict(self) -> DataDict: + data: DataDict = {'type': self.type, 'name': self.name} if self.args: - data['args'] = list(self.args) + data['args'] = self.args if self.alias: data['alias'] = self.alias if self.lineno: data['lineno'] = self.lineno return data - def _include_in_repr(self, name, value): + def _include_in_repr(self, name: str, value: Any) -> bool: return name in ('type', 'name') or value class Imports(model.ItemList): - def __init__(self, parent, imports=None): + def __init__(self, parent: ResourceFile, imports: Sequence[Import] = ()): super().__init__(Import, {'parent': parent}, items=imports) - def library(self, name, args=(), alias=None, lineno=None): + def library(self, name: str, args: Sequence[str] = (), alias: 'str|None' = None, + lineno: 'int|None' = None) -> Import: """Create library import.""" - self.create(Import.LIBRARY, name, args, alias, lineno=lineno) + return self.create(Import.LIBRARY, name, args, alias, lineno=lineno) - def resource(self, name, lineno=None): + def resource(self, name: str, lineno: 'int|None' = None) -> Import: """Create resource import.""" - self.create(Import.RESOURCE, name, lineno=lineno) + return self.create(Import.RESOURCE, name, lineno=lineno) - def variables(self, name, args=(), lineno=None): + def variables(self, name: str, args: Sequence[str] = (), + lineno: 'int|None' = None) -> Import: """Create variables import.""" - self.create(Import.VARIABLES, name, args, lineno=lineno) + return self.create(Import.VARIABLES, name, args, lineno=lineno) - def create(self, *args, **kwargs): + def create(self, *args, **kwargs) -> Import: """Generic method for creating imports. Import type specific methods :meth:`library`, :meth:`resource` and @@ -847,3 +881,15 @@ def create(self, *args, **kwargs): elif 'type' in kwargs: kwargs['type'] = kwargs['type'].upper() return super().create(*args, **kwargs) + + +class Variables(model.ItemList[Variable]): + + def __init__(self, parent: ResourceFile, variables: Sequence[Variable] = ()): + super().__init__(Variable, {'parent': parent}, items=variables) + + +class UserKeywords(model.ItemList[UserKeyword]): + + def __init__(self, parent: ResourceFile, keywords: Sequence[UserKeyword] = ()): + super().__init__(UserKeyword, {'parent': parent}, items=keywords) diff --git a/utest/running/test_imports.py b/utest/running/test_imports.py index 3d27d181f8d..4dd695d6889 100644 --- a/utest/running/test_imports.py +++ b/utest/running/test_imports.py @@ -38,7 +38,7 @@ def run_and_check_pass(self, suite): for test in result.tests: full_msg.append('%s: %s' % (test, test.message)) raise AssertionError('\n'.join(full_msg)) from e - + def test_create(self): suite = TestSuite(name='Suite') suite.resource.imports.create('Library', 'OperatingSystem') @@ -49,7 +49,7 @@ def test_create(self): test.body.create_keyword('My Test Keyword') test.body.create_keyword('Convert To Lower Case', args=['ROBOT']) self.run_and_check_pass(suite) - + def test_library(self): suite = TestSuite(name='Suite') @@ -85,7 +85,7 @@ def test_repr(self): assert_equal(repr(Import(Import.LIBRARY, 'X')), "robot.running.Import(type='LIBRARY', name='X')") assert_equal(repr(Import(Import.LIBRARY, 'X', ['a'], 'A')), - "robot.running.Import(type='LIBRARY', name='X', args=['a'], alias='A')") + "robot.running.Import(type='LIBRARY', name='X', args=('a',), alias='A')") assert_equal(repr(Import(Import.RESOURCE, 'X')), "robot.running.Import(type='RESOURCE', name='X')") assert_equal(repr(Import(Import.VARIABLES, '')), diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index d0baeddc097..77356fffd83 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -426,9 +426,9 @@ def test_user_keyword_structure(self): def test_resource_file(self): self._verify(ResourceFile()) resource = ResourceFile('x.resource', doc='doc') - resource.imports.library('L', 'a', 'A', 1) + resource.imports.library('L', ['a'], 'A', 1) resource.imports.resource('R', 2) - resource.imports.variables('V', 'a', 3) + resource.imports.variables('V', ['a'], 3) resource.variables.create('${x}', ('value',)) resource.variables.create('@{y}', ('v1', 'v2'), lineno=4) resource.variables.create('&{z}', ['k=v'], error='E') @@ -436,14 +436,14 @@ def test_resource_file(self): self._verify(resource, source='x.resource', doc='doc', - imports=[{'type': 'LIBRARY', 'name': 'L', 'args': ['a'], + imports=[{'type': 'LIBRARY', 'name': 'L', 'args': ('a',), 'alias': 'A', 'lineno': 1}, {'type': 'RESOURCE', 'name': 'R', 'lineno': 2}, - {'type': 'VARIABLES', 'name': 'V', 'args': ['a'], + {'type': 'VARIABLES', 'name': 'V', 'args': ('a',), 'lineno': 3}], - variables=[{'name': '${x}', 'value': ['value']}, - {'name': '@{y}', 'value': ['v1', 'v2'], 'lineno': 4}, - {'name': '&{z}', 'value': ['k=v'], 'error': 'E'}], + variables=[{'name': '${x}', 'value': ('value',)}, + {'name': '@{y}', 'value': ('v1', 'v2'), 'lineno': 4}, + {'name': '&{z}', 'value': ('k=v',), 'error': 'E'}], keywords=[{'name': 'UK', 'body': [{'name': 'K'}]}]) def test_bigger_suite_structure(self): diff --git a/utest/running/test_running.py b/utest/running/test_running.py index bc577d24641..cd81354a0ed 100644 --- a/utest/running/test_running.py +++ b/utest/running/test_running.py @@ -98,7 +98,7 @@ def test_user_keywords(self): def test_variables(self): suite = TestSuite(name='Suite') - suite.resource.variables.create('${ERROR}', 'Error message') + suite.resource.variables.create('${ERROR}', ['Error message']) suite.resource.variables.create('@{LIST}', ['Error', 'added tag']) suite.tests.create(name='T1').body.create_keyword('Fail', args=['${ERROR}']) suite.tests.create(name='T2').body.create_keyword('Fail', args=['@{LIST}']) @@ -240,7 +240,7 @@ def test_run_multiple_times_with_different_stdout_and_stderr(self): def _run(self, stdout=None, stderr=None, **options): suite = TestSuite(name='My Suite') - suite.resource.variables.create('${MESSAGE}', 'Hello, world!') + suite.resource.variables.create('${MESSAGE}', ['Hello, world!']) suite.tests.create(name='My Test')\ .body.create_keyword('Log', args=['${MESSAGE}', 'WARN']) run(suite, stdout=stdout, stderr=stderr, **options) From 29d5bf9e7069707d9d22ac3bb222a3911b54af67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 12 May 2023 00:47:16 +0300 Subject: [PATCH 0558/1592] Enhance expanding failed keywords in log. (#4756) - If opening only a skipped test via report or otherwise, expand its failed keywords. With failed tests keywords were expaned already earlier. - If manually opening a skipped or failed test, expand its failed keywords. Skipped tests were never expanded earlier, and failed tests were only expanded if you had opened the whole log file, not a single test. --- src/robot/htmldata/rebot/log.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/robot/htmldata/rebot/log.js b/src/robot/htmldata/rebot/log.js index f44067e960d..6462f470d71 100644 --- a/src/robot/htmldata/rebot/log.js +++ b/src/robot/htmldata/rebot/log.js @@ -6,6 +6,9 @@ function toggleSuite(suiteId) { function toggleTest(testId) { toggleElement(testId, ['keyword']); + var test = window.testdata.findLoaded(testId); + if (test.status == "FAIL" || test.status == "SKIP") + expandFailed(test); } function toggleKeyword(kwId) { @@ -79,7 +82,7 @@ function loadAndExpandElementIds(ids) { } function expandFailed(element) { - if (element.status == "FAIL") { + if (element.status == "FAIL" || (element.type == "test" && element.status == "SKIP")) { window.elementsToExpand = [element]; window.expandDecider = function (e) { return e.status == "FAIL"; From 8654ea9a6a2edb561b930edf8e51f7d8fe1e2989 Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Sat, 13 May 2023 14:18:49 +0100 Subject: [PATCH 0559/1592] Add correct typing to `TestSuite.tests` and `TestSuite.suites`, including `create` Most importantly, types now work with extending classes at lest with VSCode/PyRight. Co-authored-by: serhiy <serhiy.pikho@jitsuin.com> --- src/robot/model/__init__.py | 4 ++-- src/robot/model/itemlist.py | 5 ++++- src/robot/model/testcase.py | 11 +++++++---- src/robot/model/testsuite.py | 22 ++++++++++++---------- src/robot/result/model.py | 12 +++++++++++- src/robot/running/model.py | 13 ++++++++++++- src/robot/utils/__init__.py | 1 + src/robot/utils/typehints.py | 34 ++++++++++++++++++++++++++++++++++ 8 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 src/robot/utils/typehints.py diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index 6fb53c1a1ef..cd0415783cb 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -37,7 +37,7 @@ from .namepatterns import SuiteNamePatterns, TestNamePatterns from .statistics import Statistics from .tags import Tags, TagPattern, TagPatterns -from .testcase import TestCase -from .testsuite import TestSuite +from .testcase import TestCase, TestCases +from .testsuite import TestSuite, TestSuites from .totalstatistics import TotalStatisticsBuilder from .visitor import SuiteVisitor diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index 3a7b48f62be..e1fbe1c0ccb 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -17,7 +17,7 @@ from typing import (Any, Iterable, Iterator, MutableSequence, overload, TYPE_CHECKING, Type, TypeVar) -from robot.utils import type_name +from robot.utils import copy_signature, KnownAtRuntime, type_name from .modelobject import DataDict @@ -45,6 +45,8 @@ class ItemList(MutableSequence[T]): """ __slots__ = ['_item_class', '_common_attrs', '_items'] + # TypeVar T needs to be applied to a variable to be compatible with @copy_signature + item_type: Type[T] = KnownAtRuntime def __init__(self, item_class: Type[T], common_attrs: 'dict[str, Any]|None' = None, @@ -55,6 +57,7 @@ def __init__(self, item_class: Type[T], if items: self.extend(items) + @copy_signature(item_type) def create(self, *args, **kwargs) -> T: """Create a new item using the provided arguments.""" return self.append(self._item_class(*args, **kwargs)) diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index ab1ccdd719e..d064357d3e1 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -14,7 +14,7 @@ # limitations under the License. from pathlib import Path -from typing import Any, Sequence, Type, TYPE_CHECKING +from typing import Any, Sequence, Type, TYPE_CHECKING, TypeVar from robot.utils import setter @@ -30,6 +30,9 @@ from .visitor import SuiteVisitor +TC = TypeVar("TC", bound="TestCase") + + class TestCase(ModelObject): """Base model for a single test case. @@ -200,12 +203,12 @@ def to_dict(self) -> 'dict[str, Any]': return data -class TestCases(ItemList[TestCase]): +class TestCases(ItemList[TC]): __slots__ = [] - def __init__(self, test_class: Type[TestCase] = TestCase, + def __init__(self, test_class: Type[TC] = TestCase, parent: 'TestSuite|None' = None, - tests: 'Sequence[TestCase|DataDict]' = ()): + tests: 'Sequence[TC|DataDict]' = ()): super().__init__(test_class, {'parent': parent}, tests) def _check_type_and_set_attrs(self, test): diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index ef78b8603dc..582d9b28d97 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -15,7 +15,7 @@ from collections.abc import Mapping from pathlib import Path -from typing import Any, Iterator, Sequence, Type +from typing import Any, Iterator, Sequence, Type, TypeVar from robot.utils import seq2str, setter @@ -31,6 +31,9 @@ from .visitor import SuiteVisitor +TS = TypeVar('TS', bound="TestSuite") + + class TestSuite(ModelObject): """Base model for single suite. @@ -155,12 +158,12 @@ def metadata(self, metadata: 'Mapping[str, str]|None') -> Metadata: return Metadata(metadata) @setter - def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> 'TestSuites': - return TestSuites(self.__class__, self, suites) + def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> 'TestSuites[TestSuite]': + return TestSuites['TestSuite'](self.__class__, self, suites) @setter - def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases: - return TestCases(self.test_class, self, tests) + def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases[TestCase]: + return TestCases[TestCase](self.test_class, self, tests) @property def setup(self) -> Keyword: @@ -375,11 +378,10 @@ def to_dict(self) -> 'dict[str, Any]': data['suites'] = self.suites.to_dicts() return data - -class TestSuites(ItemList[TestSuite]): +class TestSuites(ItemList[TS]): __slots__ = [] - def __init__(self, suite_class: Type[TestSuite] = TestSuite, - parent: 'TestSuite|None' = None, - suites: 'Sequence[TestSuite|DataDict]' = ()): + def __init__(self, suite_class: Type[TS] = TestSuite, + parent: 'TS|None' = None, + suites: 'Sequence[TS|DataDict]' = ()): super().__init__(suite_class, {'parent': parent}, suites) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 915002bd5c3..5c83b109f28 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -37,10 +37,12 @@ from collections import OrderedDict from itertools import chain +from typing import Sequence import warnings from robot import model -from robot.model import BodyItem, create_fixture, Keywords, Tags, TotalStatisticsBuilder +from robot.model import (BodyItem, create_fixture, Keywords, Tags, + TotalStatisticsBuilder, DataDict, TestCases, TestSuites) from robot.utils import get_elapsed_time, setter from .configurer import SuiteConfigurer @@ -740,6 +742,14 @@ def elapsedtime(self): return sum(child.elapsedtime for child in chain(self.suites, self.tests, (self.setup, self.teardown))) + @setter + def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> TestSuites['TestSuite']: + return TestSuites['TestSuite'](self.__class__, self, suites) + + @setter + def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases[TestCase]: + return TestCases[TestCase](self.test_class, self, tests) + def remove_keywords(self, how): """Remove keywords based on the given condition. diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 492ccb63a10..582f4cc3dea 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -44,7 +44,10 @@ from robot import model from robot.conf import RobotSettings from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword -from robot.model import BodyItem, create_fixture, DataDict, Keywords, ModelObject +from robot.model import (BodyItem, create_fixture, DataDict, Keywords, ModelObject, + TestCases, TestSuites) +from robot.model.testcase import TestCases +from robot.model.testsuite import TestSuites from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, Error as ErrorResult, Return as ReturnResult) @@ -526,6 +529,14 @@ def randomize(self, suites: bool = True, tests: bool = True, """ self.visit(Randomizer(suites, tests, seed)) + @setter + def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> TestSuites['TestSuite']: + return TestSuites['TestSuite'](self.__class__, self, suites) + + @setter + def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases[TestCase]: + return TestCases[TestCase](self.test_class, self, tests) + def run(self, settings=None, **options): """Executes the suite based on the given ``settings`` or ``options``. diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 4f1641cdd5a..508fa4e4b36 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -70,6 +70,7 @@ from .text import (cut_assign_value, cut_long_message, format_assign_message, get_console_length, getdoc, getshortdoc, pad_console_length, split_tags_from_doc, split_args_from_name_or_path) +from .typehints import copy_signature, KnownAtRuntime from .unic import prepr, safe_str diff --git a/src/robot/utils/typehints.py b/src/robot/utils/typehints.py new file mode 100644 index 00000000000..9a4eb6e8bd3 --- /dev/null +++ b/src/robot/utils/typehints.py @@ -0,0 +1,34 @@ +# 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 typing import Any, Callable, TypeVar + + +T = TypeVar('T', bound=Callable[..., Any]) + +# Type Alias for objects that are only known at runtime. This should be Used as a +# default value for generic classes that also use `@copy_signature` decorator +KnownAtRuntime = type(object) + + +def copy_signature(target: T) -> Callable[..., T]: + """A decorator that applies the signature of `T` to any function that it decorates + see https://github.com/python/typing/issues/270#issuecomment-555966301 for source + and discussion. + """ + def decorator(func): + return func + + return decorator From ab0719c46b07361342067ec5c39e24145c7445e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 12 May 2023 00:47:16 +0300 Subject: [PATCH 0560/1592] Add forward compatible `start/end/elaped_time` to result objects. Fixes #4765. --- src/robot/result/model.py | 35 +++++++++++++++++++++++++++++++- utest/result/test_resultmodel.py | 32 ++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 5c83b109f28..29f99ea35ea 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -35,10 +35,11 @@ """ +import warnings from collections import OrderedDict +from datetime import datetime, timedelta from itertools import chain from typing import Sequence -import warnings from robot import model from robot.model import (BodyItem, create_fixture, Keywords, Tags, @@ -85,12 +86,44 @@ class StatusMixin: SKIP = 'SKIP' NOT_RUN = 'NOT RUN' NOT_SET = 'NOT SET' + starttime: 'str|None' + endtime: 'str|None' @property def elapsedtime(self): """Total execution time in milliseconds.""" return get_elapsed_time(self.starttime, self.endtime) + @property + def elapsed_time(self) -> timedelta: + return timedelta(milliseconds=self.elapsedtime) + + @property + def start_time(self) -> 'datetime|None': + return self._timestr_to_datetime(self.starttime) if self.starttime else None + + @start_time.setter + def start_time(self, start_time: 'datetime|None'): + self.starttime = self._datetime_to_timestr(start_time) if start_time else None + + @property + def end_time(self) -> 'datetime|None': + return self._timestr_to_datetime(self.endtime) if self.endtime else None + + @end_time.setter + def end_time(self, end_time: 'datetime|None'): + self.endtime = self._datetime_to_timestr(end_time) if end_time else None + + def _timestr_to_datetime(self, ts: str) -> datetime: + micro = int(ts[18:]) * 1000 + return datetime(int(ts[:4]), int(ts[4:6]), int(ts[6:8]), + int(ts[9:11]), int(ts[12:14]), int(ts[15:17]), micro) + + def _datetime_to_timestr(self, dt: datetime) -> str: + millis = int(round(dt.microsecond, -3) / 1000) + return (f'{dt.year}{dt.month:02}{dt.day:02} ' + f'{dt.hour:02}:{dt.minute:02}.{dt.second:02}.{millis}') + @property def passed(self): """``True`` when :attr:`status` is 'PASS', ``False`` otherwise.""" diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 708726ea64c..3ef4e1e6657 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -1,5 +1,6 @@ import unittest import warnings +from datetime import datetime from robot.model import Tags from robot.result import (Break, Continue, Error, For, If, IfBranch, Keyword, Message, @@ -120,23 +121,40 @@ def test_suite_status_cannot_be_set_directly(self): assert_raises(AttributeError, setattr, suite, attr, True) -class TestElapsedTime(unittest.TestCase): +class TestTimes(unittest.TestCase): def test_suite_elapsed_time_when_start_and_end_given(self): suite = TestSuite() suite.starttime = '20010101 10:00:00.000' suite.endtime = '20010101 10:00:01.234' - assert_equal(suite.elapsedtime, 1234) + self.assert_elapsed(suite, 1234) + + def assert_elapsed(self, obj, expected): + assert_equal(obj.elapsedtime, expected) + assert_equal(obj.elapsed_time.total_seconds() * 1000, expected) def test_suite_elapsed_time_is_zero_by_default(self): - suite = TestSuite() - assert_equal(suite.elapsedtime, 0) + self.assert_elapsed(TestSuite(), 0) - def _test_suite_elapsed_time_is_test_time(self): + def test_suite_elapsed_time_is_got_from_childen_if_suite_does_not_have_times(self): suite = TestSuite() suite.tests.create(starttime='19991212 12:00:00.010', - endtime='19991212 13:00:01.010') - assert_equal(suite.elapsedtime, 3610000) + endtime='19991212 12:00:00.011') + self.assert_elapsed(suite, 1) + assert_equal(suite.elapsedtime, 1) + suite.starttime = '19991212 12:00:00.010' + suite.endtime = '19991212 12:00:01.010' + self.assert_elapsed(suite, 1000) + + def test_forward_compatibility(self): + for cls in (TestSuite, TestCase, Keyword, If, IfBranch, Try, For, While, + Break, Continue, Return, Error): + obj = cls(starttime='20230512 16:40:00.001', endtime='20230512 16:40:01.001') + assert_equal(obj.starttime, '20230512 16:40:00.001') + assert_equal(obj.endtime, '20230512 16:40:01.001') + assert_equal(obj.start_time, datetime(2023, 5, 12, 16, 40, 0, 1000)) + assert_equal(obj.end_time, datetime(2023, 5, 12, 16, 40, 1, 1000)) + self.assert_elapsed(obj, 1000) class TestSlots(unittest.TestCase): From c706156fe0c17c2d894bddc7bb184f257e98cd2f Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Sat, 13 May 2023 21:08:05 +0100 Subject: [PATCH 0561/1592] Make model.BaseBody accept Generic parameters (#4766) Most importantly makes `create_xxx` methods typing correct with extending classes. Part of #4570. Co-authored-by: serhiy <serhiy.pikho@jitsuin.com> --- src/robot/model/body.py | 95 ++++++++++++++++++++----------------- src/robot/model/testcase.py | 2 +- src/robot/result/model.py | 8 +++- src/robot/running/model.py | 12 +++-- 4 files changed, 68 insertions(+), 49 deletions(-) diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 07c0f707847..2a03eb624c2 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -14,10 +14,11 @@ # limitations under the License. import re -from typing import Any, Callable, cast, Iterable, Type, TYPE_CHECKING, TypeVar, Union - +from typing import (Any, Callable, Generic, cast, Iterable, Type, TYPE_CHECKING, + TypeVar, Union) from .itemlist import ItemList from .modelobject import DataDict, full_name, ModelObject +from robot.utils import copy_signature, KnownAtRuntime if TYPE_CHECKING: from robot.running.model import UserKeyword, ResourceFile @@ -31,7 +32,17 @@ BodyItemParent = Union['TestSuite', 'TestCase', 'UserKeyword', 'For', 'If', 'IfBranch', 'Try', 'TryBranch', 'While', None] -T = TypeVar("T", bound="BodyItem") +BI = TypeVar('BI', bound='BodyItem') +KW = TypeVar('KW', bound='Keyword') +F = TypeVar('F', bound='For') +W = TypeVar('W', bound='While') +I = TypeVar('I', bound='If') +T = TypeVar('T', bound='Try') +R = TypeVar('R', bound='Return') +C = TypeVar('C', bound='Continue') +B = TypeVar('B', bound='Break') +M = TypeVar('M', bound='Message') +E = TypeVar('E', bound='Error') class BodyItem(ModelObject): @@ -95,33 +106,20 @@ def to_dict(self) -> DataDict: raise NotImplementedError -class BaseBody(ItemList[BodyItem]): +class BaseBody(ItemList[BodyItem], Generic[KW, F, W, I, T, R, C, B, M, E]): """Base class for Body and Branches objects.""" __slots__ = [] # Set using 'BaseBody.register' when these classes are created. - - if TYPE_CHECKING: - keyword_class = Keyword - for_class = For - while_class = While - if_class = If - try_class = Try - return_class = Return - continue_class = Continue - break_class = Break - message_class = Message - error_class = Error - else: - keyword_class = None - for_class = None - while_class = None - if_class = None - try_class = None - return_class = None - continue_class = None - break_class = None - message_class = None - error_class = None + keyword_class: Type[KW] = KnownAtRuntime + for_class: Type[F] = KnownAtRuntime + while_class: Type[W] = KnownAtRuntime + if_class: Type[I] = KnownAtRuntime + try_class: Type[T] = KnownAtRuntime + return_class: Type[R] = KnownAtRuntime + continue_class: Type[C] = KnownAtRuntime + break_class: Type[B] = KnownAtRuntime + message_class: Type[M] = KnownAtRuntime + error_class: Type[E] = KnownAtRuntime def __init__(self, parent: BodyItemParent = None, items: 'Iterable[BodyItem|DataDict]' = ()): @@ -141,7 +139,7 @@ def _item_from_dict(self, data: DataDict) -> BodyItem: return item_class.from_dict(data) @classmethod - def register(cls, item_class: Type[T]) -> Type[T]: + def register(cls, item_class: Type[BI]) -> Type[BI]: name_parts = re.findall('([A-Z][a-z]+)', item_class.__name__) + ['class'] name = '_'.join(name_parts).lower() if not hasattr(cls, name): @@ -156,40 +154,50 @@ def create(self): f"Use item specific methods like 'create_keyword' instead." ) - def _create(self, cls: 'Type[T]', name: str, args: 'tuple[Any]', - kwargs: 'dict[str, Any]') -> T: - if cls is None: + def _create(self, cls: 'Type[BI]', name: str, args: 'tuple[Any]', + kwargs: 'dict[str, Any]') -> BI: + if not issubclass(cls, BodyItem): raise TypeError(f"'{full_name(self)}' object does not support '{name}'.") return self.append(cls(*args, **kwargs)) - def create_keyword(self, *args, **kwargs) -> 'Keyword': + @copy_signature(keyword_class) + def create_keyword(self, *args, **kwargs) -> keyword_class: return self._create(self.keyword_class, 'create_keyword', args, kwargs) - def create_for(self, *args, **kwargs) -> 'For': + @copy_signature(for_class) + def create_for(self, *args, **kwargs) -> for_class: return self._create(self.for_class, 'create_for', args, kwargs) - def create_if(self, *args, **kwargs) -> 'If': + @copy_signature(if_class) + def create_if(self, *args, **kwargs) -> if_class: return self._create(self.if_class, 'create_if', args, kwargs) - def create_try(self, *args, **kwargs) -> 'Try': + @copy_signature(try_class) + def create_try(self, *args, **kwargs) -> try_class: return self._create(self.try_class, 'create_try', args, kwargs) - def create_while(self, *args, **kwargs) -> 'While': + @copy_signature(while_class) + def create_while(self, *args, **kwargs) -> while_class: return self._create(self.while_class, 'create_while', args, kwargs) - def create_return(self, *args, **kwargs) -> 'Return': + @copy_signature(return_class) + def create_return(self, *args, **kwargs) -> return_class: return self._create(self.return_class, 'create_return', args, kwargs) - def create_continue(self, *args, **kwargs) -> 'Continue': + @copy_signature(continue_class) + def create_continue(self, *args, **kwargs) -> continue_class: return self._create(self.continue_class, 'create_continue', args, kwargs) - def create_break(self, *args, **kwargs) -> 'Break': + @copy_signature(break_class) + def create_break(self, *args, **kwargs) -> break_class: return self._create(self.break_class, 'create_break', args, kwargs) - def create_message(self, *args, **kwargs) -> 'Message': + @copy_signature(message_class) + def create_message(self, *args, **kwargs) -> message_class: return self._create(self.message_class, 'create_message', args, kwargs) - def create_error(self, *args, **kwargs) -> 'Error': + @copy_signature(error_class) + def create_error(self, *args, **kwargs) -> error_class: return self._create(self.error_class, 'create_error', args, kwargs) def filter(self, keywords: 'bool|None' = None, messages: 'bool|None' = None, @@ -217,7 +225,7 @@ def filter(self, keywords: 'bool|None' = None, messages: 'bool|None' = None, use ``body.filter(keywords=False``, messages=False)``. For more detailed filtering it is possible to use ``predicate``. """ - if messages is not None and not self.message_class: + if messages is not None and not issubclass(self.message_class, BodyItem): raise TypeError(f"'{full_name(self)}' object does not support " f"filtering by 'messages'.") return self._filter([(self.keyword_class, keywords), @@ -254,7 +262,8 @@ def flatten(self) -> 'list[BodyItem]': return steps -class Body(BaseBody): +class Body(BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', + 'Break', 'Message', 'Error']): """A list-like object representing a body of a test, keyword, etc. Body contains the keywords and other structures such as FOR loops. diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index d064357d3e1..893c6123eaf 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -30,7 +30,7 @@ from .visitor import SuiteVisitor -TC = TypeVar("TC", bound="TestCase") +TC = TypeVar('TC', bound='TestCase') class TestCase(ModelObject): diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 29f99ea35ea..c921278e179 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -53,7 +53,8 @@ from .suiteteardownfailed import SuiteTeardownFailed, SuiteTeardownFailureHandler -class Body(model.Body): +class Body(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', + 'Break', 'Message', 'Error']): __slots__ = [] @@ -686,6 +687,11 @@ def critical(self): warnings.warn("'TestCase.critical' is deprecated and always returns 'True'.") return True + @setter + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: + """Test body as a :class:`~robot.result.Body` object.""" + return self.body_class(self, body) + class TestSuite(model.TestSuite, StatusMixin): """Represents results of a single test suite. diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 582f4cc3dea..19d70940fe9 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -46,8 +46,6 @@ from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword from robot.model import (BodyItem, create_fixture, DataDict, Keywords, ModelObject, TestCases, TestSuites) -from robot.model.testcase import TestCases -from robot.model.testsuite import TestSuites from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, Error as ErrorResult, Return as ReturnResult) @@ -66,8 +64,9 @@ 'Try', 'TryBranch', 'While', None] -class Body(model.Body): - __slots__ = () +class Body(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', + 'Break', 'model.Message', 'Error']): + __slots__ = [] class WithSource: @@ -399,6 +398,11 @@ def to_dict(self) -> DataDict: data['error'] = self.error return data + @setter + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: + """Test body as a :class:`~robot.running.Body` object.""" + return self.body_class(self, body) + class TestSuite(model.TestSuite): """Represents a single executable test suite. From 81576c5dbfdf6a8bcac49d4a36b5797f064e30d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 14 May 2023 02:14:33 +0300 Subject: [PATCH 0562/1592] Add typing to `robot.result.model`. Part of #4570. Order of arguments passed to `__init__`s were changed so that `parent` is now always last. This affects possible programmatic usage passing argument positionally, but such usage ought to be really rare and its anyway a lot better idea to use keyword arguments when there are this many arguments. Small typing changes also elsewhere. --- src/robot/model/__init__.py | 2 +- src/robot/model/body.py | 12 +- src/robot/model/keyword.py | 12 +- src/robot/model/totalstatistics.py | 16 +- src/robot/result/model.py | 339 ++++++++++++++++-------- utest/reporting/test_jsmodelbuilders.py | 2 +- 6 files changed, 252 insertions(+), 131 deletions(-) diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index cd0415783cb..9ddbe440c84 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -39,5 +39,5 @@ from .tags import Tags, TagPattern, TagPatterns from .testcase import TestCase, TestCases from .testsuite import TestSuite, TestSuites -from .totalstatistics import TotalStatisticsBuilder +from .totalstatistics import TotalStatistics, TotalStatisticsBuilder from .visitor import SuiteVisitor diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 2a03eb624c2..5e2ee9c3390 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -14,13 +14,16 @@ # limitations under the License. import re -from typing import (Any, Callable, Generic, cast, Iterable, Type, TYPE_CHECKING, +from typing import (Any, Callable, cast, Generic, Iterable, Type, TYPE_CHECKING, TypeVar, Union) + +from robot.utils import copy_signature, KnownAtRuntime + from .itemlist import ItemList from .modelobject import DataDict, full_name, ModelObject -from robot.utils import copy_signature, KnownAtRuntime if TYPE_CHECKING: + from robot.result.model import ForIteration, WhileIteration from robot.running.model import UserKeyword, ResourceFile from .control import (Break, Continue, Error, For, If, IfBranch, Return, Try, TryBranch, While) @@ -30,8 +33,9 @@ from .testsuite import TestSuite -BodyItemParent = Union['TestSuite', 'TestCase', 'UserKeyword', 'For', 'If', 'IfBranch', - 'Try', 'TryBranch', 'While', None] +BodyItemParent = Union['TestSuite', 'TestCase', 'UserKeyword', 'For', 'ForIteration', + 'If', 'IfBranch', 'Try', 'TryBranch', 'While', 'WhileIteration', + 'Keyword', 'Return', 'Continue', 'Break', 'Error', None] BI = TypeVar('BI', bound='BodyItem') KW = TypeVar('KW', bound='Keyword') F = TypeVar('F', bound='For') diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index a42e251011f..e5bd55cb53d 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -36,21 +36,23 @@ class Keyword(BodyItem): repr_args = ('name', 'args', 'assign') __slots__ = ['_name', 'args', 'assign', 'type'] - def __init__(self, name: str = '', args: Sequence[str] = (), - assign: Sequence[str] = (), type: str = BodyItem.KEYWORD, + def __init__(self, name: 'str|None' = '', + args: Sequence[str] = (), + assign: Sequence[str] = (), + type: str = BodyItem.KEYWORD, parent: BodyItemParent = None): - self._name = name + self.name = name self.args = args self.assign = assign self.type = type self.parent = parent @property - def name(self) -> str: + def name(self) -> 'str|None': return self._name @name.setter - def name(self, name: str): + def name(self, name: 'str|None'): self._name = name @property diff --git a/src/robot/model/totalstatistics.py b/src/robot/model/totalstatistics.py index 9d6762da76e..b436f1b477d 100644 --- a/src/robot/model/totalstatistics.py +++ b/src/robot/model/totalstatistics.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Iterator + from robot.utils import test_or_task from .stats import TotalStat @@ -22,7 +24,7 @@ class TotalStatistics: """Container for total statistics.""" - def __init__(self, rpa=False): + def __init__(self, rpa: bool = False): #: Instance of :class:`~robot.model.stats.TotalStat` for all the tests. self._stat = TotalStat(test_or_task('All {Test}s', rpa)) self._rpa = rpa @@ -30,30 +32,30 @@ def __init__(self, rpa=False): def visit(self, visitor): visitor.visit_total_statistics(self._stat) - def __iter__(self): + def __iter__(self) -> 'Iterator[TotalStat]': yield self._stat @property - def total(self): + def total(self) -> int: return self._stat.total @property - def passed(self): + def passed(self) -> int: return self._stat.passed @property - def skipped(self): + def skipped(self) -> int: return self._stat.skipped @property - def failed(self): + def failed(self) -> int: return self._stat.failed def add_test(self, test): self._stat.add_test(test) @property - def message(self): + def message(self) -> str: """String representation of the statistics. For example:: diff --git a/src/robot/result/model.py b/src/robot/result/model.py index c921278e179..29c2f02bca1 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -35,15 +35,20 @@ """ +import sys import warnings from collections import OrderedDict from datetime import datetime, timedelta from itertools import chain -from typing import Sequence +from pathlib import Path +from typing import cast, Mapping, Sequence, Type, Union +if sys.version_info >= (3, 8): + from typing import Literal from robot import model -from robot.model import (BodyItem, create_fixture, Keywords, Tags, - TotalStatisticsBuilder, DataDict, TestCases, TestSuites) +from robot.model import (BodyItem, create_fixture, DataDict, Keywords, Tags, + SuiteVisitor, TotalStatistics, TotalStatisticsBuilder, + TestCases, TestSuites) from robot.utils import get_elapsed_time, setter from .configurer import SuiteConfigurer @@ -53,6 +58,10 @@ from .suiteteardownfailed import SuiteTeardownFailed, SuiteTeardownFailureHandler +BodyItemParent = Union['TestSuite', 'TestCase', 'For', 'ForIteration', 'If', 'IfBranch', + 'Try', 'TryBranch', 'While', 'WhileIteration', None] + + class Body(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', 'Break', 'Message', 'Error']): __slots__ = [] @@ -65,23 +74,24 @@ class Branches(model.Branches): class Iterations(model.BaseBody): __slots__ = ['iteration_class'] - def __init__(self, iteration_class, parent=None, items=None): + def __init__(self, iteration_class: Type['ForIteration|WhileIteration'], + parent: BodyItemParent = None, + items: 'Sequence[ForIteration|WhileIteration|DataDict]' = ()): self.iteration_class = iteration_class super().__init__(parent, items) - def create_iteration(self, *args, **kwargs): - return self.append(self.iteration_class(*args, **kwargs)) + def create_iteration(self, *args, **kwargs) -> 'ForIteration|WhileIteration': + return self._create(self.iteration_class, 'iteration_class', args, kwargs) @Body.register @Branches.register @Iterations.register class Message(model.Message): - __slots__ = [] + __slots__ = () class StatusMixin: - __slots__ = [] PASS = 'PASS' FAIL = 'FAIL' SKIP = 'SKIP' @@ -89,18 +99,34 @@ class StatusMixin: NOT_SET = 'NOT SET' starttime: 'str|None' endtime: 'str|None' + __slots__ = () @property - def elapsedtime(self): - """Total execution time in milliseconds.""" + def elapsedtime(self) -> int: + """Total execution time in milliseconds. + + This attribute will be replaced by :attr:`elapsed_time` in the future. + """ return get_elapsed_time(self.starttime, self.endtime) @property def elapsed_time(self) -> timedelta: + """Total execution time as a ``timedelta``. + + This attribute will replace :attr:`elapsedtime` in the future. + + New in Robot Framework 6.1. + """ return timedelta(milliseconds=self.elapsedtime) @property def start_time(self) -> 'datetime|None': + """Execution start time as a ``datetime`` or as ``None`` if not set. + + This attribute will replace :attr:`starttime` in the future. + + New in Robot Framework 6.1. + """ return self._timestr_to_datetime(self.starttime) if self.starttime else None @start_time.setter @@ -109,6 +135,12 @@ def start_time(self, start_time: 'datetime|None'): @property def end_time(self) -> 'datetime|None': + """Execution end time as a ``datetime`` or as ``None`` if not set. + + This attribute will replace :attr:`endtime` in the future. + + New in Robot Framework 6.1. + """ return self._timestr_to_datetime(self.endtime) if self.endtime else None @end_time.setter @@ -126,25 +158,25 @@ def _datetime_to_timestr(self, dt: datetime) -> str: f'{dt.hour:02}:{dt.minute:02}.{dt.second:02}.{millis}') @property - def passed(self): + def passed(self) -> bool: """``True`` when :attr:`status` is 'PASS', ``False`` otherwise.""" return self.status == self.PASS @passed.setter - def passed(self, passed): + def passed(self, passed: bool): self.status = self.PASS if passed else self.FAIL @property - def failed(self): + def failed(self) -> bool: """``True`` when :attr:`status` is 'FAIL', ``False`` otherwise.""" return self.status == self.FAIL @failed.setter - def failed(self, failed): + def failed(self, failed: bool): self.status = self.FAIL if failed else self.PASS @property - def skipped(self): + def skipped(self) -> bool: """``True`` when :attr:`status` is 'SKIP', ``False`` otherwise. Setting to ``False`` value is ambiguous and raises an exception. @@ -152,13 +184,13 @@ def skipped(self): return self.status == self.SKIP @skipped.setter - def skipped(self, skipped): + def skipped(self, skipped: 'Literal[True]'): if not skipped: - raise ValueError("`skipped` value must be truthy, got '%s'." % skipped) + raise ValueError(f"`skipped` value must be truthy, got '{skipped}'.") self.status = self.SKIP @property - def not_run(self): + def not_run(self) -> bool: """``True`` when :attr:`status` is 'NOT RUN', ``False`` otherwise. Setting to ``False`` value is ambiguous and raises an exception. @@ -166,9 +198,9 @@ def not_run(self): return self.status == self.NOT_RUN @not_run.setter - def not_run(self, not_run): + def not_run(self, not_run: 'Literal[True]'): if not not_run: - raise ValueError("`not_run` value must be truthy, got '%s'." % not_run) + raise ValueError(f"`not_run` value must be truthy, got '{not_run}'.") self.status = self.NOT_RUN @@ -179,26 +211,30 @@ class ForIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): repr_args = ('variables',) __slots__ = ['variables', 'status', 'starttime', 'endtime', 'doc'] - def __init__(self, variables=None, status='FAIL', starttime=None, endtime=None, - doc='', parent=None): - self.variables = variables or OrderedDict() + def __init__(self, variables: 'Mapping[str, str]|None' = None, + status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + doc: str = '', + parent: BodyItemParent = None): + self.variables = OrderedDict(variables or ()) self.parent = parent self.status = status self.starttime = starttime self.endtime = endtime self.doc = doc - self.body = None + self.body = [] @setter - def body(self, body): + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_for_iteration(self) @property @deprecated - def name(self): + def name(self) -> str: return ', '.join('%s = %s' % item for item in self.variables.items()) @@ -208,9 +244,17 @@ class For(model.For, StatusMixin, DeprecatedAttributesMixin): iteration_class = ForIteration __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, variables=(), flavor='IN', values=(), start=None, mode=None, - fill=None, status='FAIL', starttime=None, endtime=None, doc='', - parent=None): + def __init__(self, variables: Sequence[str] = (), + flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', + values: Sequence[str] = (), + start: 'str|None' = None, + mode: 'str|None' = None, + fill: 'str|None' = None, + status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + doc: str = '', + parent: BodyItemParent = None): super().__init__(variables, flavor, values, start, mode, fill, parent) self.status = status self.starttime = starttime @@ -218,12 +262,12 @@ def __init__(self, variables=(), flavor='IN', values=(), start=None, mode=None, self.doc = doc @setter - def body(self, iterations): + def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> Iterations: return self.iterations_class(self.iteration_class, self, iterations) @property @deprecated - def name(self): + def name(self) -> str: variables = ' | '.join(self.variables) values = ' | '.join(self.values) for name, value in [('start', self.start), @@ -240,25 +284,28 @@ class WhileIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): body_class = Body __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, status='FAIL', starttime=None, endtime=None, - doc='', parent=None): + def __init__(self, status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + doc: str = '', + parent: BodyItemParent = None): self.parent = parent self.status = status self.starttime = starttime self.endtime = endtime self.doc = doc - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): visitor.visit_while_iteration(self) @property @deprecated - def name(self): + def name(self) -> str: return '' @@ -268,9 +315,15 @@ class While(model.While, StatusMixin, DeprecatedAttributesMixin): iteration_class = WhileIteration __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, condition=None, limit=None, on_limit=None, - on_limit_message=None, parent=None, status='FAIL', - starttime=None, endtime=None, doc=''): + def __init__(self, condition: 'str|None' = None, + limit: 'str|None' = None, + on_limit: 'str|None' = None, + on_limit_message: 'str|None' = None, + status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + doc: str = '', + parent: BodyItemParent = None): super().__init__(condition, limit, on_limit, on_limit_message, parent) self.status = status self.starttime = starttime @@ -278,12 +331,12 @@ def __init__(self, condition=None, limit=None, on_limit=None, self.doc = doc @setter - def body(self, iterations): + def body(self, iterations: 'Sequence[WhileIteration|DataDict]') -> Iterations: return self.iterations_class(self.iteration_class, self, iterations) @property @deprecated - def name(self): + def name(self) -> str: parts = [] if self.condition: parts.append(self.condition) @@ -300,8 +353,13 @@ class IfBranch(model.IfBranch, StatusMixin, DeprecatedAttributesMixin): body_class = Body __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, type=BodyItem.IF, condition=None, status='FAIL', - starttime=None, endtime=None, doc='', parent=None): + def __init__(self, type: str = BodyItem.IF, + condition: 'str|None' = None, + status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + doc: str = '', + parent: BodyItemParent = None): super().__init__(type, condition, parent) self.status = status self.starttime = starttime @@ -310,8 +368,8 @@ def __init__(self, type=BodyItem.IF, condition=None, status='FAIL', @property @deprecated - def name(self): - return self.condition + def name(self) -> str: + return self.condition or '' @Body.register @@ -320,7 +378,11 @@ class If(model.If, StatusMixin, DeprecatedAttributesMixin): branches_class = Branches __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, status='FAIL', starttime=None, endtime=None, doc='', parent=None): + def __init__(self, status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + doc: str = '', + parent: BodyItemParent = None): super().__init__(parent) self.status = status self.starttime = starttime @@ -332,8 +394,15 @@ class TryBranch(model.TryBranch, StatusMixin, DeprecatedAttributesMixin): body_class = Body __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, type=BodyItem.TRY, patterns=(), pattern_type=None, variable=None, - status='FAIL', starttime=None, endtime=None, doc='', parent=None): + def __init__(self, type: str = BodyItem.TRY, + patterns: Sequence[str] = (), + pattern_type: 'str|None' = None, + variable: 'str|None' = None, + status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + doc: str = '', + parent: BodyItemParent = None): super().__init__(type, patterns, pattern_type, variable, parent) self.status = status self.starttime = starttime @@ -342,7 +411,7 @@ def __init__(self, type=BodyItem.TRY, patterns=(), pattern_type=None, variable=N @property @deprecated - def name(self): + def name(self) -> str: patterns = list(self.patterns) if self.pattern_type: patterns.append(f'type={self.pattern_type}') @@ -360,7 +429,11 @@ class Try(model.Try, StatusMixin, DeprecatedAttributesMixin): branches_class = Branches __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, status='FAIL', starttime=None, endtime=None, doc='', parent=None): + def __init__(self, status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + doc: str = '', + parent: BodyItemParent = None): super().__init__(parent) self.status = status self.starttime = starttime @@ -373,15 +446,19 @@ class Return(model.Return, StatusMixin, DeprecatedAttributesMixin): __slots__ = ['status', 'starttime', 'endtime'] body_class = Body - def __init__(self, values=(), status='FAIL', starttime=None, endtime=None, parent=None): + def __init__(self, values: Sequence[str] = (), + status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + parent: BodyItemParent = None): super().__init__(values, parent) self.status = status self.starttime = starttime self.endtime = endtime - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Child keywords and messages as a :class:`~.Body` object. Typically empty. Only contains something if running RETURN has failed @@ -392,12 +469,12 @@ def body(self, body): @property @deprecated - def args(self): + def args(self) -> 'tuple[str, ...]': return self.values @property @deprecated - def doc(self): + def doc(self) -> str: return '' @@ -406,15 +483,18 @@ class Continue(model.Continue, StatusMixin, DeprecatedAttributesMixin): __slots__ = ['status', 'starttime', 'endtime'] body_class = Body - def __init__(self, status='FAIL', starttime=None, endtime=None, parent=None): + def __init__(self, status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + parent: BodyItemParent = None): super().__init__(parent) self.status = status self.starttime = starttime self.endtime = endtime - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Child keywords and messages as a :class:`~.Body` object. Typically empty. Only contains something if running CONTINUE has failed @@ -425,12 +505,12 @@ def body(self, body): @property @deprecated - def args(self): + def args(self) -> 'tuple[str, ...]': return () @property @deprecated - def doc(self): + def doc(self) -> str: return '' @@ -439,15 +519,18 @@ class Break(model.Break, StatusMixin, DeprecatedAttributesMixin): __slots__ = ['status', 'starttime', 'endtime'] body_class = Body - def __init__(self, status='FAIL', starttime=None, endtime=None, parent=None): + def __init__(self, status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + parent: BodyItemParent = None): super().__init__(parent) self.status = status self.starttime = starttime self.endtime = endtime - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Child keywords and messages as a :class:`~.Body` object. Typically empty. Only contains something if running BREAK has failed @@ -458,12 +541,12 @@ def body(self, body): @property @deprecated - def args(self): + def args(self) -> 'tuple[str, ...]': return () @property @deprecated - def doc(self): + def doc(self) -> str: return '' @@ -472,15 +555,19 @@ class Error(model.Error, StatusMixin, DeprecatedAttributesMixin): __slots__ = ['status', 'starttime', 'endtime'] body_class = Body - def __init__(self, values=(), status='FAIL', starttime=None, endtime=None, parent=None): + def __init__(self, values: Sequence[str] = (), + status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + parent: BodyItemParent = None): super().__init__(values, parent) self.status = status self.starttime = starttime self.endtime = endtime - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Messages as a :class:`~.Body` object. Typically contains the message that caused the error. @@ -489,17 +576,17 @@ def body(self, body): @property @deprecated - def kwname(self): + def kwname(self) -> str: return self.values[0] @property @deprecated - def args(self): + def args(self) -> 'tuple[str, ...]': return self.values[1:] @property @deprecated - def doc(self): + def doc(self) -> 'str': return '' @@ -512,9 +599,19 @@ class Keyword(model.Keyword, StatusMixin): __slots__ = ['kwname', 'libname', 'doc', 'timeout', 'status', '_teardown', 'starttime', 'endtime', 'message', 'sourcename'] - def __init__(self, kwname='', libname='', doc='', args=(), assign=(), tags=(), - timeout=None, type=BodyItem.KEYWORD, status='FAIL', starttime=None, - endtime=None, parent=None, sourcename=None): + def __init__(self, kwname: str = '', + libname: str = '', + doc: str = '', + args: Sequence[str] = (), + assign: Sequence[str] = (), + tags: Sequence[str] = (), + timeout: 'str|None' = None, + type: str = BodyItem.KEYWORD, + status: str = 'FAIL', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + sourcename: 'str|None' = None, + parent: BodyItemParent = None): super().__init__(None, args, assign, type, parent) #: Name of the keyword without library or resource name. self.kwname = kwname @@ -531,10 +628,10 @@ def __init__(self, kwname='', libname='', doc='', args=(), assign=(), tags=(), #: Original name of keyword with embedded arguments. self.sourcename = sourcename self._teardown = None - self.body = None + self.body = () @setter - def body(self, body): + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Possible keyword body as a :class:`~.Body` object. Body can consist of child keywords, messages, and control structures @@ -543,7 +640,7 @@ def body(self, body): return self.body_class(self, body) @property - def keywords(self): + def keywords(self) -> Keywords: """Deprecated since Robot Framework 4.0. Use :attr:`body` or :attr:`teardown` instead. @@ -558,16 +655,16 @@ def keywords(self, keywords): Keywords.raise_deprecation_error() @property - def messages(self): + def messages(self) -> 'list[Message]': """Keyword's messages. Starting from Robot Framework 4.0 this is a list generated from messages in :attr:`body`. """ - return self.body.filter(messages=True) + return self.body.filter(messages=True) # type: ignore @property - def children(self): + def children(self) -> 'list[BodyItem]': """List of child keywords and messages in creation order. Deprecated since Robot Framework 4.0. Use :attr:`body` instead. @@ -576,7 +673,7 @@ def children(self): return list(self.body) @property - def name(self): + def name(self) -> 'str|None': """Keyword name in format ``libname.kwname``. Just ``kwname`` if :attr:`libname` is empty. In practice that is the @@ -588,7 +685,7 @@ def name(self): """ if not self.libname: return self.kwname - return '%s.%s' % (self.libname, self.kwname) + return f'{self.libname}.{self.kwname}' @name.setter def name(self, name): @@ -599,7 +696,7 @@ def name(self, name): self.libname = None @property # Cannot use @setter because it would create teardowns recursively. - def teardown(self): + def teardown(self) -> 'Keyword': """Keyword teardown as a :class:`Keyword` object. Teardown can be modified by setting attributes directly:: @@ -629,14 +726,16 @@ def teardown(self): """ if self._teardown is None and self: self._teardown = create_fixture(None, self, self.TEARDOWN) - return self._teardown + # Would be better to enhance `create_fixture` so that its return + # type would match argument type. + return cast(Keyword, self._teardown) @teardown.setter - def teardown(self, teardown): + def teardown(self, teardown: 'Keyword|DataDict|None'): self._teardown = create_fixture(teardown, self, self.TEARDOWN) @property - def has_teardown(self): + def has_teardown(self) -> bool: """Check does a keyword have a teardown without creating a teardown object. A difference between using ``if kw.has_teardown:`` and ``if kw.teardown:`` @@ -650,7 +749,7 @@ def has_teardown(self): return bool(self._teardown) @setter - def tags(self, tags): + def tags(self, tags: Sequence[str]) -> model.Tags: """Keyword tags as a :class:`~.model.tags.Tags` object.""" return Tags(tags) @@ -664,9 +763,16 @@ class TestCase(model.TestCase, StatusMixin): body_class = Body fixture_class = Keyword - def __init__(self, name='', doc='', tags=None, timeout=None, lineno=None, - status='FAIL', message='', starttime=None, endtime=None, - parent=None): + def __init__(self, name: str = '', + doc: str = '', + tags: Sequence[str] = (), + timeout: 'str|None' = None, + lineno: 'int|None' = None, + status: str = 'FAIL', + message: str = '', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + parent: 'TestSuite|None' = None): super().__init__(name, doc, tags, timeout, lineno, parent) #: Status as a string ``PASS`` or ``FAIL``. See also :attr:`passed`. self.status = status @@ -679,11 +785,11 @@ def __init__(self, name='', doc='', tags=None, timeout=None, lineno=None, self.endtime = endtime @property - def not_run(self): + def not_run(self) -> bool: return False @property - def critical(self): + def critical(self) -> bool: warnings.warn("'TestCase.critical' is deprecated and always returns 'True'.") return True @@ -702,8 +808,15 @@ class TestSuite(model.TestSuite, StatusMixin): test_class = TestCase fixture_class = Keyword - def __init__(self, name='', doc='', metadata=None, source=None, message='', - starttime=None, endtime=None, rpa=False, parent=None): + def __init__(self, name: str = '', + doc: str = '', + metadata: 'Mapping[str, str]|None' = None, + source: 'Path|str|None' = None, + rpa: bool = False, + message: str = '', + starttime: 'str|None' = None, + endtime: 'str|None' = None, + parent: 'TestSuite|None' = None): super().__init__(name, doc, metadata, source, rpa, parent) #: Possible suite setup or teardown error message. self.message = message @@ -713,26 +826,26 @@ def __init__(self, name='', doc='', metadata=None, source=None, message='', self.endtime = endtime @property - def passed(self): + def passed(self) -> bool: """``True`` if no test has failed but some have passed, ``False`` otherwise.""" return self.status == self.PASS @property - def failed(self): + def failed(self) -> bool: """``True`` if any test has failed, ``False`` otherwise.""" return self.status == self.FAIL @property - def skipped(self): + def skipped(self) -> bool: """``True`` if there are no passed or failed tests, ``False`` otherwise.""" return self.status == self.SKIP @property - def not_run(self): + def not_run(self) -> bool: return False @property - def status(self): + def status(self) -> "Literal['PASS', 'SKIP', 'FAIL']": """'PASS', 'FAIL' or 'SKIP' depending on test statuses. - If any test has failed, status is 'FAIL'. @@ -748,7 +861,7 @@ def status(self): return self.SKIP @property - def statistics(self): + def statistics(self) -> TotalStatistics: """Suite statistics as a :class:`~robot.model.totalstatistics.TotalStatistics` object. Recreated every time this property is accessed, so saving the results @@ -759,22 +872,22 @@ def statistics(self): print(stats.total) print(stats.message) """ - return TotalStatisticsBuilder(self, self.rpa).stats + return TotalStatisticsBuilder(self, bool(self.rpa)).stats @property - def full_message(self): + def full_message(self) -> str: """Combination of :attr:`message` and :attr:`stat_message`.""" if not self.message: return self.stat_message - return '%s\n\n%s' % (self.message, self.stat_message) + return f'{self.message}\n\n{self.stat_message}' @property - def stat_message(self): + def stat_message(self) -> str: """String representation of the :attr:`statistics`.""" return self.statistics.message @property - def elapsedtime(self): + def elapsedtime(self) -> int: """Total execution time in milliseconds.""" if self.starttime and self.endtime: return get_elapsed_time(self.starttime, self.endtime) @@ -789,7 +902,7 @@ def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> TestSuites['TestSuit def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases[TestCase]: return TestCases[TestCase](self.test_class, self, tests) - def remove_keywords(self, how): + def remove_keywords(self, how: str): """Remove keywords based on the given condition. :param how: What approach to use when removing keywords. Either @@ -800,7 +913,7 @@ def remove_keywords(self, how): """ self.visit(KeywordRemover(how)) - def filter_messages(self, log_level='TRACE'): + def filter_messages(self, log_level: str = 'TRACE'): """Remove log messages below the specified ``log_level``.""" self.visit(MessageFilter(log_level)) @@ -822,17 +935,17 @@ def configure(self, **options): and keywords have to make it possible to set multiple attributes in one call. """ - model.TestSuite.configure(self) # Parent validates call is allowed. + super().configure() # Parent validates is call allowed. self.visit(SuiteConfigurer(**options)) def handle_suite_teardown_failures(self): """Internal usage only.""" self.visit(SuiteTeardownFailureHandler()) - def suite_teardown_failed(self, error): + def suite_teardown_failed(self, message: str): """Internal usage only.""" - self.visit(SuiteTeardownFailed(error)) + self.visit(SuiteTeardownFailed(message)) - def suite_teardown_skipped(self, message): + def suite_teardown_skipped(self, message: str): """Internal usage only.""" self.visit(SuiteTeardownFailed(message, skipped=True)) diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index fcff6bce93b..2df07c964ab 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -41,7 +41,7 @@ def test_default_suite(self): self._verify_suite(TestSuite()) def test_suite_with_values(self): - suite = TestSuite('Name', 'Doc', {'m1': 'v1', 'M2': 'V2'}, None, 'Message', + suite = TestSuite('Name', 'Doc', {'m1': 'v1', 'M2': 'V2'}, None, False, 'Message', '20111204 19:00:00.000', '20111204 19:00:42.001') self._verify_suite(suite, 'Name', 'Doc', ('m1', '<p>v1</p>', 'M2', '<p>V2</p>'), message='Message', start=0, elapsed=42001) From f7d7a349a3ecfc4ff57f66224a1a5f47166c1040 Mon Sep 17 00:00:00 2001 From: ursa-h <132347511+ursa-h@users.noreply.github.com> Date: Wed, 17 May 2023 19:34:09 +0300 Subject: [PATCH 0563/1592] Resolve conflict first using search order if multiple keywords match Fixes #4609. --- .../embedded_arguments_conflicts.robot | 10 +++++-- atest/robot/keywords/keyword_namespaces.robot | 4 +-- atest/robot/keywords/private.robot | 5 ++-- .../builtin/set_library_search_order.robot | 5 +++- .../builtin/set_resource_search_order.robot | 5 +++- .../embedded_arguments_conflicts.robot | 26 ++++++++++++++++--- .../embedded_arguments_conflicts/library.py | 8 ++++++ .../embedded_arguments_conflicts/library2.py | 3 +++ .../resource.resource | 14 +++++++--- .../resource2.resource | 3 +++ .../keywords/keyword_namespaces.robot | 2 +- atest/testdata/keywords/private.resource | 7 +++++ atest/testdata/keywords/private.robot | 4 +-- atest/testdata/keywords/private2.resource | 3 +++ atest/testdata/keywords/private3.resource | 4 +++ .../set_library_search_order/TestLibrary.py | 7 +++++ .../set_library_search_order/embedded.py | 4 +++ .../set_library_search_order/embedded2.py | 17 ++++++++++++ .../setting_library_order.robot | 19 ++++++++++++-- .../embedded.resource | 6 +++++ .../embedded2.resource | 13 ++++++++++ .../set_resource_search_order/resource1.robot | 7 +++++ .../setting_resource_order.robot | 19 ++++++++++++-- .../CreatingTestData/CreatingUserKeywords.rst | 6 ++--- src/robot/running/namespace.py | 10 +++---- 25 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 atest/testdata/standard_libraries/builtin/set_library_search_order/embedded2.py create mode 100644 atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded2.resource diff --git a/atest/robot/keywords/embedded_arguments_conflicts.robot b/atest/robot/keywords/embedded_arguments_conflicts.robot index b93ab55491f..68779178bf0 100644 --- a/atest/robot/keywords/embedded_arguments_conflicts.robot +++ b/atest/robot/keywords/embedded_arguments_conflicts.robot @@ -52,21 +52,27 @@ Conflict in library with explicit usage Search order resolves conflict with resources Check Test Case ${TESTNAME} -Best match in resource wins over search order +Search order wins over best match in resource Check Test Case ${TESTNAME} Search order resolves conflict with libraries Check Test Case ${TESTNAME} -Best match in library wins over search order +Search order wins over best match in libraries Check Test Case ${TESTNAME} Search order cannot resolve conflict within resource Check Test Case ${TESTNAME} +Search order causes conflict within resource + Check Test Case ${TESTNAME} + Search order cannot resolve conflict within library Check Test Case ${TESTNAME} +Search order causes conflict within library + Check Test Case ${TESTNAME} + Public match wins over better private match in different resource Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/keyword_namespaces.robot b/atest/robot/keywords/keyword_namespaces.robot index f5c6cb75102..938c59103a2 100644 --- a/atest/robot/keywords/keyword_namespaces.robot +++ b/atest/robot/keywords/keyword_namespaces.robot @@ -39,10 +39,10 @@ Local keyword in resource file has precedence over keywords in other resource fi Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} Keyword in resource 1 Check Log Message ${tc.body[1].body[0].body[0].msgs[0]} Keyword in resource 2 -Local keyword in resource file has precedence even if search order is set +Search order has precedence over local keyword in resource file ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} Keyword in resource 1 - Check Log Message ${tc.body[1].body[0].body[0].msgs[0]} Keyword in resource 2 + Check Log Message ${tc.body[1].body[0].body[0].msgs[0]} Keyword in resource 1 Keyword From Custom Library Overrides Keywords From Standard Library ${tc} = Check Test Case ${TEST NAME} diff --git a/atest/robot/keywords/private.robot b/atest/robot/keywords/private.robot index bdb73611e92..f85ee0a7bec 100644 --- a/atest/robot/keywords/private.robot +++ b/atest/robot/keywords/private.robot @@ -31,10 +31,9 @@ Local Private Keyword In Resource File Has Precedence Over Keywords In Another R Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} private.resource Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} private.resource -Local Private Keyword In Resource File Has Precedence Even If Search Order Is Set +Search Order Has Precedence Over Local Private Keyword In Resource File ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} private.resource - Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} private.resource + Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} private2.resource Imported Public Keyword Has Precedence Over Imported Private Keywords ${tc}= Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/set_library_search_order.robot b/atest/robot/standard_libraries/builtin/set_library_search_order.robot index 2b2d940909f..bb9d316a7f0 100644 --- a/atest/robot/standard_libraries/builtin/set_library_search_order.robot +++ b/atest/robot/standard_libraries/builtin/set_library_search_order.robot @@ -39,5 +39,8 @@ Library Search Order Is Space Insensitive Library Search Order Is Case Insensitive Check Test Case ${TEST NAME} -Exact match wins over match containing embedded arguments regardless search order +Search Order Controlled Match Containing Embedded Arguments Wins Over Exact Match + Check Test Case ${TEST NAME} + +Best Search Order Controlled Match Wins In Library Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/set_resource_search_order.robot b/atest/robot/standard_libraries/builtin/set_resource_search_order.robot index af80b4adcac..1d0a1bbd403 100644 --- a/atest/robot/standard_libraries/builtin/set_resource_search_order.robot +++ b/atest/robot/standard_libraries/builtin/set_resource_search_order.robot @@ -39,5 +39,8 @@ Resource Search Order Is Case Insensitive Default Resource Order Should Be Suite Specific Check Test Case ${TEST NAME} -Exact match wins over match containing embedded arguments regardless search order +Search Order Controlled Match Containing Embedded Arguments Wins Over Exact Match + Check Test Case ${TEST NAME} + +Best Search Order Controlled Match Wins In Resource Check Test Case ${TEST NAME} diff --git a/atest/testdata/keywords/embedded_arguments_conflicts.robot b/atest/testdata/keywords/embedded_arguments_conflicts.robot index f2924a5fc2a..699d717cc54 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts.robot +++ b/atest/testdata/keywords/embedded_arguments_conflicts.robot @@ -91,9 +91,9 @@ Search order resolves conflict with resources Match in both resources [Teardown] Disable search order -Best match in resource wins over search order +Search order wins over best match in resource [Setup] Enable search order - Best match in one of resources + Follow search order in resources [Teardown] Disable search order Search order resolves conflict with libraries @@ -101,9 +101,9 @@ Search order resolves conflict with libraries Match in both libraries [Teardown] Disable search order -Best match in library wins over search order +Search order wins over best match in libraries [Setup] Enable search order - Best match in one of libraries + Follow search order in libraries [Teardown] Disable search order Search order cannot resolve conflict within resource @@ -115,6 +115,15 @@ Search order cannot resolve conflict within resource Unresolvable conflict in resource [Teardown] Disable search order +Search order causes conflict within resource + [Documentation] FAIL + ... Multiple keywords matching name 'Unresolvable conflict in resource' found: + ... ${INDENT}resource2.\${possible} conflict in resource + ... ${INDENT}resource2.Unresolvable \${conflict} in resource + [Setup] Enable search order + Cause unresolvable conflict in resource due to search order + [Teardown] Disable search order + Search order cannot resolve conflict within library [Documentation] FAIL ... Multiple keywords matching name 'Unresolvable conflict in library' found: @@ -124,6 +133,15 @@ Search order cannot resolve conflict within library Unresolvable conflict in library [Teardown] Disable search order +Search order causes conflict within library + [Documentation] FAIL + ... Multiple keywords matching name 'Unresolvable conflict in library' found: + ... ${INDENT}library2.\${possible} conflict in library + ... ${INDENT}library2.Unresolvable \${conflict} in library + [Setup] Enable search order + Cause unresolvable conflict in library due to search order + [Teardown] Disable search order + Public match wins over better private match in different resource [Documentation] and better match wins when both are in same file Better public match diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/library.py b/atest/testdata/keywords/embedded_arguments_conflicts/library.py index 49e0e925d11..c1d90974362 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/library.py +++ b/atest/testdata/keywords/embedded_arguments_conflicts/library.py @@ -26,3 +26,11 @@ def match_in_both_libraries(match, both): def best_match_in_one_of_libraries(match, one_of): assert match == 'match' assert one_of == 'one of' + +@keyword('Follow search ${disorder} in libraries') +def follow_search_order_in_libraries(disorder): + assert disorder == 'disorder should not happen' + +@keyword('Unresolvable conflict in library') +def unresolvable_conflict_in_library(): + assert False diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/library2.py b/atest/testdata/keywords/embedded_arguments_conflicts/library2.py index 9a7186f3345..e3a7e11e4d4 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/library2.py +++ b/atest/testdata/keywords/embedded_arguments_conflicts/library2.py @@ -6,6 +6,9 @@ def match_in_both_libraries(match, both): assert match == 'Match' assert both == 'both' +@keyword('Follow search ${order} in libraries') +def follow_search_order_in_libraries(order): + assert order == 'order' @keyword('${match} libraries') def match_libraries(match): diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource index e810f8ea168..9fd7d391061 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource @@ -12,9 +12,8 @@ ${y:y} in resource ${match} in ${both} resources Fail Should not be run due to search order -Best ${match} in ${one of} resources - Should be equal ${match} match - Should be equal ${one of} one of +Follow search ${disorder} in resources + Fail Should not be run due to search order ${public} match Should be equal ${public} Better public @@ -33,3 +32,12 @@ Another match ${in both resource files} Match with and without embedded arguments in different files No operation + +Cause unresolvable conflict in resource due to search order + Unresolvable conflict in resource + +Cause unresolvable conflict in library due to search order + Unresolvable conflict in library + +Unresolvable conflict in resource + Fail Should not be run due to search order diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource index 2f6cdbd79a0..0613370357a 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource @@ -6,6 +6,9 @@ ${match} in ${both} resources ${match} resources Fail Should not be run due to being worse match than above +Follow search ${order} in resources + Should be equal ${order} order + Unresolvable ${conflict} in resource Fail Should not be run due to conflict diff --git a/atest/testdata/keywords/keyword_namespaces.robot b/atest/testdata/keywords/keyword_namespaces.robot index 81a015abbbc..074b410e73c 100644 --- a/atest/testdata/keywords/keyword_namespaces.robot +++ b/atest/testdata/keywords/keyword_namespaces.robot @@ -63,7 +63,7 @@ Local keyword in resource file has precedence over keywords in other resource fi Use local keyword that exists also in another resource 1 Use local keyword that exists also in another resource 2 -Local keyword in resource file has precedence even if search order is set +Search order has precedence over local keyword in resource file [Setup] Set library search order my_resource_1 Use local keyword that exists also in another resource 1 Use local keyword that exists also in another resource 2 diff --git a/atest/testdata/keywords/private.resource b/atest/testdata/keywords/private.resource index 42121464728..5433905a8be 100644 --- a/atest/testdata/keywords/private.resource +++ b/atest/testdata/keywords/private.resource @@ -14,6 +14,9 @@ Use Local Private Keyword Instead Of Keywords From Other Resources Private Keyword In All Resources Private In Two Resources And Public In One +Use Search Order Instead Of Private Keyword When Prioritized Resource Keyword Is Public + Private In Resource 1 And 3 And Public In Resource 2 + Private Keyword In All Resources [Tags] ROBOT: private Log private.resource @@ -31,3 +34,7 @@ Call Private Keyword From Private 2 Resource Private In One Resource And Public In Two [Tags] robot:private Fail Not executed + +Private In Local And One Resource And Public In Another + [Tags] robot:private + Fail Not executed diff --git a/atest/testdata/keywords/private.robot b/atest/testdata/keywords/private.robot index 38a32404f17..b4ce4349b0d 100644 --- a/atest/testdata/keywords/private.robot +++ b/atest/testdata/keywords/private.robot @@ -22,9 +22,9 @@ Invalid Usage In Resource file Local Private Keyword In Resource File Has Precedence Over Keywords In Another Resource Use Local Private Keyword Instead Of Keywords From Other Resources -Local Private Keyword In Resource File Has Precedence Even If Search Order Is Set +Search Order Has Precedence Over Local Private Keyword In Resource File [Setup] Set Library Search Order private2 private3 - Use Local Private Keyword Instead Of Keywords From Other Resources + Use Search Order Instead Of Private Keyword When Prioritized Resource Keyword Is Public [Teardown] Set Library Search Order Imported Public Keyword Has Precedence Over Imported Private Keywords diff --git a/atest/testdata/keywords/private2.resource b/atest/testdata/keywords/private2.resource index 847eeb43f30..8606bd08dc8 100644 --- a/atest/testdata/keywords/private2.resource +++ b/atest/testdata/keywords/private2.resource @@ -16,3 +16,6 @@ Private In One Resource And Public In Another Private In One Resource And Public In Two Fail Not executed + +Private In Resource 1 And 3 And Public In Resource 2 + Log private2.resource diff --git a/atest/testdata/keywords/private3.resource b/atest/testdata/keywords/private3.resource index b2462bc90c1..25247ec1c9e 100644 --- a/atest/testdata/keywords/private3.resource +++ b/atest/testdata/keywords/private3.resource @@ -12,3 +12,7 @@ Private In One Resource And Public In Another Private In One Resource And Public In Two Fail Not executed + +Private In Resource 1 And 3 And Public In Resource 2 + [Tags] ROBOT: PRIVATE + Fail Not executed diff --git a/atest/testdata/standard_libraries/builtin/set_library_search_order/TestLibrary.py b/atest/testdata/standard_libraries/builtin/set_library_search_order/TestLibrary.py index 2976efc4391..73e90f84054 100644 --- a/atest/testdata/standard_libraries/builtin/set_library_search_order/TestLibrary.py +++ b/atest/testdata/standard_libraries/builtin/set_library_search_order/TestLibrary.py @@ -11,3 +11,10 @@ def get_name(self): def no_operation(self): return self.name +def get_name_with_search_order(name): + raise AssertionError('Should not be run due to search order ' + 'having higher precedence.') + +def get_best_match_ever_with_search_order(): + raise AssertionError('Should not be run due to search order ' + 'having higher precedence.') diff --git a/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded.py b/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded.py index 6dd0927d1c8..29eb5f7a4c2 100644 --- a/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded.py +++ b/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded.py @@ -11,3 +11,7 @@ def no_operation(ope): def get_name(name): raise AssertionError('Should not be run due to keywords with normal ' 'arguments having higher precedence.') + +@keyword('Get ${Name} With Search Order') +def get_name_with_search_order(name): + return "embedded" diff --git a/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded2.py b/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded2.py new file mode 100644 index 00000000000..81f91bbe08a --- /dev/null +++ b/atest/testdata/standard_libraries/builtin/set_library_search_order/embedded2.py @@ -0,0 +1,17 @@ +from robot.api.deco import keyword + + +@keyword('Get ${Match} With Search Order') +def get_best_match_ever_with_search_order(Match): + raise AssertionError('Should not be run due to a better match' + 'in same library.') + +@keyword('Get Best ${Match:\w+} With Search Order') +def get_best_match_with_search_order(Match): + raise AssertionError('Should not be run due to a better match' + 'in same library.') + +@keyword('Get Best ${Match} With Search Order') +def get_best_match_with_search_order(Match): + assert Match == "Match Ever" + return "embedded2" diff --git a/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot b/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot index bfe42e937ba..bab6c971759 100644 --- a/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot +++ b/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot @@ -5,6 +5,7 @@ Library TestLibrary.py Library2 WITH NAME Library2 Library TestLibrary.py Library3 WITH NAME Library3 Library TestLibrary.py Library With Space WITH NAME Library With Space Library embedded.py +Library embedded2.py *** Test Cases *** Library Order Set In Suite Setup Should Be Available In Test Cases @@ -63,9 +64,13 @@ Library Search Order Is Case Insensitive Set Library Search Order library3 Library1 Active Library Should Be Library3 -Exact match wins over match containing embedded arguments regardless search order +Search Order Controlled Match Containing Embedded Arguments Wins Over Exact Match Set Library Search Order embedded Library1 - Active Library Should Be Library1 + Active Library With Search Order Should Be embedded + +Best Search Order Controlled Match Wins In Library + Set Library Search Order embedded2 embedded Library1 + With Search Order The Best Matching Keyword Should Be Run In embedded2 *** Keywords *** Active Library Should Be @@ -77,3 +82,13 @@ Own Library Should Be Used [Arguments] ${expected} ${name} = No Operation Should Be Equal ${name} ${expected} + +Active Library With Search Order Should Be + [Arguments] ${expected} + ${name} = Get Name With Search Order + Should Be Equal ${name} ${expected} + +With Search Order The Best Matching Keyword Should Be Run In + [Arguments] ${expected} + ${name} = Get Best Match Ever With Search Order + Should Be Equal ${name} ${expected} \ No newline at end of file diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource index 0a5dbd8028a..cd8a9b7cd5e 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource @@ -1,3 +1,9 @@ *** Keywords *** Get ${Name:\w+} Fail Should not be run due to keywords with normal arguments having higher precedence + +${Get Name} With Search Order + Fail Should not be run due to better match in same resource + +Get ${Name:\w+} With Search Order + RETURN embedded diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded2.resource b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded2.resource new file mode 100644 index 00000000000..96ea7a690c2 --- /dev/null +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded2.resource @@ -0,0 +1,13 @@ +*** Keywords *** +Get ${Match} With Search Order + Fail Should not be run due to a better match in same resource + RETURN fail + +Get Best ${Match:\w+} With Search Order + Fail Should not be run due to a better match in same resource + RETURN fail + +Get Best ${Match} With Search Order + Should Be Equal ${Match} Match Ever + RETURN embedded2 + \ No newline at end of file diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot b/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot index 7cb1194dc78..c78119ef932 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot @@ -1,3 +1,10 @@ *** Keywords *** Get Name RETURN resource1 + +Get Name With Search Order + Fail Should not be run due to search order having higher precedence + +Get Best Match Ever With Search Order + Fail Should not be run due to search order + RETURN fail diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot b/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot index e66bea35cb5..483a4971d64 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot @@ -3,6 +3,7 @@ Suite Setup Set Library Search Order resource1 resource2 Resource resource1.robot Resource resource2.robot Resource embedded.resource +Resource embedded2.resource Library ../set_library_search_order/TestLibrary.py Library ../set_library_search_order/TestLibrary.py AnotherLibrary WITH NAME AnotherLibrary @@ -58,9 +59,13 @@ Resource Search Order Is Case Insensitive Set Library Search Order Resource1 resource2 Active Resource Should Be resource1 -Exact match wins over match containing embedded arguments regardless search order +Search Order Controlled Match Containing Embedded Arguments Wins Over Exact Match Set Library Search Order embedded resource1 - Active Resource Should Be resource1 + With Search Order Active Resource Should Be embedded + +Best Search Order Controlled Match Wins In Resource + Set Library Search Order embedded2 embedded resource1 + With Search Order The Best Matching Keyword Should Be Run In embedded2 *** Keywords *** Active Resource Should Be @@ -72,3 +77,13 @@ Active Library Should Be [Arguments] ${expected} ${name} = Get Library Name Should Be Equal ${name} ${expected} + +With Search Order Active Resource Should Be + [Arguments] ${expected} + ${name} = Get Name With Search Order + Should Be Equal ${name} ${expected} + +With Search Order The Best Matching Keyword Should Be Run In + [Arguments] ${expected} + ${name} = Get Best Match Ever With Search Order + Should Be Equal ${name} ${expected} \ No newline at end of file diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index aca23927c31..c22e80affef 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -702,9 +702,9 @@ succeeds: Before looking which match is best, Robot Framework checks are some of the matching keywords implemented in the same file as the caller keyword. If there are such keywords, -they are given precedence over other keywords. If there are still conflicts -after looking for best matches, Robot Framework checks can they be -resolved based on the `library search order`_. +they are given precedence over other keywords. Alternatively, `library search order`_ +can be used to control the order in which Robot Framework looks for keywords in resources +and libraries. .. note:: Automatically resolving conflicts if multiple keywords with embedded arguments match is a new feature in Robot Framework 6.0. With older diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index b0b23f3814c..67f0f3967b7 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -370,11 +370,11 @@ def _get_runner_from_resource_files(self, name): if not handlers: return None if len(handlers) > 1: - handlers = self._prioritize_same_file_or_public(handlers) + handlers = self._filter_based_on_search_order(handlers) if len(handlers) > 1: - handlers = self._select_best_matches(handlers) + handlers = self._prioritize_same_file_or_public(handlers) if len(handlers) > 1: - handlers = self._filter_based_on_search_order(handlers) + handlers = self._select_best_matches(handlers) if len(handlers) > 1: self._raise_multiple_keywords_found(handlers, name) return handlers[0].create_runner(name, self.languages) @@ -386,9 +386,9 @@ def _get_runner_from_libraries(self, name): return None pre_run_message = None if len(handlers) > 1: - handlers = self._select_best_matches(handlers) + handlers = self._filter_based_on_search_order(handlers) if len(handlers) > 1: - handlers = self._filter_based_on_search_order(handlers) + handlers = self._select_best_matches(handlers) if len(handlers) > 1: handlers, pre_run_message = self._filter_stdlib_handler(handlers) if len(handlers) > 1: From e253e6aaf46b3827b082d7b7e07622ce18a20f55 Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Mon, 22 May 2023 15:38:05 +0100 Subject: [PATCH 0564/1592] Enhance model.branches and result.Iterations typing (#4767) Part of #4570. --- src/robot/model/__init__.py | 2 +- src/robot/model/body.py | 22 ++++++++++++++------- src/robot/model/control.py | 22 +++++++++++++++------ src/robot/result/model.py | 38 ++++++++++++++++++++++++------------- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index 9ddbe440c84..9e78b7cd9ba 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -25,7 +25,7 @@ This package is considered stable. """ -from .body import BaseBody, Body, BodyItem, Branches +from .body import BaseBody, Body, BodyItem, BaseBranches from .configurer import SuiteConfigurer from .control import Break, Continue, Error, For, If, IfBranch, Return, Try, TryBranch, While from .fixture import create_fixture diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 5e2ee9c3390..151010f1e63 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -47,6 +47,7 @@ B = TypeVar('B', bound='Break') M = TypeVar('M', bound='Message') E = TypeVar('E', bound='Error') +IT = TypeVar('IT', bound='IfBranch|TryBranch') class BodyItem(ModelObject): @@ -160,9 +161,9 @@ def create(self): def _create(self, cls: 'Type[BI]', name: str, args: 'tuple[Any]', kwargs: 'dict[str, Any]') -> BI: - if not issubclass(cls, BodyItem): + if cls is KnownAtRuntime: raise TypeError(f"'{full_name(self)}' object does not support '{name}'.") - return self.append(cls(*args, **kwargs)) + return self.append(cls(*args, **kwargs)) # type: ignore @copy_signature(keyword_class) def create_keyword(self, *args, **kwargs) -> keyword_class: @@ -229,7 +230,7 @@ def filter(self, keywords: 'bool|None' = None, messages: 'bool|None' = None, use ``body.filter(keywords=False``, messages=False)``. For more detailed filtering it is possible to use ``predicate``. """ - if messages is not None and not issubclass(self.message_class, BodyItem): + if messages is not None and self.message_class is KnownAtRuntime: raise TypeError(f"'{full_name(self)}' object does not support " f"filtering by 'messages'.") return self._filter([(self.keyword_class, keywords), @@ -275,18 +276,25 @@ class Body(BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue' pass -class Branches(BaseBody): +class BranchType(Generic[IT]): + """Class that wrapps `Generic` as python doesn't allow multple generic inheritance""" + pass + + +class BaseBranches(BaseBody[KW, F, W, I, T, R, C, B, M, E], BranchType[IT]): """A list-like object representing IF and TRY branches.""" __slots__ = ['branch_class'] + branch_type: Type[IT] = KnownAtRuntime - def __init__(self, branch_class: 'Type[IfBranch|TryBranch]', + def __init__(self, branch_class: Type[IT], parent: BodyItemParent = None, items: 'Iterable[BodyItem|DataDict]' = ()): self.branch_class = branch_class super().__init__(parent, items) - def _item_from_dict(self, data: DataDict) -> 'IfBranch|TryBranch': + def _item_from_dict(self, data: DataDict) -> IT: return self.branch_class.from_dict(data) - def create_branch(self, *args, **kwargs) -> 'IfBranch|TryBranch': + @copy_signature(branch_type) + def create_branch(self, *args, **kwargs) -> IT: return self._create(self.branch_class, 'create_branch', args, kwargs) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 4f5e1214340..eda1a5fee70 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -14,17 +14,27 @@ # limitations under the License. import sys -from typing import Any, cast, Sequence +from typing import Any, cast, Sequence, TypeVar, TYPE_CHECKING if sys.version_info >= (3, 8): from typing import Literal from robot.utils import setter -from .body import Body, BodyItem, BodyItemParent, Branches +from .body import Body, BodyItem, BodyItemParent, BaseBranches from .keyword import Keywords from .modelobject import DataDict from .visitor import SuiteVisitor +if TYPE_CHECKING: + from robot.model import Keyword, Message + +IT = TypeVar('IT', bound='IfBranch|TryBranch') + + +class Branches(BaseBranches['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', + 'Break', 'Message', 'Error', IT]): + pass + @Body.register class For(BodyItem): @@ -200,7 +210,7 @@ class If(BodyItem): """IF/ELSE structure root. Branches are stored in :attr:`body`.""" type = BodyItem.IF_ELSE_ROOT branch_class = IfBranch - branches_class = Branches + branches_class = Branches[branch_class] __slots__ = [] def __init__(self, parent: BodyItemParent = None): @@ -208,7 +218,7 @@ def __init__(self, parent: BodyItemParent = None): self.body = () @setter - def body(self, branches: 'Sequence[BodyItem|DataDict]') -> Branches: + def body(self, branches: 'Sequence[BodyItem|DataDict]') -> branches_class: return self.branches_class(self.branch_class, self, branches) @property @@ -290,7 +300,7 @@ class Try(BodyItem): """TRY/EXCEPT structure root. Branches are stored in :attr:`body`.""" type = BodyItem.TRY_EXCEPT_ROOT branch_class = TryBranch - branches_class = Branches + branches_class = Branches[branch_class] __slots__ = [] def __init__(self, parent: BodyItemParent = None): @@ -298,7 +308,7 @@ def __init__(self, parent: BodyItemParent = None): self.body = () @setter - def body(self, branches: 'Sequence[TryBranch|DataDict]') -> Branches: + def body(self, branches: 'Sequence[TryBranch|DataDict]') -> branches_class: return self.branches_class(self.branch_class, self, branches) @property diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 29c2f02bca1..554b395cbd6 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -41,7 +41,8 @@ from datetime import datetime, timedelta from itertools import chain from pathlib import Path -from typing import cast, Mapping, Sequence, Type, Union +from typing import cast, Generic, Mapping, Sequence, Type, Union, TypeVar + if sys.version_info >= (3, 8): from typing import Literal @@ -49,7 +50,7 @@ from robot.model import (BodyItem, create_fixture, DataDict, Keywords, Tags, SuiteVisitor, TotalStatistics, TotalStatisticsBuilder, TestCases, TestSuites) -from robot.utils import get_elapsed_time, setter +from robot.utils import copy_signature, get_elapsed_time, KnownAtRuntime, setter from .configurer import SuiteConfigurer from .messagefilter import MessageFilter @@ -57,6 +58,8 @@ from .keywordremover import KeywordRemover from .suiteteardownfailed import SuiteTeardownFailed, SuiteTeardownFailureHandler +IT = TypeVar('IT', bound='IfBranch|TryBranch') +FW = TypeVar('FW', bound='ForIteration|WhileIteration') BodyItemParent = Union['TestSuite', 'TestCase', 'For', 'ForIteration', 'If', 'IfBranch', 'Try', 'TryBranch', 'While', 'WhileIteration', None] @@ -67,20 +70,29 @@ class Body(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Con __slots__ = [] -class Branches(model.Branches): +class Branches(model.BaseBranches['Keyword', 'For', 'While', 'If', 'Try', 'Return', + 'Continue', 'Break', 'Message', 'Error', IT]): __slots__ = [] -class Iterations(model.BaseBody): +class IterationType(Generic[FW]): + """Class that wrapps `Generic` as python doesn't allow multple generic inheritance""" + pass + + +class Iterations(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', + 'Continue', 'Break', 'Message', 'Error'], IterationType[FW]): __slots__ = ['iteration_class'] + iteration_type: Type[FW] = KnownAtRuntime - def __init__(self, iteration_class: Type['ForIteration|WhileIteration'], + def __init__(self, iteration_class: Type[FW], parent: BodyItemParent = None, - items: 'Sequence[ForIteration|WhileIteration|DataDict]' = ()): + items: 'Sequence[FW|DataDict]' = ()): self.iteration_class = iteration_class super().__init__(parent, items) - def create_iteration(self, *args, **kwargs) -> 'ForIteration|WhileIteration': + @copy_signature(iteration_type) + def create_iteration(self, *args, **kwargs) -> FW: return self._create(self.iteration_class, 'iteration_class', args, kwargs) @@ -240,8 +252,8 @@ def name(self) -> str: @Body.register class For(model.For, StatusMixin, DeprecatedAttributesMixin): - iterations_class = Iterations iteration_class = ForIteration + iterations_class = Iterations[iteration_class] __slots__ = ['status', 'starttime', 'endtime', 'doc'] def __init__(self, variables: Sequence[str] = (), @@ -262,7 +274,7 @@ def __init__(self, variables: Sequence[str] = (), self.doc = doc @setter - def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> Iterations: + def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> iterations_class: return self.iterations_class(self.iteration_class, self, iterations) @property @@ -311,8 +323,8 @@ def name(self) -> str: @Body.register class While(model.While, StatusMixin, DeprecatedAttributesMixin): - iterations_class = Iterations iteration_class = WhileIteration + iterations_class = Iterations[iteration_class] __slots__ = ['status', 'starttime', 'endtime', 'doc'] def __init__(self, condition: 'str|None' = None, @@ -331,7 +343,7 @@ def __init__(self, condition: 'str|None' = None, self.doc = doc @setter - def body(self, iterations: 'Sequence[WhileIteration|DataDict]') -> Iterations: + def body(self, iterations: 'Sequence[WhileIteration|DataDict]') -> iterations_class: return self.iterations_class(self.iteration_class, self, iterations) @property @@ -375,7 +387,7 @@ def name(self) -> str: @Body.register class If(model.If, StatusMixin, DeprecatedAttributesMixin): branch_class = IfBranch - branches_class = Branches + branches_class = Branches[branch_class] __slots__ = ['status', 'starttime', 'endtime', 'doc'] def __init__(self, status: str = 'FAIL', @@ -426,7 +438,7 @@ def name(self) -> str: @Body.register class Try(model.Try, StatusMixin, DeprecatedAttributesMixin): branch_class = TryBranch - branches_class = Branches + branches_class = Branches[branch_class] __slots__ = ['status', 'starttime', 'endtime', 'doc'] def __init__(self, status: str = 'FAIL', From 50d6db6e99e728b5f44df701d02b19c99eaf9553 Mon Sep 17 00:00:00 2001 From: Jonathan Arns <jonathan.arns@googlemail.com> Date: Tue, 23 May 2023 00:20:53 +0200 Subject: [PATCH 0565/1592] Add `--files` option to filter files before parsing (#4735) The option takes one or multiple simple glob-like patterns and then filters out any data file before parsing that does not have a filename which matches one or more of the patterns. See issue #4687 for more information. Co-authored-by: Fabian Zeiher <fzeiher@gmail.com> --- atest/robot/cli/runner/files.robot | 27 +++++++++++++++++++ .../src/Appendices/CommandLineOptions.rst | 2 ++ .../ConfiguringExecution.rst | 12 ++++++++- src/robot/conf/settings.py | 10 +++++-- src/robot/model/__init__.py | 2 +- src/robot/model/namepatterns.py | 9 +++++++ src/robot/parsing/suitestructure.py | 14 ++++++++-- src/robot/run.py | 7 ++++- src/robot/running/builder/builders.py | 8 +++++- 9 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 atest/robot/cli/runner/files.robot diff --git a/atest/robot/cli/runner/files.robot b/atest/robot/cli/runner/files.robot new file mode 100644 index 00000000000..d27d0fc60ab --- /dev/null +++ b/atest/robot/cli/runner/files.robot @@ -0,0 +1,27 @@ +*** Settings *** +Test Template Expected number of tests should be run +Resource atest_resource.robot + +*** Variables *** +${DATA FORMATS} ${DATADIR}/parsing/data_formats + +*** Test Cases *** + +Simple filename + -f sample.robot 18 + +Filtering by extension + --files *.robot 27 + --FILES s* 20 + +Multiple patterns + --files sample.robot --files tests.robot 25 + +Combine extension and files + --files sample.rst -F rst 18 + +*** Keywords *** +Expected number of tests should be run + [Arguments] ${options} ${expected}=0 + Run Tests ${options} ${DATA FORMATS} + Should Be Equal As Integers ${SUITE.test_count} ${expected} diff --git a/doc/userguide/src/Appendices/CommandLineOptions.rst b/doc/userguide/src/Appendices/CommandLineOptions.rst index 2f3b269cc24..9bbf65627ea 100644 --- a/doc/userguide/src/Appendices/CommandLineOptions.rst +++ b/doc/userguide/src/Appendices/CommandLineOptions.rst @@ -18,6 +18,7 @@ Command line options for test execution of a `built-in language <Translations_>`__, or a path or a module name of a custom language file. -F, --extension <value> `Parse only these files`_ when executing a directory. + -f, --files <pattern> `Parse only matching files`_ when executing a directory. -N, --name <name> `Sets the name`_ of the top-level test suite. -D, --doc <document> `Sets the documentation`_ of the top-level test suite. -M, --metadata <name:value> `Sets free metadata`_ for the top level test suite. @@ -150,6 +151,7 @@ Command line options for post-processing outputs .. _generic automation: `Task execution`_ .. _Parse only these files: `Selecting files to parse`_ +.. _Parse only matching files: `Selecting files to parse`_ .. _Sets the name: `Setting suite name`_ .. _Sets the documentation: `Setting suite documentation`_ .. _Sets free metadata: `Setting free suite metadata`_ diff --git a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst index 1e407217a1d..5162dbd9f86 100644 --- a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst @@ -31,11 +31,21 @@ extensions, the :option:`--extension (-F)` option must be used to explicitly tell the framework to parse also them. If there is a need to parse more than one kind of files, it is possible to use a colon `:` to separate extensions. Matching extensions is case insensitive and the leading `.` -can be omitted:: +can be omitted. You can additionally filter files before parsing using +the :option:`--files (-f)` option with a `simple pattern`_. The option +can be used multiple times to match multiple patterns. Arguments to the +:option:`--files (-f)` option are case- and space-insensitive. +If the :option:`--files (-f)` option matches files with a non-default extension, +the :option:`--extension (-F)` option must be added in order for those files +to also be parsed. +:: robot path/to/tests/ # Parse only *.robot files. robot --extension TSV path/to/tests # Parse only *.tsv files. robot -F robot:rst path/to/tests # Parse *.robot and *.rst files. + robot --files foo.robot path/to/tests # Parse only files named foo.robot. + robot -f foo* path/to/tests # Parse only .robot files starting with foo. + robot -f foo* -F txt path/to/tests # Parse only .txt files starting with foo. If files in one format use different extensions like :file:`.rst` and :file:`.rest`, they must be specified separately. Using just one of them diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index 40ed8b85b10..aab66ede221 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -42,6 +42,7 @@ class _BaseSettings: 'TestNames' : ('test', []), 'TaskNames' : ('task', []), 'SuiteNames' : ('suite', []), + 'FilePatterns' : ('files', []), 'SetTag' : ('settag', []), 'Include' : ('include', []), 'Exclude' : ('exclude', []), @@ -392,6 +393,10 @@ def split_log(self): def suite_names(self): return self._filter_empty(self['SuiteNames']) + @property + def file_patterns(self): + return self._filter_empty(self['FilePatterns']) + def _filter_empty(self, items): return [i for i in items if i] or None @@ -484,8 +489,9 @@ class RobotSettings(_BaseSettings): def get_rebot_settings(self): settings = RebotSettings() settings.start_timestamp = self.start_timestamp - not_copied = {'Include', 'Exclude', 'TestNames', 'SuiteNames', 'Name', 'Doc', - 'Metadata', 'SetTag', 'Output', 'LogLevel', 'TimestampOutputs'} + not_copied = {'Include', 'Exclude', 'TestNames', 'SuiteNames', 'FilePatterns', + 'Name', 'Doc', 'Metadata', 'SetTag', 'Output', 'LogLevel', + 'TimestampOutputs'} for opt in settings._opts: if opt in self and opt not in not_copied: settings._opts[opt] = self[opt] diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index 9e78b7cd9ba..fc6a2713b91 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -34,7 +34,7 @@ from .message import Message, Messages from .modelobject import DataDict, ModelObject from .modifier import ModelModifier -from .namepatterns import SuiteNamePatterns, TestNamePatterns +from .namepatterns import SuiteNamePatterns, TestNamePatterns, FileNamePatterns from .statistics import Statistics from .tags import Tags, TagPattern, TagPatterns from .testcase import TestCase, TestCases diff --git a/src/robot/model/namepatterns.py b/src/robot/model/namepatterns.py index a9a99019c9a..b003e9d0089 100644 --- a/src/robot/model/namepatterns.py +++ b/src/robot/model/namepatterns.py @@ -54,3 +54,12 @@ class TestNamePatterns(NamePatterns): def _match_longname(self, name): return self._match(name) + + +class FileNamePatterns(NamePatterns): + + def __init__(self, patterns=None): + self.matcher = MultiMatcher(patterns) + + def _match_longname(self, name): + return self._match(name) diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 46990d5fcf5..6ae98fc3f70 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -18,7 +18,7 @@ from typing import Iterable, Iterator, Sequence from robot.errors import DataError -from robot.model import SuiteNamePatterns +from robot.model import SuiteNamePatterns, FileNamePatterns from robot.output import LOGGER from robot.utils import get_error_message @@ -132,11 +132,14 @@ class SuiteStructureBuilder: ignored_dirs = ('CVS',) def __init__(self, extensions: Iterable[str] = ('.robot', '.rbt'), - included_suites: Iterable[str] = ()): + included_suites: Iterable[str] = (), + included_files: Iterable[str] = ()): self.extensions = ValidExtensions(extensions) self.included_suites = SuiteNamePatterns( self._create_included_suites(included_suites) ) + self.included_files = None if not included_files else \ + FileNamePatterns(included_files) def _create_included_suites(self, included_suites): for suite in included_suites: @@ -180,6 +183,11 @@ def _is_suite_included(self, name: str, included_suites: SuiteNamePatterns) -> b name = name.split('__', 1)[1] or name return included_suites.match(name) + def _is_file_included(self, name, included_files): + if not included_files: + return True + return included_files.match(name) + def _list_dir(self, path: Path) -> 'list[Path]': try: return sorted(path.iterdir(), key=lambda p: p.name.lower()) @@ -200,6 +208,8 @@ def _is_included(self, path: Path, included_suites: SuiteNamePatterns) -> bool: return False if not self.extensions.match(path): return False + if not self._is_file_included(path.name, self.included_files): + return False return self._is_suite_included(path.stem, included_suites) def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: diff --git a/src/robot/run.py b/src/robot/run.py index 8e2d58c2270..13403711b62 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -96,6 +96,10 @@ extension is needed, separate them with a colon. Examples: `--extension txt`, `--extension robot:txt` Only `*.robot` files are parsed by default. + -f --files pattern * Parse only files with a name that matches one of the + specified patterns when executing a directory. + Has no effect when running individual files or when + using resource files. -N --name name Set the name of the top level suite. By default the name is created based on the executed file or directory. @@ -431,8 +435,9 @@ def main(self, datasources, **options): LOGGER.info(f'Settings:\n{settings}') if settings.pythonpath: sys.path = settings.pythonpath + sys.path - builder = TestSuiteBuilder(settings.suite_names, + builder = TestSuiteBuilder(included_suites=settings.suite_names, included_extensions=settings.extension, + included_files=settings.file_patterns, custom_parsers=settings.parsers, rpa=settings.rpa, lang=settings.languages, diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 5b972d2242c..151d79c9971 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -57,6 +57,7 @@ class TestSuiteBuilder: def __init__(self, included_suites: Sequence[str] = (), included_extensions: Sequence[str] = ('.robot', '.rbt'), + included_files: Sequence[str] = (), custom_parsers: Sequence[str] = (), defaults: 'TestDefaults|None' = None, rpa: 'bool|None' = None, lang: LanguagesLike = None, @@ -67,6 +68,9 @@ def __init__(self, included_suites: Sequence[str] = (), Same as using ``--suite`` on the command line. :param included_extensions: List of extensions of files to parse. Same as ``--extension``. + :param included_files: + List of filename patterns to include. If no files are specified, all + files are parsed. Same as `--files`. New in RF 6.1. :param custom_parsers: Custom parsers as names or paths (same as ``--parser``) or as parser objects. New in RF 6.1. @@ -96,6 +100,7 @@ def __init__(self, included_suites: Sequence[str] = (), self.defaults = defaults self.included_suites = tuple(included_suites or ()) self.included_extensions = tuple(included_extensions or ()) + self.included_files = tuple(included_files or ()) self.rpa = rpa self.allow_empty_suite = allow_empty_suite @@ -137,7 +142,8 @@ def build(self, *paths: 'Path|str'): paths = self._normalize_paths(paths) extensions = chain(self.included_extensions, self.custom_parsers) structure = SuiteStructureBuilder(extensions, - self.included_suites).build(*paths) + self.included_suites, + self.included_files).build(*paths) suite = SuiteStructureParser(self._get_parsers(paths), self.defaults, self.rpa).parse(structure) if not self.included_suites and not self.allow_empty_suite: From 957b7f389d82023f1548214c46a15315d4e1520e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 17 May 2023 19:37:30 +0300 Subject: [PATCH 0566/1592] f-strings --- src/robot/result/xmlelementhandlers.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index aeb4b9c2c03..8f86c8d2aa0 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -48,9 +48,8 @@ def register(cls, handler): def get_child_handler(self, tag): if tag not in self.children: if not self.tag: - raise DataError("Incompatible root element '%s'." % tag) - raise DataError("Incompatible child element '%s' for '%s'." - % (tag, self.tag)) + raise DataError(f"Incompatible root element '{tag}'.") + raise DataError(f"Incompatible child element '{tag}' for '{self.tag}'.") return self.element_handlers[tag] def start(self, elem, result): @@ -129,7 +128,7 @@ def start(self, elem, result): if not elem_type: creator = self._create_keyword else: - creator = getattr(self, '_create_%s' % elem_type.lower().replace(' ', '_')) + creator = getattr(self, '_create_' + elem_type.lower()) return creator(elem, result) def _create_keyword(self, elem, result): @@ -150,7 +149,7 @@ def _get_body_for_suite_level_keyword(self, result): kw_type = 'teardown' if result.tests or result.suites else 'setup' keyword = getattr(result, kw_type) if not keyword: - keyword.config(kwname='Implicit %s' % kw_type, status=keyword.PASS) + keyword.config(kwname=f'Implicit {kw_type}', status=keyword.PASS) return keyword.body def _create_setup(self, elem, result): @@ -380,7 +379,7 @@ def end(self, elem, result): elif result.type == result.ITERATION: result.variables[elem.get('name')] = value else: - raise DataError("Invalid element '%s' for result '%r'." % (elem, result)) + raise DataError(f"Invalid element '{elem}' for result '{result!r}'.") @ElementHandler.register From 9f56877a1d00b66f7309800ce258a88a926e364f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 17 May 2023 19:42:18 +0300 Subject: [PATCH 0567/1592] Fix witespace issues caused by PR #4760. - Tabs to spaces - Make sure files end with a newline The PR was otherwise great! --- .../resource.resource | 8 ++++---- .../resource2.resource | 2 +- atest/testdata/keywords/private.resource | 2 +- .../setting_library_order.robot | 8 ++++---- .../set_resource_search_order/embedded.resource | 6 +++--- .../set_resource_search_order/embedded2.resource | 15 +++++++-------- .../set_resource_search_order/resource1.robot | 6 +++--- .../setting_resource_order.robot | 6 +++--- 8 files changed, 26 insertions(+), 27 deletions(-) diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource index 9fd7d391061..446aa8bb908 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource.resource @@ -13,7 +13,7 @@ ${match} in ${both} resources Fail Should not be run due to search order Follow search ${disorder} in resources - Fail Should not be run due to search order + Fail Should not be run due to search order ${public} match Should be equal ${public} Better public @@ -34,10 +34,10 @@ Match with and without embedded arguments in different files No operation Cause unresolvable conflict in resource due to search order - Unresolvable conflict in resource + Unresolvable conflict in resource Cause unresolvable conflict in library due to search order - Unresolvable conflict in library + Unresolvable conflict in library Unresolvable conflict in resource - Fail Should not be run due to search order + Fail Should not be run due to search order diff --git a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource index 0613370357a..872d48cb382 100644 --- a/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource +++ b/atest/testdata/keywords/embedded_arguments_conflicts/resource2.resource @@ -7,7 +7,7 @@ ${match} resources Fail Should not be run due to being worse match than above Follow search ${order} in resources - Should be equal ${order} order + Should be equal ${order} order Unresolvable ${conflict} in resource Fail Should not be run due to conflict diff --git a/atest/testdata/keywords/private.resource b/atest/testdata/keywords/private.resource index 5433905a8be..b82d7618ffb 100644 --- a/atest/testdata/keywords/private.resource +++ b/atest/testdata/keywords/private.resource @@ -15,7 +15,7 @@ Use Local Private Keyword Instead Of Keywords From Other Resources Private In Two Resources And Public In One Use Search Order Instead Of Private Keyword When Prioritized Resource Keyword Is Public - Private In Resource 1 And 3 And Public In Resource 2 + Private In Resource 1 And 3 And Public In Resource 2 Private Keyword In All Resources [Tags] ROBOT: private diff --git a/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot b/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot index bab6c971759..53dc63734fd 100644 --- a/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot +++ b/atest/testdata/standard_libraries/builtin/set_library_search_order/setting_library_order.robot @@ -67,10 +67,10 @@ Library Search Order Is Case Insensitive Search Order Controlled Match Containing Embedded Arguments Wins Over Exact Match Set Library Search Order embedded Library1 Active Library With Search Order Should Be embedded - + Best Search Order Controlled Match Wins In Library - Set Library Search Order embedded2 embedded Library1 - With Search Order The Best Matching Keyword Should Be Run In embedded2 + Set Library Search Order embedded2 embedded Library1 + With Search Order The Best Matching Keyword Should Be Run In embedded2 *** Keywords *** Active Library Should Be @@ -91,4 +91,4 @@ Active Library With Search Order Should Be With Search Order The Best Matching Keyword Should Be Run In [Arguments] ${expected} ${name} = Get Best Match Ever With Search Order - Should Be Equal ${name} ${expected} \ No newline at end of file + Should Be Equal ${name} ${expected} diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource index cd8a9b7cd5e..44981bee98e 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded.resource @@ -1,9 +1,9 @@ *** Keywords *** Get ${Name:\w+} Fail Should not be run due to keywords with normal arguments having higher precedence - + ${Get Name} With Search Order - Fail Should not be run due to better match in same resource + Fail Should not be run due to better match in same resource Get ${Name:\w+} With Search Order - RETURN embedded + RETURN embedded diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded2.resource b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded2.resource index 96ea7a690c2..2402c7789e7 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded2.resource +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/embedded2.resource @@ -1,13 +1,12 @@ *** Keywords *** Get ${Match} With Search Order - Fail Should not be run due to a better match in same resource - RETURN fail + Fail Should not be run due to a better match in same resource + RETURN fail Get Best ${Match:\w+} With Search Order - Fail Should not be run due to a better match in same resource - RETURN fail - + Fail Should not be run due to a better match in same resource + RETURN fail + Get Best ${Match} With Search Order - Should Be Equal ${Match} Match Ever - RETURN embedded2 - \ No newline at end of file + Should Be Equal ${Match} Match Ever + RETURN embedded2 diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot b/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot index c78119ef932..4a583995f73 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource1.robot @@ -3,8 +3,8 @@ Get Name RETURN resource1 Get Name With Search Order - Fail Should not be run due to search order having higher precedence + Fail Should not be run due to search order having higher precedence Get Best Match Ever With Search Order - Fail Should not be run due to search order - RETURN fail + Fail Should not be run due to search order + RETURN fail diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot b/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot index 483a4971d64..eee23397da0 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/setting_resource_order.robot @@ -64,8 +64,8 @@ Search Order Controlled Match Containing Embedded Arguments Wins Over Exact Matc With Search Order Active Resource Should Be embedded Best Search Order Controlled Match Wins In Resource - Set Library Search Order embedded2 embedded resource1 - With Search Order The Best Matching Keyword Should Be Run In embedded2 + Set Library Search Order embedded2 embedded resource1 + With Search Order The Best Matching Keyword Should Be Run In embedded2 *** Keywords *** Active Resource Should Be @@ -86,4 +86,4 @@ With Search Order Active Resource Should Be With Search Order The Best Matching Keyword Should Be Run In [Arguments] ${expected} ${name} = Get Best Match Ever With Search Order - Should Be Equal ${name} ${expected} \ No newline at end of file + Should Be Equal ${name} ${expected} From 34257fcee84604ecf7655edc0a11e415c23dab00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 23 May 2023 15:45:04 +0300 Subject: [PATCH 0568/1592] Don't exclude files in parsing based on --suite. Fixes #4688. Some type hint tuning in related code as well. --- atest/robot/core/filter_by_names.robot | 37 ---------------------- src/robot/model/namepatterns.py | 17 +++++----- src/robot/parsing/suitestructure.py | 43 ++++++++------------------ src/robot/run.py | 3 +- src/robot/running/builder/builders.py | 28 +++++++++++------ src/robot/utils/match.py | 2 +- 6 files changed, 42 insertions(+), 88 deletions(-) diff --git a/atest/robot/core/filter_by_names.robot b/atest/robot/core/filter_by_names.robot index 5335cbef80b..563e1798ae0 100644 --- a/atest/robot/core/filter_by_names.robot +++ b/atest/robot/core/filter_by_names.robot @@ -56,48 +56,11 @@ Parent suite init files are processed Should Be True ${SUITE.teardown} Check log message ${SUITE.teardown.msgs[0]} Default suite teardown -Unnecessary files are not parsed when --suite matches files - [Documentation] Test that only files matching --suite are processed. - ... Additionally __init__ files should never be ignored. - Previous Test Should Have Passed Parent suite init files are processed - ${root} = Normalize Path ${DATA DIR}/${SUITE DIR} - Syslog Should Contain Parsing directory '${root}'. - Syslog Should Contain Parsing file '${root}${/}tsuite1.robot'. - Syslog Should Contain Ignoring file or directory '${root}${/}tsuite2.robot'. - Syslog Should Contain Parsing file '${root}${/}tsuite3.robot'. - Syslog Should Contain Parsing file '${root}${/}fourth.robot'. - Syslog Should Contain Parsing directory '${root}${/}subsuites'. - Syslog Should Contain Ignoring file or directory '${root}${/}subsuites${/}sub1.robot'. - Syslog Should Contain Ignoring file or directory '${root}${/}subsuites${/}sub2.robot'. - Syslog Should Contain Parsing directory '${root}${/}subsuites2'. - Syslog Should Contain Ignoring file or directory '${root}${/}subsuites2${/}subsuite3.robot'. - Syslog Should Contain Ignoring file or directory '${root}${/}subsuites2${/}sub.suite.4.robot'. - Syslog Should Not Contain Regexp Ignoring file or directory '.*__init__.robot'. - --suite matching directory Run Suites --suite sub?uit[efg]s Should Contain Suites ${SUITE.suites[0]} Sub1 Sub2 Should Contain Tests ${SUITE} SubSuite1 First SubSuite2 First -Unnecessary files are not parsed when --suite matches directory - [Documentation] Testing that only files matching to --suite are processed. - ... This time --suite matches directory so all suites under it - ... should be parsed regardless their names. - Previous Test Should Have Passed --suite matching directory - ${root} = Normalize Path ${DATA DIR}/${SUITE DIR} - Syslog Should Contain Parsing directory '${root}'. - Syslog Should Contain Ignoring file or directory '${root}${/}tsuite1.robot'. - Syslog Should Contain Ignoring file or directory '${root}${/}tsuite2.robot'. - Syslog Should Contain Ignoring file or directory '${root}${/}tsuite3.robot'. - Syslog Should Contain Ignoring file or directory '${root}${/}fourth.robot'. - Syslog Should Contain Parsing directory '${root}${/}subsuites'. - Syslog Should Contain Parsing file '${root}${/}subsuites${/}sub1.robot'. - Syslog Should Contain Parsing file '${root}${/}subsuites${/}sub2.robot'. - Syslog Should Contain Parsing directory '${root}${/}subsuites2'. - Syslog Should Contain Ignoring file or directory '${root}${/}subsuites2${/}subsuite3.robot'. - Syslog Should Contain Ignoring file or directory '${root}${/}subsuites2${/}sub.suite.4.robot'. - Syslog Should Not Contain Regexp Ignoring file or directory '.*__init__.robot'. - --suite with long name matching file Run Suites --suite suites.fourth --suite suites.*.SUB? Should Contain Suites ${SUITE} Fourth Subsuites diff --git a/src/robot/model/namepatterns.py b/src/robot/model/namepatterns.py index b003e9d0089..42c19aa91a3 100644 --- a/src/robot/model/namepatterns.py +++ b/src/robot/model/namepatterns.py @@ -13,23 +13,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Iterable, Iterator +from typing import Iterable, Iterator, Sequence from robot.utils import MultiMatcher class NamePatterns(Iterable[str]): - def __init__(self, patterns: Iterator[str] = ()): - self.matcher = MultiMatcher(patterns, ignore='_') + def __init__(self, patterns: Sequence[str] = (), ignore: Sequence[str] = '_'): + self.matcher = MultiMatcher(patterns, ignore) def match(self, name: str, longname: 'str|None' = None) -> bool: - return self._match(name) or longname and self._match_longname(longname) + return bool(self._match(name) or + longname and self._match_longname(longname)) - def _match(self, name): + def _match(self, name: str) -> bool: return self.matcher.match(name) - def _match_longname(self, name): + def _match_longname(self, name: str) -> bool: raise NotImplementedError def __bool__(self) -> bool: @@ -58,8 +59,8 @@ def _match_longname(self, name): class FileNamePatterns(NamePatterns): - def __init__(self, patterns=None): - self.matcher = MultiMatcher(patterns) + def __init__(self, patterns: Sequence[str] = ()): + super().__init__(patterns, ignore='') def _match_longname(self, name): return self._match(name) diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 6ae98fc3f70..99037f2e294 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -18,7 +18,7 @@ from typing import Iterable, Iterator, Sequence from robot.errors import DataError -from robot.model import SuiteNamePatterns, FileNamePatterns +from robot.model import FileNamePatterns from robot.output import LOGGER from robot.utils import get_error_message @@ -131,15 +131,11 @@ class SuiteStructureBuilder: ignored_prefixes = ('_', '.') ignored_dirs = ('CVS',) - def __init__(self, extensions: Iterable[str] = ('.robot', '.rbt'), - included_suites: Iterable[str] = (), - included_files: Iterable[str] = ()): + def __init__(self, extensions: Sequence[str] = ('.robot', '.rbt'), + included_files: Sequence[str] = ()): self.extensions = ValidExtensions(extensions) - self.included_suites = SuiteNamePatterns( - self._create_included_suites(included_suites) - ) self.included_files = None if not included_files else \ - FileNamePatterns(included_files) + FileNamePatterns(included_files) def _create_included_suites(self, included_suites): for suite in included_suites: @@ -150,39 +146,28 @@ def _create_included_suites(self, included_suites): def build(self, *paths: Path) -> SuiteStructure: if len(paths) == 1: - return self._build(paths[0], self.included_suites) + return self._build(paths[0]) return self._build_multi_source(paths) - def _build(self, path: Path, included_suites: SuiteNamePatterns) -> SuiteStructure: + def _build(self, path: Path) -> SuiteStructure: if path.is_file(): return SuiteFile(self.extensions, path) - return self._build_directory(path, included_suites) + return self._build_directory(path) - def _build_directory(self, path: Path, - included_suites: SuiteNamePatterns) -> SuiteStructure: + def _build_directory(self, path: Path) -> SuiteStructure: structure = SuiteDirectory(self.extensions, path) - # If a directory is included, also its children are included. - if self._is_suite_included(path.name, included_suites): - included_suites = SuiteNamePatterns() for item in self._list_dir(path): if self._is_init_file(item): if structure.init_file: LOGGER.error(f"Ignoring second test suite init file '{item}'.") else: structure.init_file = item - elif self._is_included(item, included_suites): - structure.add(self._build(item, included_suites)) + elif self._is_included(item): + structure.add(self._build(item)) else: LOGGER.info(f"Ignoring file or directory '{item}'.") return structure - def _is_suite_included(self, name: str, included_suites: SuiteNamePatterns) -> bool: - if not included_suites: - return True - if '__' in name: - name = name.split('__', 1)[1] or name - return included_suites.match(name) - def _is_file_included(self, name, included_files): if not included_files: return True @@ -199,7 +184,7 @@ def _is_init_file(self, path: Path) -> bool: and self.extensions.match(path) and path.is_file()) - def _is_included(self, path: Path, included_suites: SuiteNamePatterns) -> bool: + def _is_included(self, path: Path) -> bool: if path.name.startswith(self.ignored_prefixes): return False if path.is_dir(): @@ -208,9 +193,7 @@ def _is_included(self, path: Path, included_suites: SuiteNamePatterns) -> bool: return False if not self.extensions.match(path): return False - if not self._is_file_included(path.name, self.included_files): - return False - return self._is_suite_included(path.stem, included_suites) + return self._is_file_included(path.name, self.included_files) def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: structure = SuiteDirectory(self.extensions) @@ -220,5 +203,5 @@ def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: raise DataError("Multiple init files not allowed.") structure.init_file = path else: - structure.add(self._build(path, self.included_suites)) + structure.add(self._build(path)) return structure diff --git a/src/robot/run.py b/src/robot/run.py index 13403711b62..22c4499c310 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -435,8 +435,7 @@ def main(self, datasources, **options): LOGGER.info(f'Settings:\n{settings}') if settings.pythonpath: sys.path = settings.pythonpath + sys.path - builder = TestSuiteBuilder(included_suites=settings.suite_names, - included_extensions=settings.extension, + builder = TestSuiteBuilder(included_extensions=settings.extension, included_files=settings.file_patterns, custom_parsers=settings.parsers, rpa=settings.rpa, diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 151d79c9971..41ab5d2642d 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import warnings from itertools import chain from os.path import normpath from pathlib import Path @@ -55,17 +56,21 @@ class TestSuiteBuilder: classmethod that uses this class internally. """ - def __init__(self, included_suites: Sequence[str] = (), + def __init__(self, included_suites: str = 'DEPRECATED', included_extensions: Sequence[str] = ('.robot', '.rbt'), included_files: Sequence[str] = (), custom_parsers: Sequence[str] = (), defaults: 'TestDefaults|None' = None, - rpa: 'bool|None' = None, lang: LanguagesLike = None, - allow_empty_suite: bool = False, process_curdir: bool = True): + rpa: 'bool|None' = None, + lang: LanguagesLike = None, + allow_empty_suite: bool = False, + process_curdir: bool = True): """ :param included_suites: - List of suite names to include. If not given, all suites are included. - Same as using ``--suite`` on the command line. + This argument used to be used for limiting what suite file to parse. + It is deprecated and has no effect starting from RF 6.1. Use the + new ``included_files`` argument or filter the created suite after + parsing instead. :param included_extensions: List of extensions of files to parse. Same as ``--extension``. :param included_files: @@ -98,11 +103,15 @@ def __init__(self, included_suites: Sequence[str] = (), self.standard_parsers = self._get_standard_parsers(lang, process_curdir) self.custom_parsers = self._get_custom_parsers(custom_parsers) self.defaults = defaults - self.included_suites = tuple(included_suites or ()) self.included_extensions = tuple(included_extensions or ()) self.included_files = tuple(included_files or ()) self.rpa = rpa self.allow_empty_suite = allow_empty_suite + # TODO: Remove in RF 7. + if included_suites != 'DEPRECATED': + warnings.warn("'TestSuiteBuilder' argument 'included_suites' is deprecated " + "and has no effect. Use the new 'included_files' argument " + "or filter the parsed suite instead.") def _get_standard_parsers(self, lang: LanguagesLike, process_curdir: bool) -> 'dict[str, Parser]': @@ -134,19 +143,18 @@ def _get_custom_parsers(self, parsers: Sequence[str]) -> 'dict[str, CustomParser custom_parsers[ext] = custom_parser return custom_parsers - def build(self, *paths: 'Path|str'): + def build(self, *paths: 'Path|str') -> TestSuite: """ :param paths: Paths to test data files or directories. :return: :class:`~robot.running.model.TestSuite` instance. """ paths = self._normalize_paths(paths) - extensions = chain(self.included_extensions, self.custom_parsers) + extensions = self.included_extensions + tuple(self.custom_parsers) structure = SuiteStructureBuilder(extensions, - self.included_suites, self.included_files).build(*paths) suite = SuiteStructureParser(self._get_parsers(paths), self.defaults, self.rpa).parse(structure) - if not self.included_suites and not self.allow_empty_suite: + if not self.allow_empty_suite: self._validate_not_empty(suite, multi_source=len(paths) > 1) suite.remove_empty_suites(preserve_direct_children=len(paths) > 1) return suite diff --git a/src/robot/utils/match.py b/src/robot/utils/match.py index 0a42f35f9c0..d79b7d869eb 100644 --- a/src/robot/utils/match.py +++ b/src/robot/utils/match.py @@ -55,7 +55,7 @@ def __bool__(self) -> bool: class MultiMatcher(Iterable[Matcher]): - def __init__(self, patterns: Iterable[str] = (), ignore: Sequence[str] = (), + def __init__(self, patterns: Sequence[str] = (), ignore: Sequence[str] = (), caseless: bool = True, spaceless: bool = True, match_if_no_patterns: bool = False, regexp: bool = False): self.matchers = [Matcher(pattern, ignore, caseless, spaceless, regexp) From 7b30d3292069c28a0c09c2b9f140aa1be7ef1d1d Mon Sep 17 00:00:00 2001 From: Vincema <maire.vincent31@gmail.com> Date: Wed, 24 May 2023 04:38:07 +0700 Subject: [PATCH 0569/1592] Item assignment support: `${x}[key] = Keyword` (#4727) Fixes #4546. --- atest/robot/running/if/inline_if_else.robot | 5 + atest/robot/variables/extended_assign.robot | 6 + atest/robot/variables/return_values.robot | 86 +++++++ .../testdata/running/if/inline_if_else.robot | 11 + .../testdata/variables/extended_assign.robot | 17 ++ atest/testdata/variables/return_values.py | 23 ++ atest/testdata/variables/return_values.robot | 213 ++++++++++++++++++ .../src/CreatingTestData/Variables.rst | 25 ++ src/robot/libraries/Collections.py | 8 + src/robot/parsing/lexer/statementlexers.py | 4 +- src/robot/utils/text.py | 5 +- src/robot/variables/assigner.py | 139 +++++++++--- src/robot/variables/search.py | 23 +- utest/variables/test_isvar.py | 21 +- 14 files changed, 533 insertions(+), 53 deletions(-) create mode 100644 atest/testdata/variables/return_values.py diff --git a/atest/robot/running/if/inline_if_else.robot b/atest/robot/running/if/inline_if_else.robot index 216fb0124a6..f3af1456008 100644 --- a/atest/robot/running/if/inline_if_else.robot +++ b/atest/robot/running/if/inline_if_else.robot @@ -48,6 +48,11 @@ Assign NOT RUN PASS NOT RUN index=1 NOT RUN NOT RUN PASS index=2 +Assign with item + PASS NOT RUN NOT RUN index=0 + NOT RUN PASS NOT RUN index=1 + NOT RUN NOT RUN PASS index=2 + Multi assign PASS NOT RUN index=0 FAIL NOT RUN index=4 diff --git a/atest/robot/variables/extended_assign.robot b/atest/robot/variables/extended_assign.robot index bdbceff4167..c2fce168353 100644 --- a/atest/robot/variables/extended_assign.robot +++ b/atest/robot/variables/extended_assign.robot @@ -14,6 +14,12 @@ Set nested attribute Set nested attribute when parent uses item access Check Test Case ${TESTNAME} +Set item to list attribute + Check Test Case ${TESTNAME} + +Set item to dict attribute + Check Test Case ${TESTNAME} + Trying to set un-settable attribute Check Test Case ${TESTNAME} diff --git a/atest/robot/variables/return_values.robot b/atest/robot/variables/return_values.robot index 84316c4b94e..b18c774ac61 100644 --- a/atest/robot/variables/return_values.robot +++ b/atest/robot/variables/return_values.robot @@ -225,3 +225,89 @@ Invalid assign with assign mark Too many assign marks Check Test Case ${TESTNAME} + +Item assign to scalar dictionary + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.kws[1].msgs[0]} \${dict_variable}[key_str1] = replaced_value + Check Log Message ${tc.kws[2].msgs[0]} \${dict_variable}[\${0}] = 100 + Check Log Message ${tc.kws[3].msgs[0]} \${dict_variable}[0] = new_value + +Nested item assign + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.kws[2].msgs[0]} \${dict_variable}[list][0] = 101 + Check Log Message ${tc.kws[3].msgs[0]} \${dict_variable}[list][\${1}] = 102 + Check Log Message ${tc.kws[4].msgs[0]} \${dict_variable}[list][-1] = 103 + +Item assign to scalar list + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.kws[1].msgs[0]} \${list_variable}[0] = 100 + Check Log Message ${tc.kws[2].msgs[0]} \${list_variable}[\${1}] = 101 + Check Log Message ${tc.kws[3].msgs[0]} \${list_variable}[-1] = 102 + +Slice assign to scalar list + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.kws[3].msgs[0]} \${list_variable}[:2] = ['101', '102', '103'] + Check Log Message ${tc.kws[4].msgs[0]} \${list_variable}[-2:] = ['104'] + +Item assign using variable as index + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.kws[5].msgs[0]} \${list_variable}[\${int_variable}] = 105 + Check Log Message ${tc.kws[5].msgs[1]} \${list_variable}[\${str_variable}] = 101 + Check Log Message ${tc.kws[5].msgs[2]} \${list_variable}[\${slice_variable}] = [102] + Check Log Message ${tc.kws[5].msgs[3]} \${list_variable}[\${strslice_variable}] = [103, 104] + +Item assign to object with setitem capability + Check Test Case ${TESTNAME} + +Item assign to object without setitem capability fails + Check Test Case ${TESTNAME} + +Item assign to immutable object fails + Check Test Case ${TESTNAME} + +Item assign expects iterable fails + Check Test Case ${TESTNAME} + +Index not found error when item assign to list + Check Test Case ${TESTNAME} + +Item assign to undeclared scalar fails + Check Test Case ${TESTNAME} + +Item assign to undeclared dict fails + Check Test Case ${TESTNAME} + +Item assign to undeclared list fails + Check Test Case ${TESTNAME} + +Empty item assign to list fails + Check Test Case ${TESTNAME} + +Empty item assign to dictionary + Check Test Case ${TESTNAME} + +Multiple item assigns to scalars only + Check Test Case ${TESTNAME} + +Multiple item assigns to scalars and list + Check Test Case ${TESTNAME} + +Multiple item assigns to scalars and list slice + Check Test Case ${TESTNAME} + +Item assign without assign mark + Check Test Case ${TESTNAME} + +Single item assign to list + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.kws[1].msgs[0]} \@{list_variable}[1] = [ a | b | c ] + +Single item assign to dict + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.kws[1].msgs[0]} \&{dict_variable}[a] = { 0=1 | 2=3 } + +Single item assign to list should fail if value is not list + Check Test Case ${TESTNAME} + +Single item assign to dict should fail if value is not dict + Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/if/inline_if_else.robot b/atest/testdata/running/if/inline_if_else.robot index df922c3a23f..5a3de9397af 100644 --- a/atest/testdata/running/if/inline_if_else.robot +++ b/atest/testdata/running/if/inline_if_else.robot @@ -1,3 +1,6 @@ +*** Variable *** +&{dict} + *** Test Cases *** IF passing IF True Log reached this @@ -58,6 +61,14 @@ Assign Should Be Equal ${y} ${2} Should Be Equal ${z} ${3} +Assign with item + ${dict}[x] = IF 1 Convert to integer 1 ELSE IF 2 Convert to integer 2 ELSE Convert to integer 3 + ${dict}[y] = IF 0 Convert to integer 1 ELSE IF 2 Convert to integer 2 ELSE Convert to integer 3 + ${dict}[z] = IF 0 Convert to integer 1 ELSE IF 0 Convert to integer 2 ELSE Convert to integer 3 + Should Be Equal ${dict}[x] ${1} + Should Be Equal ${dict}[y] ${2} + Should Be Equal ${dict}[z] ${3} + Multi assign [Documentation] FAIL Cannot set variables: Expected 3 return values, got 2. ${x} ${y} ${z} = IF True Create list a b c ELSE Not run diff --git a/atest/testdata/variables/extended_assign.robot b/atest/testdata/variables/extended_assign.robot index 217fd1a68d8..3e524711063 100644 --- a/atest/testdata/variables/extended_assign.robot +++ b/atest/testdata/variables/extended_assign.robot @@ -1,5 +1,6 @@ *** Settings *** Variables extended_assign_vars.py +Library Collections *** Test Cases *** Set attributes to Python object @@ -19,6 +20,22 @@ Set nested attribute when parent uses item access ${body.data[0].name} = Set Variable new value Should Be Equal ${body.data[0].name} new value +Set item to list attribute + &{body} = Evaluate {'data': [0, 1, 2, 3]} + ${body.data}[${0}] = Set Variable firstVal + ${body.data}[-1] = Set Variable lastVal + ${body.data}[1:3] = Create List ${98} middle ${99} + ${EXPECTED_LIST} = Create List firstVal ${98} middle ${99} lastVal + Lists Should Be Equal ${body.data} ${EXPECTED_LIST} + +Set item to dict attribute + &{body} = Evaluate {'data': {'key': 'val', 0: 1}} + ${body.data}[key] = Set Variable newVal + ${body.data}[${0}] = Set Variable ${2} + ${body.data}[newKey] = Set Variable newKeyVal + ${EXPECTED_DICT} = Create Dictionary key=newVal ${0}=${2} newKey=newKeyVal + Dictionaries Should Be Equal ${body.data} ${EXPECTED_DICT} + Trying to set un-settable attribute [Documentation] FAIL STARTS: Setting attribute 'not_settable' to variable '\${VAR}' failed: AttributeError: ${VAR.not_settable} = Set Variable whatever diff --git a/atest/testdata/variables/return_values.py b/atest/testdata/variables/return_values.py new file mode 100644 index 00000000000..46ee055eec5 --- /dev/null +++ b/atest/testdata/variables/return_values.py @@ -0,0 +1,23 @@ +class ObjectWithSetItemCap: + def __init__(self) -> None: + self._dict = {} + + def clear(self): + self._dict = {} + + def __setitem__(self, item, value): + self._dict[item] = value + + def __getitem__(self, item): + return self._dict[item] + + @property + def container(self): + return self._dict + +class ObjectWithoutSetItemCap: + def __init__(self) -> None: + pass + +OBJECT_WITH_SETITEM_CAP = ObjectWithSetItemCap() +OBJECT_WITHOUT_SETITEM_CAP = ObjectWithoutSetItemCap() diff --git a/atest/testdata/variables/return_values.robot b/atest/testdata/variables/return_values.robot index 5f40b3f0f5c..d5029a85799 100644 --- a/atest/testdata/variables/return_values.robot +++ b/atest/testdata/variables/return_values.robot @@ -2,6 +2,7 @@ Library ExampleLibrary Library Collections Library get_file_lib.py +Variables return_values.py *** Variables *** &{DICT} foo=bar muu=mi @@ -353,6 +354,218 @@ Too many assign marks [Documentation] FAIL No keyword with name '\${oops}==' found. ${oops}== Set Variable whatever +Item assign to scalar dictionary + ${dict_variable}= Create Dictionary key_str1=initial_value ${0}=${99} + + ${dict_variable}[key_str1]= Set Variable replaced_value + ${dict_variable}[${0}]= Set Variable ${100} + ${dict_variable}[0]= Set Variable new_value + + ${tuple_as_key}= Evaluate (1, 2, 3,) + ${dict_variable}[${tuple_as_key}]= Set Variable tuple_value + + Should Be Equal ${dict_variable}[key_str1] replaced_value + Should Be Equal ${dict_variable}[${0}] ${100} + Should Be Equal ${dict_variable}[0] new_value + + Should Be Equal ${dict_variable}[${tuple_as_key}] tuple_value + +Nested item assign + ${dict_variable}= Create Dictionary + + ${dict_variable}[list]= Evaluate [1, 2, 3] + ${dict_variable}[list][0]= Set Variable ${101} + ${dict_variable}[list][${1}]= Set Variable ${102} + ${dict_variable}[list][-1]= Set Variable ${103} + + ${expected_list}= Evaluate [101, 102, 103] + Should Be Equal ${dict_variable}[list] ${expected_list} + + ${dict_variable}[dict]= Evaluate {"a": "b"} + ${dict_variable}[dict][a]= Set Variable c + ${dict_variable}[dict][${0}]= Set Variable zero_int + ${dict_variable}[dict][0]= Set Variable zero_str + + ${expected_dict}= Evaluate {"a": "c", "0": "zero_str", 0: "zero_int"} + Should Be Equal ${dict_variable}[dict] ${expected_dict} + +Item assign to scalar list + ${list_variable}= Create List 1 2 3 + + ${list_variable}[0]= Set Variable 100 + ${list_variable}[${1}]= Set Variable 101 + ${list_variable}[-1]= Set Variable 102 + + Should Be Equal ${list_variable}[0] 100 + Should Be Equal ${list_variable}[1] 101 + Should Be Equal ${list_variable}[2] 102 + Length Should Be ${list_variable} 3 + +Slice assign to scalar list + ${list_variable}= Create List 1 2 3 4 5 + ${iterator1}= Create List 101 102 103 + ${iterator2}= Create List 104 + + ${list_variable}[:2]= Set Variable ${iterator1} + ${list_variable}[-2:]= Set Variable ${iterator2} + + Length Should Be ${list_variable} 5 + Should Be Equal ${list_variable}[0] 101 + Should Be Equal ${list_variable}[1] 102 + Should Be Equal ${list_variable}[2] 103 + Should Be Equal ${list_variable}[3] 3 + Should Be Equal ${list_variable}[4] 104 + +Item assign using variable as index + ${int_variable}= Set Variable ${-1} + ${str_variable}= Set Variable 0 + ${slice_variable}= Set Variable ${{ slice(1, 2) }} + ${strslice_variable}= Set Variable 2:4 + + ${list_variable}= Create List ${1} ${2} ${3} ${4} ${5} + ${list_variable}[${int_variable}] + ... ${list_variable}[${str_variable}] + ... ${list_variable}[${slice_variable}] + ... ${list_variable}[${strslice_variable}]= Evaluate (105, 101, [102], [103, 104]) + + ${expected_list}= Create List ${101} ${102} ${103} ${104} ${105} + Should Be Equal ${list_variable} ${expected_list} + +Item assign to object with setitem capability + # Reset the object if used in other test + Call Method ${OBJECT_WITH_SETITEM_CAP} clear + + ${OBJECT_WITH_SETITEM_CAP}[str_key]= Set Variable new_value + ${OBJECT_WITH_SETITEM_CAP}[0]= Set Variable value_str + ${OBJECT_WITH_SETITEM_CAP}[${0}]= Set Variable value_int + ${OBJECT_WITH_SETITEM_CAP}[1:2]= Set Variable value_slice_as_str + + Length Should Be ${OBJECT_WITH_SETITEM_CAP.container} 4 + Dictionary Should Contain Item ${OBJECT_WITH_SETITEM_CAP.container} str_key new_value + Dictionary Should Contain Item ${OBJECT_WITH_SETITEM_CAP.container} 0 value_str + Dictionary Should Contain Item ${OBJECT_WITH_SETITEM_CAP.container} ${0} value_int + Dictionary Should Contain Item ${OBJECT_WITH_SETITEM_CAP.container} 1:2 value_slice_as_str + +Item assign to object without setitem capability fails + [Documentation] FAIL + ... Variable '\${OBJECT_WITHOUT_SETITEM_CAP}' is ObjectWithoutSetItemCap and does not support item assignment. + ${OBJECT_WITHOUT_SETITEM_CAP}[newKey]= Set Variable newVal + +Item assign to immutable object fails + [Documentation] FAIL + ... Variable '${tuple_variable}' is tuple and does not support item assignment. + ${tuple_variable}= Evaluate (1,) + ${tuple_variable}[0]= Set Variable 0 + +Item assign expects iterable fails + [Documentation] FAIL + ... Setting value to list variable '${list_variable}' at index [:1] failed: \ + ... TypeError: can only assign an iterable + ${list_variable}= Create List 1 2 3 + ${list_variable}[:1]= Evaluate 0 + + Log To Console ${list_variable} + +Index not found error when item assign to list + [Documentation] FAIL + ... Setting value to list variable '${list_variable}[0]' at index [2] failed: \ + ... IndexError: list assignment index out of range + ${list_variable}= Create List ${{ [1, 2] }} + ${list_variable}[0][2]= Set Variable 3 + +Item assign to undeclared scalar fails + [Documentation] FAIL Variable '${undeclared_scalar}' not found. + ${undeclared_scalar}[0]= Set Variable 0 + +Item assign to undeclared dict fails + [Documentation] FAIL Variable '${undeclared_dict}' not found. + &{undeclared_dict}[0]= Set Variable 0 + +Item assign to undeclared list fails + [Documentation] FAIL Variable '${undeclared_list}' not found. + @{undeclared_list}[0]= Set Variable 0 + +Empty item assign to list fails + [Documentation] FAIL + ... Setting value to list variable '${list_variable}' at index [] failed: \ + ... TypeError: list indices must be integers or slices, not str + ${list_variable}= Create List ${{ [1, 2] }} + ${list_variable}[]= Set Variable 3 + +Empty item assign to dictionary + ${dict_variable}= Create Dictionary + ${dict_variable}[]= Set Variable empty + + Dictionary Should Contain Item ${dict_variable} ${{ '' }} empty + +Multiple item assigns to scalars only + ${list_variable}= Create List ${1} ${2} + ${list_variable}[1] ${list_variable}[${0}]= Set Variable @{list_variable} + + Should Be Equal ${list_variable} ${{ [2, 1] }} + +Multiple item assigns to scalars and list + ${list_variable}= Create List ${1} ${2} + ${dict_variable}= Create Dictionary + + ${dict_variable}[abc] ${dict_variable}[def] @{list_variable}[1]= Set Variable ${{ ("first", "second", "list_element") }} + + Should Be Equal ${list_variable} ${{ [1, ["list_element"]] }} + Should Be Equal ${dict_variable} ${{ {"abc": "first", "def": "second" } }} + +Multiple item assigns to scalars and list slice + ${list_variable}= Create List ${1} ${2} + ${dict_variable}= Create Dictionary + + ${dict_variable}[abc] ${dict_variable}[def] @{list_variable}[1:]= Set Variable ${{ ("first", "second", "list_element") }} + + Should Be Equal ${list_variable} ${{ [1, "list_element"] }} + Should Be Equal ${dict_variable} ${{ {"abc": "first", "def": "second" } }} + +Item assign without assign mark + ${dict_variable} Create Dictionary + ${dict_variable}[key] Set Variable val + Should Be Equal ${dict_variable}[key] val + +Single item assign to list + @{list_variable}= Create List x y z + @{list_variable}[1]= Create List a b c + @{temp_list}= Create List 0 1 2 + @{list_variable}[1][-1]= Set Variable ${temp_list} + + Should Be Equal ${list_variable} ${{ ['x', ['a', 'b', ['0', '1', '2']], 'z'] }} + + # Assert that the assigned list has been copied by changing the value of temp_list + ${temp_list}[0]= Set Variable -1 + @{expected_list}= Create List 0 1 2 + @{inner_list}= Set Variable @{list_variable}[1][-1] + Lists Should Be Equal ${inner_list} ${expected_list} + +Single item assign to dict + &{dict_variable}= Create Dictionary x=y a=b + &{dict_variable}[a]= Evaluate {0:1, 2:3} + &{dict_variable}[a][z]= Evaluate {'key': 'value'} + + Should Be Equal ${dict_variable} ${{ {'x': 'y', 'a': {0: 1, 2: 3, 'z': {'key': 'value'}}} }} + + # Assert that the dictionary is a DotDict (extended assign) + ${inner_dict}= Set Variable ${dict_variable.a.z} + Should Not Be Empty ${inner_dict} + +Single item assign to list should fail if value is not list + [Documentation] FAIL + ... Setting value to list variable '@{list_variable}' at index [1] failed: \ + ... Expected list-like value, got string. + @{list_variable}= Create List x y z + @{list_variable}[1]= Set Variable abc + +Single item assign to dict should fail if value is not dict + [Documentation] FAIL + ... Setting value to DotDict variable '&{dict_variable}' at index [1] failed: \ + ... Expected dictionary-like value, got string. + &{dict_variable}= Create Dictionary x=y a=b + &{dict_variable}[1]= Set Variable abc + *** Keywords *** Assign multiple variables [Arguments] @{args} diff --git a/doc/userguide/src/CreatingTestData/Variables.rst b/doc/userguide/src/CreatingTestData/Variables.rst index aa2ea1ce4bf..46db427cae6 100644 --- a/doc/userguide/src/CreatingTestData/Variables.rst +++ b/doc/userguide/src/CreatingTestData/Variables.rst @@ -663,6 +663,31 @@ variable`_ if it has a dictionary-like value. Length Should Be ${list} 3 Log Many @{list} +Assigning variables with item values +'''''''''''''''''''''''''''''''''''' + +Starting from Robot Framework 6.1, when working with variables that support +item assignment such as lists or dictionaries, it is possible to set their values +by specifying the index or key of the item using the syntax `${var}[index]=`: + +.. sourcecode:: robotframework + + *** Test Cases *** + Item assignment to list + ${list} = Create List one two three four + ${list}[0] = Set Variable first + ${list}[${1}] = Set Variable second + ${list}[2:3] = Evaluate ['third'] + ${list}[-1] = Set Variable last + Log Many @{list} # Logs 'first', 'second', 'third' and 'last' + + Item assignment to dictionary + ${dictionary} = Create Dictionary first_name=unknown + ${dictionary}[first_name] = Set Variable John + ${dictionary}[last_name] = Set Variable Doe + Log ${dictionary} # Logs {'first_name': 'John', 'last_name': 'Doe'} + + Assigning list variables '''''''''''''''''''''''' diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 5489b532ad6..18c2fbae8d0 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -111,6 +111,10 @@ def set_list_value(self, list_, index, value): | Set List Value | ${L3} | -1 | yyy | => | ${L3} = ['a', 'xxx', 'yyy'] + + This keyword is equivalent to using the following syntax: + | ${L3}[1] = | Set Variable | xxx | + | ${D1}[-1] = | Set Variable | yyy | """ self._validate_list(list_) try: @@ -510,6 +514,10 @@ def set_to_dictionary(self, dictionary, *key_value_pairs, **items): a limitation that keys must be strings. If given keys already exist in the dictionary, their values are updated. + + This keyword is equivalent to using the following syntax: + | ${D1}[key] = | Set Variable | value | + | ${D1}[second] = | Set Variable | ${2} | """ self._validate_dictionary(dictionary) if len(key_value_pairs) % 2 != 0: diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index dc231cd9625..12f4c8a8461 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -200,7 +200,7 @@ def _lex_as_keyword_call(self): for token in self.statement: if keyword_seen: token.type = Token.ARGUMENT - elif is_assign(token.value, allow_assign_mark=True): + elif is_assign(token.value, allow_assign_mark=True, allow_items=True): token.type = Token.ASSIGN else: token.type = Token.KEYWORD @@ -244,7 +244,7 @@ def handles(self, statement: StatementTokens) -> bool: for token in statement: if token.value == 'IF': return True - if not is_assign(token.value, allow_assign_mark=True): + if not is_assign(token.value, allow_assign_mark=True, allow_items=True): return False return False diff --git a/src/robot/utils/text.py b/src/robot/utils/text.py index aceae8062a0..ee90c9f8771 100644 --- a/src/robot/utils/text.py +++ b/src/robot/utils/text.py @@ -80,12 +80,13 @@ def _count_virtual_line_length(line): return lines if not remainder else lines + 1 -def format_assign_message(variable, value, cut_long=True): +def format_assign_message(variable, value, items=None, cut_long=True): formatter = {'$': safe_str, '@': seq2str2, '&': _dict_to_str}[variable[0]] value = formatter(value) if cut_long: value = cut_assign_value(value) - return '%s = %s' % (variable, value) + decorated_items = ''.join(f'[{item}]' for item in items) if items else '' + return f'{variable}{decorated_items} = {value}' def _dict_to_str(d): if not d: diff --git a/src/robot/variables/assigner.py b/src/robot/variables/assigner.py index 26e68b21cae..395f9a1edcc 100644 --- a/src/robot/variables/assigner.py +++ b/src/robot/variables/assigner.py @@ -14,11 +14,14 @@ # limitations under the License. import re +from collections.abc import MutableSequence from robot.errors import (DataError, ExecutionStatus, HandlerExecutionFailed, VariableError) -from robot.utils import (ErrorDetails, format_assign_message, get_error_message, +from robot.utils import (DotDict, ErrorDetails, format_assign_message, + get_error_message, is_dict_like, is_list_like, is_number, is_string, prepr, type_name) +from .search import search_variable, VariableMatch class VariableAssignment: @@ -105,10 +108,12 @@ def assign(self, return_value): context.output.trace(lambda: 'Return: %s' % prepr(return_value), write_if_flat=False) resolver = ReturnValueResolver(self._assignment) - for name, value in resolver.resolve(return_value): - if not self._extended_assign(name, value, context.variables): + for name, items, value in resolver.resolve(return_value): + if items: + value = self._item_assign(name, items, value, context.variables) + elif not self._extended_assign(name, value, context.variables): value = self._normal_assign(name, value, context.variables) - context.info(format_assign_message(name, value)) + context.info(format_assign_message(name, value, items)) def _extended_assign(self, name, value, variables): if name[0] != '$' or '.' not in name or name in variables: @@ -134,6 +139,65 @@ def _variable_supports_extended_assign(self, var): def _is_valid_extended_attribute(self, attr): return self._valid_extended_attr.match(attr) is not None + def _parse_sequence_index(self, index): + if isinstance(index, (int, slice)): + return index + if not is_string(index): + raise ValueError + if ':' not in index: + return int(index) + if index.count(':') > 2: + raise ValueError + return slice(*[int(i) if i else None for i in index.split(':')]) + + def _variable_type_supports_item_assign(self, var): + return (hasattr(var, '__setitem__') and callable(var.__setitem__)) + + def _raise_cannot_set_type(self, value, expected): + value_type = type_name(value) + raise VariableError(f"Expected {expected}-like value, got {value_type}.") + + def _validate_item_assign(self, name, value): + if name[0] == '@': + if not is_list_like(value): + self._raise_cannot_set_type(value, 'list') + value = list(value) + if name[0] == '&': + if not is_dict_like(value): + self._raise_cannot_set_type(value, 'dictionary') + value = DotDict(value) + return value + + def _item_assign(self, name, items, value, variables): + *nested, item = items + decorated_nested_items = ''.join(f'[{item}]' for item in nested) + var = variables.replace_scalar(f'${name[1:]}{decorated_nested_items}') + + if not self._variable_type_supports_item_assign(var): + var_type = type_name(var) + raise VariableError( + f"Variable '{name}{decorated_nested_items}' is {var_type} " + f"and does not support item assignment." + ) + + selector = variables.replace_scalar(item) + if isinstance(var, MutableSequence): + try: + selector = self._parse_sequence_index(selector) + except ValueError: + pass + try: + value = self._validate_item_assign(name, value) + var[selector] = value + except (IndexError, TypeError, Exception): + var_type = type_name(var) + raise VariableError( + f"Setting value to {var_type} variable " + f"'{name}{decorated_nested_items}' " + f"at index [{item}] failed: {get_error_message()}" + ) + return value + def _normal_assign(self, name, value, variables): variables[name] = value # Always return the actually assigned value. @@ -158,21 +222,28 @@ def resolve(self, return_value): class OneReturnValueResolver: - def __init__(self, variable): - self._variable = variable + def __init__(self, assignment): + match: VariableMatch = search_variable(assignment) + self._name = match.name + self._items = match.items def resolve(self, return_value): if return_value is None: - identifier = self._variable[0] + identifier = self._name[0] return_value = {'$': None, '@': [], '&': {}}[identifier] - return [(self._variable, return_value)] + return [(self._name, self._items, return_value)] class _MultiReturnValueResolver: - def __init__(self, variables): - self._variables = variables - self._min_count = len(variables) + def __init__(self, assignments): + self._names = [] + self._items = [] + for assigment in assignments: + match: VariableMatch = search_variable(assigment) + self._names.append(match.name) + self._items.append(match.items) + self._min_count = len(assignments) def resolve(self, return_value): return_value = self._convert_to_list(return_value) @@ -210,13 +281,13 @@ def _validate(self, return_count): % (self._min_count, return_count)) def _resolve(self, return_value): - return list(zip(self._variables, return_value)) + return list(zip(self._names, self._items, return_value)) class ScalarsAndListReturnValueResolver(_MultiReturnValueResolver): - def __init__(self, variables): - _MultiReturnValueResolver.__init__(self, variables) + def __init__(self, assignments): + _MultiReturnValueResolver.__init__(self, assignments) self._min_count -= 1 def _validate(self, return_count): @@ -225,23 +296,23 @@ def _validate(self, return_count): % (self._min_count, return_count)) def _resolve(self, return_value): - before_vars, list_var, after_vars \ - = self._split_variables(self._variables) - before_items, list_items, after_items \ - = self._split_return(return_value, before_vars, after_vars) - before = list(zip(before_vars, before_items)) - after = list(zip(after_vars, after_items)) - return before + [(list_var, list_items)] + after - - def _split_variables(self, variables): - list_index = [v[0] for v in variables].index('@') - return (variables[:list_index], - variables[list_index], - variables[list_index+1:]) - - def _split_return(self, return_value, before_vars, after_vars): - list_start = len(before_vars) - list_end = len(return_value) - len(after_vars) - return (return_value[:list_start], - return_value[list_start:list_end], - return_value[list_end:]) + list_index = [a[0][0] for a in self._names].index('@') + list_len = len(return_value) - len(self._names) + 1 + + elements_before_list = list(zip( + self._names[:list_index], + self._items[:list_index], + return_value[:list_index], + )) + elements_after_list = list(zip( + self._names[list_index+1:], + self._items[list_index+1:], + return_value[list_index+list_len:], + )) + list_elements = [( + self._names[list_index], + self._items[list_index], + return_value[list_index:list_index+list_len], + )] + + return elements_before_list + list_elements + elements_after_list diff --git a/src/robot/variables/search.py b/src/robot/variables/search.py index 5102519c244..6453c435f19 100644 --- a/src/robot/variables/search.py +++ b/src/robot/variables/search.py @@ -47,21 +47,21 @@ def is_dict_variable(string): return is_variable(string, '&') -def is_assign(string, identifiers='$@&', allow_assign_mark=False): +def is_assign(string, identifiers='$@&', allow_assign_mark=False, allow_items=False): match = search_variable(string, identifiers, ignore_errors=True) - return match.is_assign(allow_assign_mark) + return match.is_assign(allow_assign_mark, allow_items=allow_items) -def is_scalar_assign(string, allow_assign_mark=False): - return is_assign(string, '$', allow_assign_mark) +def is_scalar_assign(string, allow_assign_mark=False, allow_items=False): + return is_assign(string, '$', allow_assign_mark, allow_items) -def is_list_assign(string, allow_assign_mark=False): - return is_assign(string, '@', allow_assign_mark) +def is_list_assign(string, allow_assign_mark=False, allow_items=False): + return is_assign(string, '@', allow_assign_mark, allow_items) -def is_dict_assign(string, allow_assign_mark=False): - return is_assign(string, '&', allow_assign_mark) +def is_dict_assign(string, allow_assign_mark=False, allow_items=False): + return is_assign(string, '&', allow_assign_mark, allow_items) class VariableMatch: @@ -114,13 +114,14 @@ def is_list_variable(self): def is_dict_variable(self): return self.identifier == '&' and self.is_variable() - def is_assign(self, allow_assign_mark=False, allow_nested=False): + def is_assign(self, + allow_assign_mark=False, allow_nested=False, allow_items=False): if allow_assign_mark and self.string.endswith('='): match = search_variable(self.string[:-1].rstrip(), ignore_errors=True) - return match.is_assign() + return match.is_assign(allow_items=allow_items) return (self.is_variable() and self.identifier in '$@&' - and not self.items + and (allow_items or not self.items) and (allow_nested or not search_variable(self.base))) def is_scalar_assign(self, allow_assign_mark=False, allow_nested=False): diff --git a/utest/variables/test_isvar.py b/utest/variables/test_isvar.py index 8a2fb51ca7e..f584b9b95a1 100644 --- a/utest/variables/test_isvar.py +++ b/utest/variables/test_isvar.py @@ -13,9 +13,9 @@ DICTS = ['&{var}', '&{ v A R }'] NOKS = ['', 'nothing', '$not', '${not', '@not', '&{not', '${not}[oops', '%{not}', '*{not}', r'\${var}', r'\\\${var}', 42, None, ['${var}']] -NOK_ASSIGNS = NOKS + ['${${internal}}', '${var}[item]', - '@{${internal}}', '@{var}[item]', - '&{${internal}}', '&{var}[item]'] +NOK_ASSIGNS = NOKS + ['${${internal}}', + '@{${internal}}', + '&{${internal}}'] class TestIsVariable(unittest.TestCase): @@ -87,8 +87,12 @@ def test_is_assign(self): assert search_variable(ok).is_assign() assert is_assign(ok + '=', allow_assign_mark=True) assert is_assign(ok + ' =', allow_assign_mark=True) - assert not is_assign(ok + '[item]') assert not is_assign(' ' + ok) + for ok in SCALARS + LISTS + DICTS: + assert is_assign(ok + '[item]' + '[ i t e m ]' + '[${item}]', allow_items=True) + assert not is_assign(ok + '[item]' + '[ i t e m ]' + '[${item}]') + assert is_assign(ok + '[item]' + '[ i t e m ]' + '[${item}]', allow_items=True) + assert not is_assign(ok + '[item]' + '[ i t e m ]' + '[${item}]') for nok in NOK_ASSIGNS: assert not is_assign(nok) assert not search_variable(nok, ignore_errors=True).is_assign() @@ -99,7 +103,10 @@ def test_is_scalar_assign(self): assert search_variable(ok).is_scalar_assign() assert is_scalar_assign(ok + '=', allow_assign_mark=True) assert is_scalar_assign(ok + ' =', allow_assign_mark=True) + assert is_scalar_assign(ok + '[item]', allow_items=True) + assert is_scalar_assign(ok + '[item1][item2]', allow_items=True) assert not is_scalar_assign(ok + '[item]') + assert not is_scalar_assign(ok + '[item1][item2]') assert not is_scalar_assign(' ' + ok) for nok in NOK_ASSIGNS + LISTS + DICTS: assert not is_scalar_assign(nok) @@ -111,7 +118,10 @@ def test_is_list_assign(self): assert search_variable(ok).is_list_assign() assert is_list_assign(ok + '=', allow_assign_mark=True) assert is_list_assign(ok + ' =', allow_assign_mark=True) + assert is_list_assign(ok + '[item]', allow_items=True) + assert is_list_assign(ok + '[item1][item2]', allow_items=True) assert not is_list_assign(ok + '[item]') + assert not is_list_assign(ok + '[item1][item2]') assert not is_list_assign(' ' + ok) for nok in NOK_ASSIGNS + SCALARS + DICTS: assert not is_list_assign(nok) @@ -123,7 +133,10 @@ def test_is_dict_assign(self): assert search_variable(ok).is_dict_assign() assert is_dict_assign(ok + '=', allow_assign_mark=True) assert is_dict_assign(ok + ' =', allow_assign_mark=True) + assert is_dict_assign(ok + '[item]', allow_items=True) + assert is_dict_assign(ok + '[item1][item2]', allow_items=True) assert not is_dict_assign(ok + '[item]') + assert not is_dict_assign(ok + '[item1][item2]') assert not is_dict_assign(' ' + ok) for nok in NOK_ASSIGNS + SCALARS + LISTS: assert not is_dict_assign(nok) From b83307a8b39c311a982dc868adf8d5919bc076d7 Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Tue, 23 May 2023 23:25:39 +0100 Subject: [PATCH 0570/1592] Make fixture generic to enable type hinting (#4770) Part of #4570. --- src/robot/model/fixture.py | 19 +++++++++++++++---- src/robot/model/keyword.py | 2 -- src/robot/model/testcase.py | 8 ++++---- src/robot/model/testsuite.py | 8 ++++---- src/robot/result/model.py | 12 +++++------- src/robot/running/model.py | 8 +++----- utest/model/test_fixture.py | 6 +++--- 7 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/robot/model/fixture.py b/src/robot/model/fixture.py index 79535d0ed3c..ee94bd76751 100644 --- a/src/robot/model/fixture.py +++ b/src/robot/model/fixture.py @@ -14,17 +14,28 @@ # limitations under the License. from collections.abc import Mapping +from typing import Type, TypeVar, TYPE_CHECKING -from .keyword import Keyword +if TYPE_CHECKING: + from robot.model import DataDict, Keyword, TestCase, TestSuite + from robot.running.model import UserKeyword -def create_fixture(fixture, parent, fixture_type) -> Keyword: - # TestCase and TestSuite have 'fixture_class', Keyword doesn't. - fixture_class = getattr(parent, 'fixture_class', parent.__class__) +T = TypeVar('T', bound='Keyword') + + +def create_fixture(fixture_class: Type[T], + fixture: 'T|DataDict|None', + parent: 'TestCase|TestSuite|Keyword|UserKeyword', + fixture_type: str) -> T: + """Create or configure a `fixture_class` instance.""" + # If a fixture instance has been passed in update the config if isinstance(fixture, fixture_class): return fixture.config(parent=parent, type=fixture_type) + # If a Mapping has been passed in, create a fixture instance from it if isinstance(fixture, Mapping): return fixture_class.from_dict(fixture).config(parent=parent, type=fixture_type) + # If nothing has been passed in then return a new fixture instance from it if fixture is None: return fixture_class(None, parent=parent, type=fixture_type) raise TypeError(f"Invalid fixture type '{type(fixture).__name__}'.") diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index e5bd55cb53d..0f596541fb4 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -21,8 +21,6 @@ from .modelobject import DataDict if TYPE_CHECKING: - from .testcase import TestCase - from .testsuite import TestSuite from .visitor import SuiteVisitor diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index 893c6123eaf..eaa6ca64c01 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -96,12 +96,12 @@ def setup(self) -> Keyword: ``test.keywords.setup``. """ if self._setup is None: - self._setup = create_fixture(None, self, Keyword.SETUP) + self._setup = create_fixture(self.fixture_class, None, self, Keyword.SETUP) return self._setup @setup.setter def setup(self, setup: 'Keyword|DataDict|None'): - self._setup = create_fixture(setup, self, Keyword.SETUP) + self._setup = create_fixture(self.fixture_class, setup, self, Keyword.SETUP) @property def has_setup(self) -> bool: @@ -124,12 +124,12 @@ def teardown(self) -> Keyword: See :attr:`setup` for more information. """ if self._teardown is None: - self._teardown = create_fixture(None, self, Keyword.TEARDOWN) + self._teardown = create_fixture(self.fixture_class, None, self, Keyword.TEARDOWN) return self._teardown @teardown.setter def teardown(self, teardown: 'Keyword|DataDict|None'): - self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) + self._teardown = create_fixture(self.fixture_class, teardown, self, Keyword.TEARDOWN) @property def has_teardown(self) -> bool: diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 582d9b28d97..0571277133d 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -192,12 +192,12 @@ def setup(self) -> Keyword: ``suite.keywords.setup``. """ if self._setup is None: - self._setup = create_fixture(None, self, Keyword.SETUP) + self._setup = create_fixture(self.fixture_class, None, self, Keyword.SETUP) return self._setup @setup.setter def setup(self, setup: 'Keyword|DataDict|None'): - self._setup = create_fixture(setup, self, Keyword.SETUP) + self._setup = create_fixture(self.fixture_class, setup, self, Keyword.SETUP) @property def has_setup(self) -> bool: @@ -220,12 +220,12 @@ def teardown(self) -> Keyword: See :attr:`setup` for more information. """ if self._teardown is None: - self._teardown = create_fixture(None, self, Keyword.TEARDOWN) + self._teardown = create_fixture(self.fixture_class, None, self, Keyword.TEARDOWN) return self._teardown @teardown.setter def teardown(self, teardown: 'Keyword|DataDict|None'): - self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) + self._teardown = create_fixture(self.fixture_class, teardown, self, Keyword.TEARDOWN) @property def has_teardown(self) -> bool: diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 554b395cbd6..48983fddf6d 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -41,7 +41,7 @@ from datetime import datetime, timedelta from itertools import chain from pathlib import Path -from typing import cast, Generic, Mapping, Sequence, Type, Union, TypeVar +from typing import Generic, Mapping, Sequence, Type, Union, TypeVar if sys.version_info >= (3, 8): from typing import Literal @@ -736,15 +736,13 @@ def teardown(self) -> 'Keyword': ``keyword.keywords.teardown``. :attr:`has_teardown` is new in Robot Framework 4.1.2. """ - if self._teardown is None and self: - self._teardown = create_fixture(None, self, self.TEARDOWN) - # Would be better to enhance `create_fixture` so that its return - # type would match argument type. - return cast(Keyword, self._teardown) + if self._teardown is None: + self._teardown = create_fixture(self.__class__, None, self, self.TEARDOWN) + return self._teardown @teardown.setter def teardown(self, teardown: 'Keyword|DataDict|None'): - self._teardown = create_fixture(teardown, self, self.TEARDOWN) + self._teardown = create_fixture(self.__class__, teardown, self, self.TEARDOWN) @property def has_teardown(self) -> bool: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 19d70940fe9..5ac59642860 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -755,14 +755,12 @@ def keywords(self, keywords): @property def teardown(self) -> Keyword: if self._teardown is None: - self._teardown = create_fixture(None, self, Keyword.TEARDOWN) - # Would be better to enhance `create_fixture` so that its return - # type would match argument type. - return cast(Keyword, self._teardown) + self._teardown = create_fixture(self.fixture_class, None, self, Keyword.TEARDOWN) + return self._teardown @teardown.setter def teardown(self, teardown: 'Keyword|DataDict|None'): - self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN) + self._teardown = create_fixture(self.fixture_class, teardown, self, Keyword.TEARDOWN) @property def has_teardown(self) -> bool: diff --git a/utest/model/test_fixture.py b/utest/model/test_fixture.py index 91e15b2f88e..b09881970bb 100644 --- a/utest/model/test_fixture.py +++ b/utest/model/test_fixture.py @@ -9,13 +9,13 @@ class TestCreateFixture(unittest.TestCase): def test_creates_default_fixture_when_given_none(self): suite = TestSuite() - fixture = create_fixture(None, suite, Keyword.SETUP) + fixture = create_fixture(suite.fixture_class, None, suite, Keyword.SETUP) self._assert_fixture(fixture, suite, Keyword.SETUP) def test_sets_parent_and_type_correctly(self): suite = TestSuite() kw = Keyword('KW Name') - fixture = create_fixture(kw, suite, Keyword.TEARDOWN) + fixture = create_fixture(suite.fixture_class, kw, suite, Keyword.TEARDOWN) self._assert_fixture(fixture, suite, Keyword.TEARDOWN) def test_raises_type_error_when_wrong_fixture_type(self): @@ -23,7 +23,7 @@ def test_raises_type_error_when_wrong_fixture_type(self): wrong_kw = object() assert_raises_with_msg( TypeError, "Invalid fixture type 'object'.", - create_fixture, wrong_kw, suite, Keyword.TEARDOWN + create_fixture, suite.fixture_class, wrong_kw, suite, Keyword.TEARDOWN ) def _assert_fixture(self, fixture, exp_parent, exp_type, From f8b7b88214737100f66cff5a9001b0c53607fb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 23 May 2023 20:14:41 +0300 Subject: [PATCH 0571/1592] Python 3.12 compatibility #4771 `$` isn't anymore tokenized as `ERRORTOKEN`. --- src/robot/variables/evaluation.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py index 34bc7f15164..afff6713d1f 100644 --- a/src/robot/variables/evaluation.py +++ b/src/robot/variables/evaluation.py @@ -71,20 +71,22 @@ def _decorate_variables(expression, variable_store): variable_started = False variable_found = False tokens = [] + prev_toknum = None for toknum, tokval, _, _, _ in generate_tokens(StringIO(expression).readline): if variable_started: if toknum == token.NAME: if tokval not in variable_store: - variable_not_found('$%s' % tokval, + variable_not_found(f'${tokval}', variable_store.as_dict(decoration=False), deco_braces=False) tokval = 'RF_VAR_' + tokval variable_found = True else: - tokens.append((token.ERRORTOKEN, '$')) + tokens.append((prev_toknum, '$')) variable_started = False - if toknum == token.ERRORTOKEN and tokval == '$': + if tokval == '$': variable_started = True + prev_toknum = toknum else: tokens.append((toknum, tokval)) return untokenize(tokens).strip() if variable_found else expression From ace527f1bf13a3f09c51f35d8084cd6f7a392815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 23 May 2023 20:25:39 +0300 Subject: [PATCH 0572/1592] Avoid invalid escape sequences. Part of Python 3.12 compatibility. #4771 --- atest/robot/cli/console/max_error_lines.robot | 2 +- utest/run.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/atest/robot/cli/console/max_error_lines.robot b/atest/robot/cli/console/max_error_lines.robot index 88abce98564..b0a32af81ae 100644 --- a/atest/robot/cli/console/max_error_lines.robot +++ b/atest/robot/cli/console/max_error_lines.robot @@ -51,7 +51,7 @@ Error Message In Log Should Not Have Been Cut Should Match Non Empty Regexp [Arguments] ${message} ${pattern} - Run Keyword If '${pattern}' Should Match Regexp ${message} ${pattern} + IF $pattern Should Match Regexp ${message} ${pattern} Has Not Been Cut [Arguments] ${testname} diff --git a/utest/run.py b/utest/run.py index a9e74825a01..5237e65ea0b 100755 --- a/utest/run.py +++ b/utest/run.py @@ -37,7 +37,7 @@ if path not in sys.path: sys.path.insert(0, path) -testfile = re.compile("^test_.*\.py$", re.IGNORECASE) +testfile = re.compile(r"^test_.*\.py$", re.IGNORECASE) imported = {} From a9274b7ce4704575cffbcc73c628b275a027fc2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 24 May 2023 00:57:54 +0300 Subject: [PATCH 0573/1592] Minor enhancement to Collections docs. --- src/robot/libraries/Collections.py | 33 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 18c2fbae8d0..c07774a8a15 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -112,9 +112,10 @@ def set_list_value(self, list_, index, value): => | ${L3} = ['a', 'xxx', 'yyy'] - This keyword is equivalent to using the following syntax: - | ${L3}[1] = | Set Variable | xxx | - | ${D1}[-1] = | Set Variable | yyy | + Starting from Robot Framework 6.1, it is also possible to use the native + item assignment syntax. This is equivalent to the above: + | ${L3}[1] = | Set Variable | xxx | + | ${L3}[-1] = | Set Variable | yyy | """ self._validate_list(list_) try: @@ -499,25 +500,25 @@ def convert_to_dictionary(self, item): return dict(item) def set_to_dictionary(self, dictionary, *key_value_pairs, **items): - """Adds the given ``key_value_pairs`` and ``items`` to the ``dictionary``. + """Adds the given ``key_value_pairs`` and/or ``items`` to the ``dictionary``. - Giving items as ``key_value_pairs`` means giving keys and values - as separate arguments: + If given items already exist in the dictionary, their values are updated. - | Set To Dictionary | ${D1} | key | value | second | ${2} | + It is easiest to specify items using the ``name=value`` syntax: + | Set To Dictionary | ${D1} | key=value | second=${2} | => | ${D1} = {'a': 1, 'key': 'value', 'second': 2} - | Set To Dictionary | ${D1} | key=value | second=${2} | - - The latter syntax is typically more convenient to use, but it has - a limitation that keys must be strings. - - If given keys already exist in the dictionary, their values are updated. + A limitation of the above syntax is that keys must be strings. + That can be avoided by passing keys and values as separate arguments: + | Set To Dictionary | ${D1} | key | value | ${2} | value 2 | + => + | ${D1} = {'a': 1, 'key': 'value', 2: 'value 2'} - This keyword is equivalent to using the following syntax: - | ${D1}[key] = | Set Variable | value | - | ${D1}[second] = | Set Variable | ${2} | + Starting from Robot Framework 6.1, it is also possible to use the native + item assignment syntax. This is equivalent to the above: + | ${D1}[key] = | Set Variable | value | + | ${D1}[${2}] = | Set Variable | value 2 | """ self._validate_dictionary(dictionary) if len(key_value_pairs) % 2 != 0: From a672e03b81dbe79c8331d8ad9408541f32c25d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 24 May 2023 22:23:46 +0300 Subject: [PATCH 0574/1592] f-strings --- src/robot/running/importer.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/robot/running/importer.py b/src/robot/running/importer.py index 261df740f4a..0097c0b586b 100644 --- a/src/robot/running/importer.py +++ b/src/robot/running/importer.py @@ -49,13 +49,13 @@ def import_library(self, name, args, alias, variables): if alias: alias = variables.replace_scalar(alias) lib = self._copy_library(lib, alias) - LOGGER.info("Imported library '%s' with name '%s'" % (name, alias)) + LOGGER.info(f"Imported library '{name}' with name '{alias}'.") return lib def import_resource(self, path, lang=None): self._validate_resource_extension(path) if path in self._resource_cache: - LOGGER.info("Found resource file '%s' from cache" % path) + LOGGER.info(f"Found resource file '{path}' from cache.") else: resource = ResourceFileBuilder(lang=lang).build(path) self._resource_cache[path] = resource @@ -64,16 +64,16 @@ def import_resource(self, path, lang=None): def _validate_resource_extension(self, path): extension = os.path.splitext(path)[1] if extension.lower() not in RESOURCE_EXTENSIONS: - raise DataError("Invalid resource file extension '%s'. " - "Supported extensions are %s." - % (extension, seq2str(RESOURCE_EXTENSIONS))) + extensions = seq2str(sorted(RESOURCE_EXTENSIONS)) + raise DataError(f"Invalid resource file extension '{extension}'. " + f"Supported extensions are {extensions}.") def _import_library(self, name, positional, named, lib): - args = positional + ['%s=%s' % arg for arg in named] + args = positional + [f'{name}={value}' for name, value in named] key = (name, positional, named) if key in self._library_cache: - LOGGER.info("Found library '%s' with arguments %s from cache." - % (name, seq2str2(args))) + LOGGER.info(f"Found library '{name}' with arguments {seq2str2(args)} " + f"from cache.") return self._library_cache[key] lib.create_handlers() self._library_cache[key] = lib @@ -83,12 +83,11 @@ def _import_library(self, name, positional, named, lib): def _log_imported_library(self, name, args, lib): type = lib.__class__.__name__.replace('Library', '').lower()[1:] listener = ', with listener' if lib.has_listener else '' - LOGGER.info("Imported library '%s' with arguments %s " - "(version %s, %s type, %s scope, %d keywords%s)" - % (name, seq2str2(args), lib.version or '<unknown>', - type, lib.scope, len(lib), listener)) + LOGGER.info(f"Imported library '{name}' with arguments {seq2str2(args)} " + f"(version {lib.version or '<unknown>'}, {type} type, " + f"{lib.scope} scope, {len(lib)} keywords{listener}).") if not lib: - LOGGER.warn("Imported library '%s' contains no keywords." % name) + LOGGER.warn(f"Imported library '{name}' contains no keywords.") def _copy_library(self, orig, name): # This is pretty ugly. Hopefully we can remove cache and copying From f13d31fdae44a34a522dd4f315911301109ada02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 24 May 2023 23:16:16 +0300 Subject: [PATCH 0575/1592] Enhancements and fixes to JSON serialization. #3902 - Support JSON resource files. Possible extensions are `.json` and `.rsrc`. - Handle failures in parsing JSON files gracefully. - Tests. --- atest/robot/cli/runner/extension.robot | 4 +- atest/robot/cli/runner/files.robot | 8 +- .../data_formats/formats_resource.robot | 7 +- atest/robot/parsing/data_formats/json.robot | 32 + .../data_formats/resource_extensions.robot | 10 +- .../parsing/data_formats/json/_invalid.json | 5 + .../data_formats/json/not_a_picture.jpg | 1 + .../parsing/data_formats/json/sample.rbt | 669 ++++++++++++++++++ .../data_formats/json/with_init/__init__.json | 85 +++ .../resource_extensions/resource.json | 37 + .../resource_extensions/resource.rsrc | 37 + .../resource_extensions/tests.robot | 12 +- .../data_formats/resources/json_resource.json | 31 + .../resources/json_resource2.rsrc | 24 + src/robot/model/modelobject.py | 9 +- src/robot/running/builder/builders.py | 11 +- src/robot/running/builder/parsers.py | 6 +- src/robot/running/importer.py | 3 +- utest/model/test_modelobject.py | 13 +- 19 files changed, 976 insertions(+), 28 deletions(-) create mode 100644 atest/robot/parsing/data_formats/json.robot create mode 100644 atest/testdata/parsing/data_formats/json/_invalid.json create mode 100644 atest/testdata/parsing/data_formats/json/not_a_picture.jpg create mode 100644 atest/testdata/parsing/data_formats/json/sample.rbt create mode 100644 atest/testdata/parsing/data_formats/json/with_init/__init__.json create mode 100644 atest/testdata/parsing/data_formats/resource_extensions/resource.json create mode 100644 atest/testdata/parsing/data_formats/resource_extensions/resource.rsrc create mode 100644 atest/testdata/parsing/data_formats/resources/json_resource.json create mode 100644 atest/testdata/parsing/data_formats/resources/json_resource2.rsrc diff --git a/atest/robot/cli/runner/extension.robot b/atest/robot/cli/runner/extension.robot index 659d63a6b06..e124abfc09a 100644 --- a/atest/robot/cli/runner/extension.robot +++ b/atest/robot/cli/runner/extension.robot @@ -7,11 +7,11 @@ ${DATA FORMATS} ${DATADIR}/parsing/data_formats *** Test Cases *** One extension - --extension robot 27 + --extension robot 29 --EXTENSION .TXT 23 Multiple extensions - -F robot:txt:.ROBOT 50 + -F robot:txt:.ROBOT 52 Any extension is accepted --extension bar 1 diff --git a/atest/robot/cli/runner/files.robot b/atest/robot/cli/runner/files.robot index d27d0fc60ab..f3dd2472610 100644 --- a/atest/robot/cli/runner/files.robot +++ b/atest/robot/cli/runner/files.robot @@ -6,16 +6,14 @@ Resource atest_resource.robot ${DATA FORMATS} ${DATADIR}/parsing/data_formats *** Test Cases *** - Simple filename -f sample.robot 18 -Filtering by extension - --files *.robot 27 - --FILES s* 20 +Pattern + --files *.robot 29 Multiple patterns - --files sample.robot --files tests.robot 25 + --files sample.robot --files tests.robot 27 Combine extension and files --files sample.rst -F rst 18 diff --git a/atest/robot/parsing/data_formats/formats_resource.robot b/atest/robot/parsing/data_formats/formats_resource.robot index 376810d8caf..10463f0ca6e 100644 --- a/atest/robot/parsing/data_formats/formats_resource.robot +++ b/atest/robot/parsing/data_formats/formats_resource.robot @@ -7,6 +7,7 @@ ${TSV DIR} ${FORMATS DIR}/tsv ${TXT DIR} ${FORMATS DIR}/txt ${ROBOT DIR} ${FORMATS DIR}/robot ${REST DIR} ${FORMATS DIR}/rest +${JSON DIR} ${FORMATS DIR}/json ${MIXED DIR} ${FORMATS DIR}/mixed_data ${RESOURCE DIR} ${FORMATS DIR}/resources @{SAMPLE TESTS} Passing Failing User Keyword Nön-äscïï Own Tags Default Tags Variable Table @@ -51,8 +52,10 @@ Run Suite Dir And Check Results Should Contain Suites ${SUITE.suites[1]} Sub Suite1 Sub Suite2 Should Contain Tests ${SUITE} @{SAMPLE_TESTS} @{SUBSUITE_TESTS} ${path} = Normalize Path ${path} - Syslog Should Contain | INFO \ | Data source '${path}${/}invalid.${type}' has no tests or tasks. - Syslog Should Contain | INFO \ | Data source '${path}${/}empty.${type}' has no tests or tasks. + IF $type != 'json' + Syslog Should Contain | INFO \ | Data source '${path}${/}invalid.${type}' has no tests or tasks. + Syslog Should Contain | INFO \ | Data source '${path}${/}empty.${type}' has no tests or tasks. + END Syslog Should Contain | INFO \ | Ignoring file or directory '${path}${/}not_a_picture.jpg'. Check Suite With Init diff --git a/atest/robot/parsing/data_formats/json.robot b/atest/robot/parsing/data_formats/json.robot new file mode 100644 index 00000000000..ebcae1ec497 --- /dev/null +++ b/atest/robot/parsing/data_formats/json.robot @@ -0,0 +1,32 @@ +*** Settings *** +Resource formats_resource.robot + +*** Test Cases *** +One JSON + Run sample file and check tests ${EMPTY} ${JSON DIR}/sample.rbt + +JSON With JSON Resource + Previous Run Should Have Been Successful + Check Test Case Resource File + +Invalid JSON Resource + Previous Run Should Have Been Successful + ${path} = Normalize Path atest/testdata/parsing/data_formats/json/sample.rbt + ${inva} = Normalize Path ${JSON DIR}/_invalid.json + Check Log Message ${ERRORS}[0] + ... Error in file '${path}' on line 12: Parsing JSON resource file '${inva}' failed: Loading JSON data failed: Invalid JSON data: * + ... level=ERROR pattern=True + +Invalid JSON Suite + ${result} = Run Tests ${EMPTY} ${JSON DIR}/_invalid.json output=None + Should Be Equal As Integers ${result.rc} 252 + ${path} = Normalize Path ${JSON DIR}/_invalid.json + Should Start With ${result.stderr} + ... [ ERROR ] Parsing '${path}' failed: Loading JSON data failed: Invalid JSON data: + +JSON Directory + Run Suite Dir And Check Results -F json:rbt ${JSON DIR} + +Directory With JSON Init + Previous Run Should Have Been Successful + Check Suite With Init ${SUITE.suites[1]} diff --git a/atest/robot/parsing/data_formats/resource_extensions.robot b/atest/robot/parsing/data_formats/resource_extensions.robot index d28e15c85a7..dcc66e3e9f4 100644 --- a/atest/robot/parsing/data_formats/resource_extensions.robot +++ b/atest/robot/parsing/data_formats/resource_extensions.robot @@ -33,9 +33,15 @@ Resource with '*.rest' extension [Tags] require-docutils Check Test Case ${TESTNAME} +Resource with '*.rsrc' extension + Check Test Case ${TESTNAME} + +Resource with '*.json' extension + Check Test Case ${TESTNAME} + Resource with invalid extension Check Test Case ${TESTNAME} - Error in file 0 parsing/data_formats/resource_extensions/tests.robot 6 + Error in file 0 parsing/data_formats/resource_extensions/tests.robot 10 ... Invalid resource file extension '.invalid'. - ... Supported extensions are '.resource', '.robot', '.txt', '.tsv', '.rst' and '.rest'. + ... Supported extensions are '.json', '.resource', '.rest', '.robot', '.rsrc', '.rst', '.tsv' and '.txt'. Length should be ${ERRORS} 1 diff --git a/atest/testdata/parsing/data_formats/json/_invalid.json b/atest/testdata/parsing/data_formats/json/_invalid.json new file mode 100644 index 00000000000..3dbf5eb1c77 --- /dev/null +++ b/atest/testdata/parsing/data_formats/json/_invalid.json @@ -0,0 +1,5 @@ +<html> +<body> +<h1>Not JSON</h1> +</body> +</html> diff --git a/atest/testdata/parsing/data_formats/json/not_a_picture.jpg b/atest/testdata/parsing/data_formats/json/not_a_picture.jpg new file mode 100644 index 00000000000..ea50d93cf7b --- /dev/null +++ b/atest/testdata/parsing/data_formats/json/not_a_picture.jpg @@ -0,0 +1 @@ +This is not really a picture, but this should be ignored in parsing. diff --git a/atest/testdata/parsing/data_formats/json/sample.rbt b/atest/testdata/parsing/data_formats/json/sample.rbt new file mode 100644 index 00000000000..7f58aa1070b --- /dev/null +++ b/atest/testdata/parsing/data_formats/json/sample.rbt @@ -0,0 +1,669 @@ +{ +"name":"Sample", +"doc":"A complex testdata file in rbt format.", +"source":"atest/testdata/parsing/data_formats/json/sample.rbt", +"setup":{ +"name":"Log", +"args":[ +"Setup" +], +"lineno":10 +}, +"tests":[ +{ +"name":"Passing", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":33, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Log", +"args":[ +"Passing test case." +], +"lineno":33 +} +] +}, +{ +"name":"Failing", +"doc":"FAIL Failing test case.", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":35, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Fail", +"args":[ +"Failing test case." +], +"lineno":36 +} +] +}, +{ +"name":"User Keyword", +"doc":"FAIL A cunning argument. != something", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":37, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"My Keyword With Arg", +"args":[ +"A cunning argument." +], +"lineno":38 +} +] +}, +{ +"name":"Nön-äscïï", +"doc":"FAIL Nön-äscïï error", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":39, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Fail", +"args":[ +"Nön-äscïï error" +], +"lineno":41 +} +] +}, +{ +"name":"Own Tags", +"tags":[ +"force1", +"force2", +"own1", +"own2" +], +"lineno":42, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Log", +"args":[ +"tags test" +], +"lineno":43 +} +] +}, +{ +"name":"Default Tags", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":45, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"No Operation", +"lineno":45 +} +] +}, +{ +"name":"Variable Table", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":47, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Should Be Equal", +"args":[ +"${table_var}", +"foo" +], +"lineno":47 +}, +{ +"name":"Should Be Equal", +"args":[ +"${table_listvar}[0]", +"bar" +], +"lineno":48 +}, +{ +"name":"Should Be Equal", +"args":[ +"${table_listvar}[1]", +"foo" +], +"lineno":49 +} +] +}, +{ +"name":"Resource File", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":52, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Keyword from JSON resource", +"lineno":52 +}, +{ +"name":"Keyword from JSON resource 2", +"lineno":53 +}, +{ +"name":"Should Be Equal", +"args":[ +"${json_resource_var}", +"JSON Resource Variable" +], +"lineno":54 +}, +{ +"name":"Should Be Equal", +"args":[ +"${json_resource_var2}", +"JSON Resource Variable From Recursive Resource" +], +"lineno":55 +} +] +}, +{ +"name":"Variable File", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":57, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Should Be Equal", +"args":[ +"${file_listvar}[0]", +"${True}" +], +"lineno":57 +}, +{ +"name":"Should Be Equal", +"args":[ +"${file_listvar}[1]", +"${3.14}" +], +"lineno":58 +}, +{ +"name":"Should Be Equal", +"args":[ +"${file_listvar}[2]", +"Hello, world!!" +], +"lineno":59 +}, +{ +"name":"Should Be Equal", +"args":[ +"${file_var1}", +"${-314}" +], +"lineno":60 +}, +{ +"name":"Should Be Equal", +"args":[ +"${file_var2}", +"file variable 2" +], +"lineno":61 +} +] +}, +{ +"name":"Library Import", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":64, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Directory Should Not Be Empty", +"args":[ +"atest/testdata/parsing/data_formats/json" +], +"lineno":64 +} +] +}, +{ +"name":"Test Timeout", +"doc":"FAIL Test timeout 10 milliseconds exceeded.", +"tags":[ +"default1", +"force1", +"force2" +], +"timeout":"0.01second", +"lineno":70, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Sleep", +"args":[ +"1" +], +"lineno":72 +} +] +}, +{ +"name":"Keyword Timeout", +"doc":"FAIL Keyword timeout 2 milliseconds exceeded.", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":74, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Timeouted Keyword", +"lineno":75 +} +] +}, +{ +"name":"Empty Rows", +"doc":"Testing that empty rows are ignored. FAIL Expected failure.", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":83, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"No operation", +"lineno":87 +}, +{ +"name":"Fail", +"args":[ +"Expected failure." +], +"lineno":89 +} +] +}, +{ +"name":"Document", +"doc":"Testing the metadata parsing.", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":91, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"no operation", +"lineno":92 +} +] +}, +{ +"name":"Default Fixture", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":94, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"No operation", +"lineno":94 +} +] +}, +{ +"name":"Overridden Fixture", +"doc":"FAIL Teardown failed:\\nFailing Teardown", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":96, +"setup":{ +"name":"Log", +"args":[ +"Own Setup" +], +"lineno":97 +}, +"teardown":{ +"name":"Fail", +"args":[ +"Failing Teardown" +], +"lineno":96 +}, +"body":[ +{ +"name":"No Operation", +"lineno":99 +} +] +}, +{ +"name":"Quotes", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":101, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Should Be Equal", +"args":[ +"${quoted}", +"\"\"\"this has \"\"\"\"many \"\" quotes \"\"\"\"\"" +], +"lineno":101 +}, +{ +"name":"Should Be Equal", +"args":[ +"${single_quoted}", +"s'ingle'qu'ot'es''" +], +"lineno":102 +} +] +}, +{ +"name":"Escaping", +"tags":[ +"default1", +"force1", +"force2" +], +"lineno":104, +"teardown":{ +"name":"Log", +"args":[ +"Test Teardown" +], +"lineno":11 +}, +"body":[ +{ +"name":"Should Be Equal", +"args":[ +"-c:\\\\temp-\\t-\\x00-\\${x}-", +"${ESCAPING}" +], +"lineno":105 +} +] +} +], +"resource":{ +"imports":[ +{ +"type":"RESOURCE", +"name":"../resources/json_resource.json", +"lineno":12 +}, +{ +"type":"RESOURCE", +"name":"_invalid.json", +"lineno":12 +}, +{ +"type":"VARIABLES", +"name":"../resources/variables.py", +"lineno":13 +}, +{ +"type":"LIBRARY", +"name":"OperatingSystem", +"lineno":14 +} +], +"variables":[ +{ +"name":"${table_var}", +"value":[ +"foo" +], +"lineno":25 +}, +{ +"name":"@{table_listvar}", +"value":[ +"bar", +"${table_var}" +], +"lineno":26 +}, +{ +"name":"${quoted}", +"value":[ +"\"\"\"this has \"\"\"\"many \"\" quotes \"\"\"\"\"" +], +"lineno":27 +}, +{ +"name":"${single_quoted}", +"value":[ +"s'ingle'qu'ot'es''" +], +"lineno":28 +} +], +"keywords":[ +{ +"name":"My Keyword With Arg", +"args":[ +"${arg1}" +], +"lineno":110, +"body":[ +{ +"name":"Keyword with no arguments", +"lineno":112 +}, +{ +"name":"Another Keyword", +"args":[ +"${arg1}" +], +"lineno":113 +} +] +}, +{ +"name":"Another Keyword", +"args":[ +"${arg1}", +"${arg2}=something" +], +"lineno":117, +"body":[ +{ +"name":"Should Be Equal", +"args":[ +"${arg1}", +"${arg2}" +], +"lineno":119 +} +] +}, +{ +"name":"Timeouted Keyword", +"timeout":"2ms", +"lineno":121, +"body":[ +{ +"name":"Sleep", +"args":[ +"0.1" +], +"lineno":122 +} +] +}, +{ +"name":"Keyword With No Arguments", +"lineno":124, +"body":[ +{ +"name":"Log", +"args":[ +"Hello world!" +], +"lineno":124 +} +] +} +] +} +} diff --git a/atest/testdata/parsing/data_formats/json/with_init/__init__.json b/atest/testdata/parsing/data_formats/json/with_init/__init__.json new file mode 100644 index 00000000000..8b17cd2c921 --- /dev/null +++ b/atest/testdata/parsing/data_formats/json/with_init/__init__.json @@ -0,0 +1,85 @@ +{ +"name":"With Init", +"doc":"Testing suite init file", +"source":"atest/testdata/parsing/data_formats/json/with_init", +"setup":{ +"name":"Suite Setup", +"lineno":2 +}, +"suites":[ +{ +"name":"Sub Suite1", +"source":"atest/testdata/parsing/data_formats/json/with_init/sub_suite1.ROBOT", +"tests":[ +{ +"name":"Suite1 Test", +"lineno":23, +"body":[ +{ +"name":"No Operation", +"lineno":23 +} +] +} +], +"resource":{} +}, +{ +"name":"Sub Suite2", +"source":"atest/testdata/parsing/data_formats/json/with_init/sub_suite2.robot", +"tests":[ +{ +"name":"Suite2 Test", +"doc":"FAIL Expected failure", +"lineno":4, +"body":[ +{ +"name":"Fail", +"args":[ +"${msg}" +], +"lineno":5 +} +] +} +], +"resource":{ +"variables":[ +{ +"name":"${msg}", +"value":[ +"Expected failure" +], +"lineno":11 +} +] +} +} +], +"resource":{ +"variables":[ +{ +"name":"${msg}", +"value":[ +"Running suite setup" +], +"lineno":6 +} +], +"keywords":[ +{ +"name":"Suite Setup", +"lineno":9, +"body":[ +{ +"name":"Log", +"args":[ +"${msg}" +], +"lineno":9 +} +] +} +] +} +} diff --git a/atest/testdata/parsing/data_formats/resource_extensions/resource.json b/atest/testdata/parsing/data_formats/resource_extensions/resource.json new file mode 100644 index 00000000000..1489a704356 --- /dev/null +++ b/atest/testdata/parsing/data_formats/resource_extensions/resource.json @@ -0,0 +1,37 @@ +{ +"source":"atest/testdata/parsing/data_formats/resource_extensions/resource.json", +"imports":[ +{ +"type":"RESOURCE", +"name":"nested.resource", +"lineno":3 +} +], +"variables":[ +{ +"name":"${JSON}", +"value":[ +"resource.json" +], +"lineno":7 +} +], +"keywords":[ +{ +"name":"Keyword in resource.json", +"lineno":11, +"body":[ +{ +"name":"Should Be Equal", +"args":["${NESTED}", "nested.resource"], +"lineno":12 +}, +{ +"name":"Should Be Equal", +"args":["${JSON}", "resource.json"], +"lineno":13 +} +] +} +] +} diff --git a/atest/testdata/parsing/data_formats/resource_extensions/resource.rsrc b/atest/testdata/parsing/data_formats/resource_extensions/resource.rsrc new file mode 100644 index 00000000000..dfbbddc76ca --- /dev/null +++ b/atest/testdata/parsing/data_formats/resource_extensions/resource.rsrc @@ -0,0 +1,37 @@ +{ +"source":"atest/testdata/parsing/data_formats/resource_extensions/resource.rsrc", +"imports":[ +{ +"type":"RESOURCE", +"name":"nested.resource", +"lineno":3 +} +], +"variables":[ +{ +"name":"${RSRC}", +"value":[ +"resource.rsrc" +], +"lineno":7 +} +], +"keywords":[ +{ +"name":"Keyword in resource.rsrc", +"lineno":11, +"body":[ +{ +"name":"Should Be Equal", +"args":["${NESTED}", "nested.resource"], +"lineno":12 +}, +{ +"name":"Should Be Equal", +"args":["${RSRC}", "resource.rsrc"], +"lineno":13 +} +] +} +] +} diff --git a/atest/testdata/parsing/data_formats/resource_extensions/tests.robot b/atest/testdata/parsing/data_formats/resource_extensions/tests.robot index 15b7017a07c..51aca9b5072 100644 --- a/atest/testdata/parsing/data_formats/resource_extensions/tests.robot +++ b/atest/testdata/parsing/data_formats/resource_extensions/tests.robot @@ -3,9 +3,11 @@ Resource resource.resource Resource resource.robot Resource resource.txt Resource resource.TSV -Resource resource.invalid Resource resource.rst Resource resource.reST +Resource resource.rsrc +Resource resource.json +Resource resource.invalid *** Test Cases *** Resource with '*.resource' extension @@ -36,6 +38,14 @@ Resource with '*.rest' extension Keyword in resource.rest Should Be Equal ${REST} resource.reST +Resource with '*.rsrc' extension + Keyword in resource.json + Should Be Equal ${JSON} resource.json + +Resource with '*.json' extension + Keyword in resource.json + Should Be Equal ${JSON} resource.json + Resource with invalid extension [Documentation] FAIL No keyword with name 'Keyword in resource.invalid' found. Keyword in resource.invalid diff --git a/atest/testdata/parsing/data_formats/resources/json_resource.json b/atest/testdata/parsing/data_formats/resources/json_resource.json new file mode 100644 index 00000000000..bd6f78b7590 --- /dev/null +++ b/atest/testdata/parsing/data_formats/resources/json_resource.json @@ -0,0 +1,31 @@ +{ +"source":"atest/testdata/parsing/data_formats/resources/json_resource.json", +"imports":[ +{ +"type":"RESOURCE", +"name":"json_resource2.rsrc", +"lineno":3 +} +], +"variables":[ +{ +"name":"${JSON RESOURCE VAR}", +"value":[ +"JSON Resource Variable" +], +"lineno":7 +} +], +"keywords":[ +{ +"name":"Keyword from JSON resource", +"lineno":11, +"body":[ +{ +"name":"No operation", +"lineno":11 +} +] +} +] +} diff --git a/atest/testdata/parsing/data_formats/resources/json_resource2.rsrc b/atest/testdata/parsing/data_formats/resources/json_resource2.rsrc new file mode 100644 index 00000000000..23348ebeb2b --- /dev/null +++ b/atest/testdata/parsing/data_formats/resources/json_resource2.rsrc @@ -0,0 +1,24 @@ +{ +"source":"atest/testdata/parsing/data_formats/resources/robot_resource2.robot", +"variables":[ +{ +"name":"${JSON RESOURCE VAR 2}", +"value":[ +"JSON Resource Variable From Recursive Resource" +], +"lineno":2 +} +], +"keywords":[ +{ +"name":"Keyword from JSON resource 2", +"lineno":6, +"body":[ +{ +"name":"No operation", +"lineno":6 +} +] +} +] +} diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index b8f9300c092..027fc7c4250 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import Any, Dict, overload, Type, TypeVar +from robot.errors import DataError from robot.utils import get_error_message, SetterAwareType, type_name @@ -39,8 +40,8 @@ def from_dict(cls: Type[T], data: DataDict) -> T: try: return cls().config(**data) except (AttributeError, TypeError) as err: - raise ValueError(f"Creating '{full_name(cls)}' object from dictionary " - f"failed: {err}") + raise DataError(f"Creating '{full_name(cls)}' object from dictionary " + f"failed: {err}") @classmethod def from_json(cls: Type[T], source: 'str|bytes|IOBase|Path') -> T: @@ -62,7 +63,7 @@ def from_json(cls: Type[T], source: 'str|bytes|IOBase|Path') -> T: try: data = JsonLoader().load(source) except (TypeError, ValueError) as err: - raise ValueError(f'Loading JSON data failed: {err}') + raise DataError(f'Loading JSON data failed: {err}') return cls.from_dict(data) def to_dict(self) -> DataDict: @@ -242,5 +243,5 @@ def dump(self, data: DataDict, output: 'None|IOBase|Path|str' = None) -> 'None|s elif hasattr(output, 'write'): json.dump(data, output, **self.config) else: - raise TypeError(f"Output should be None, open file or path, " + raise TypeError(f"Output should be None, path or open file, " f"got {type_name(output)}.") diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 41ab5d2642d..5bd1a1980e0 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -294,6 +294,11 @@ def build(self, source: Path) -> ResourceFile: return resource def _parse(self, source: Path) -> ResourceFile: - if source.suffix.lower() in ('.rst', '.rest'): - return RestParser(self.lang, self.process_curdir).parse_resource_file(source) - return RobotParser(self.lang, self.process_curdir).parse_resource_file(source) + suffix = source.suffix.lower() + if suffix in ('.rst', '.rest'): + parser = RestParser(self.lang, self.process_curdir) + elif suffix in ('.json', '.rsrc'): + parser = JsonParser() + else: + parser = RobotParser(self.lang, self.process_curdir) + return parser.parse_resource_file(source) diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index d8d6f89b1a1..b15ff002729 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -99,9 +99,11 @@ def parse_suite_file(self, source: Path, defaults: TestDefaults) -> TestSuite: def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: return TestSuite.from_json(source) - # FIXME: Resource imports don't otherwise support JSON yet! def parse_resource_file(self, source: Path) -> ResourceFile: - return ResourceFile.from_json(source) + try: + return ResourceFile.from_json(source) + except DataError as err: + raise DataError(f"Parsing JSON resource file '{source}' failed: {err}") class NoInitFileDirectoryParser(Parser): diff --git a/src/robot/running/importer.py b/src/robot/running/importer.py index 0097c0b586b..229f0a7464f 100644 --- a/src/robot/running/importer.py +++ b/src/robot/running/importer.py @@ -26,7 +26,8 @@ from .testlibraries import TestLibrary -RESOURCE_EXTENSIONS = ('.resource', '.robot', '.txt', '.tsv', '.rst', '.rest') +RESOURCE_EXTENSIONS = {'.resource', '.robot', '.txt', '.tsv', '.rst', '.rest', + '.json', '.rsrc'} class Importer: diff --git a/utest/model/test_modelobject.py b/utest/model/test_modelobject.py index 3ff0839e43e..6340884db1c 100644 --- a/utest/model/test_modelobject.py +++ b/utest/model/test_modelobject.py @@ -5,6 +5,7 @@ import unittest import tempfile +from robot.errors import DataError from robot.model.modelobject import ModelObject from robot.utils import get_error_message from robot.utils.asserts import assert_equal, assert_raises_with_msg @@ -98,13 +99,13 @@ def test_other_attributes(self): def test_not_accepted_attribute(self): assert_raises_with_msg( - ValueError, + DataError, f"Creating '{__name__}.Example' object from dictionary failed: " f"'{__name__}.Example' object does not have attribute 'nonex'", Example.from_dict, {'nonex': 'attr'} ) assert_raises_with_msg( - ValueError, + DataError, f"Creating '{__name__}.Example' object from dictionary failed: " f"Setting attribute 'a' failed: Ooops!", Example.from_dict, {'a': 'fail'} @@ -136,7 +137,7 @@ def test_json_as_path(self): def test_invalid_json_type(self): error = self._get_json_load_error(None) assert_raises_with_msg( - ValueError, + DataError, f"Loading JSON data failed: Invalid JSON data: {error}", ModelObject.from_json, None ) @@ -144,14 +145,14 @@ def test_invalid_json_type(self): def test_invalid_json_syntax(self): error = self._get_json_load_error('bad') assert_raises_with_msg( - ValueError, + DataError, f"Loading JSON data failed: Invalid JSON data: {error}", ModelObject.from_json, 'bad' ) def test_invalid_json_content(self): assert_raises_with_msg( - ValueError, + DataError, "Loading JSON data failed: Expected dictionary, got list.", ModelObject.from_json, '["bad"]' ) @@ -198,7 +199,7 @@ def test_write_to_path(self): def test_invalid_output(self): assert_raises_with_msg(TypeError, - "Output should be None, open file or path, got integer.", + "Output should be None, path or open file, got integer.", Example().to_json, 42) From ef9de4bc892fb8c4a5798db2fd025dac23d984d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 25 May 2023 01:12:10 +0300 Subject: [PATCH 0576/1592] Typing tuning. Most importantly, use typing.TextIO instead of io.IOBase in type hints. --- src/robot/model/modelobject.py | 19 +++++++++---------- src/robot/parsing/model/blocks.py | 7 +++---- src/robot/utils/filereader.py | 8 ++++---- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index 027fc7c4250..2b5c46220c7 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -15,9 +15,8 @@ import copy import json -from io import IOBase from pathlib import Path -from typing import Any, Dict, overload, Type, TypeVar +from typing import Any, Dict, overload, TextIO, Type, TypeVar from robot.errors import DataError from robot.utils import get_error_message, SetterAwareType, type_name @@ -44,7 +43,7 @@ def from_dict(cls: Type[T], data: DataDict) -> T: f"failed: {err}") @classmethod - def from_json(cls: Type[T], source: 'str|bytes|IOBase|Path') -> T: + def from_json(cls: Type[T], source: 'str|bytes|TextIO|Path') -> T: """Create this object based on JSON data. The data is given as the ``source`` parameter. It can be: @@ -79,11 +78,11 @@ def to_json(self, file: None = None, *, ensure_ascii: bool = False, ... @overload - def to_json(self, file: 'IOBase|Path|str', *, ensure_ascii: bool = False, - indent: int = 0, separators: 'tuple[str, str]' = (',', ':')) -> str: + def to_json(self, file: 'TextIO|Path|str', *, ensure_ascii: bool = False, + indent: int = 0, separators: 'tuple[str, str]' = (',', ':')) -> None: ... - def to_json(self, file: 'None|IOBase|Path|str' = None, *, + def to_json(self, file: 'None|TextIO|Path|str' = None, *, ensure_ascii: bool = False, indent: int = 0, separators: 'tuple[str, str]' = (',', ':')) -> 'None|str': """Serialize this object into JSON. @@ -193,7 +192,7 @@ def full_name(obj_or_cls): class JsonLoader: - def load(self, source: 'str|bytes|IOBase|Path') -> DataDict: + def load(self, source: 'str|bytes|TextIO|Path') -> DataDict: try: data = self._load(source) except (json.JSONDecodeError, TypeError): @@ -227,14 +226,14 @@ def __init__(self, **config): self.config = config @overload - def dump(self, data: DataDict, output: None = None) -> 'str': + def dump(self, data: DataDict, output: None = None) -> str: ... @overload - def dump(self, data: DataDict, output: 'str|Path|IOBase') -> None: + def dump(self, data: DataDict, output: 'TextIO|Path|str') -> None: ... - def dump(self, data: DataDict, output: 'None|IOBase|Path|str' = None) -> 'None|str': + def dump(self, data: DataDict, output: 'None|TextIO|Path|str' = None) -> 'None|str': if not output: return json.dumps(data, **self.config) elif isinstance(output, (str, Path)): diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index e0e9b004ac4..0ae1d41d0f5 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -15,9 +15,8 @@ from abc import ABC from contextlib import contextmanager -from io import IOBase from pathlib import Path -from typing import cast, Iterator, Sequence, Union +from typing import cast, Iterator, Sequence, TextIO, Union from robot.utils import file_writer, test_or_task @@ -74,7 +73,7 @@ def __init__(self, sections: 'Sequence[Section]' = (), source: 'Path|None' = Non self.source = source self.languages = list(languages) - def save(self, output: 'Path|str|IOBase|None' = None): + def save(self, output: 'Path|str|TextIO|None' = None): """Save model to the given ``output`` or to the original source file. The ``output`` can be a path to a file or an already opened file @@ -387,7 +386,7 @@ def validate(self, ctx: 'ValidationContext'): class ModelWriter(ModelVisitor): - def __init__(self, output: 'Path|str|IOBase'): + def __init__(self, output: 'Path|str|TextIO'): if isinstance(output, (Path, str)): self.writer = file_writer(output) self.close_writer = True diff --git a/src/robot/utils/filereader.py b/src/robot/utils/filereader.py index 6bcd540a2e1..ce39819a047 100644 --- a/src/robot/utils/filereader.py +++ b/src/robot/utils/filereader.py @@ -14,14 +14,14 @@ # limitations under the License. from collections.abc import Iterator -from io import IOBase, StringIO +from io import StringIO from pathlib import Path -from typing import Union +from typing import TextIO, Union from .robottypes import is_bytes, is_pathlike, is_string -Source = Union[Path, str, IOBase] +Source = Union[Path, str, TextIO] class FileReader: # FIXME: Rename to SourceReader @@ -46,7 +46,7 @@ class FileReader: # FIXME: Rename to SourceReader def __init__(self, source: Source, accept_text: bool = False): self.file, self._opened = self._get_file(source, accept_text) - def _get_file(self, source: Source, accept_text: bool) -> 'tuple[IOBase, bool]': + def _get_file(self, source: Source, accept_text: bool) -> 'tuple[TextIO, bool]': path = self._get_path(source, accept_text) if path: file = open(path, 'rb') From a69c702705fd260a36ce472909819e4c4e5907b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 25 May 2023 01:12:46 +0300 Subject: [PATCH 0577/1592] Problems have been resolved and we are Python 3.12 compatible. Fixes #4771. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index d60c3307974..8eb6bc4daa2 100755 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 +Programming Language :: Python :: 3.12 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy Topic :: Software Development :: Testing From b15baa20d57bb7aa828e0a9157c9007a47b9da1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 29 May 2023 10:44:33 +0300 Subject: [PATCH 0578/1592] Enhance `--files` to work with file paths and directories. #4687 After this change the sematics are: - File name like `example.robot` is matched agains parsed file names as earlier. This includes support for glob patters. - It is possible to specify files also using paths. Also paths support glob patterns, including recursive globs using the `**` syntax. - If `--files` is used with a directory path, all files inside the specified directories are included, recursively. After these changes we probably want to change `--files` to something like `--parse-only`. --- atest/robot/cli/runner/files.robot | 31 ++++++++------ src/robot/model/__init__.py | 2 +- src/robot/model/namepatterns.py | 9 ---- src/robot/parsing/suitestructure.py | 58 ++++++++++++++++++++++---- utest/parsing/test_suitestructure.py | 62 ++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 utest/parsing/test_suitestructure.py diff --git a/atest/robot/cli/runner/files.robot b/atest/robot/cli/runner/files.robot index f3dd2472610..feb9e7f0d7e 100644 --- a/atest/robot/cli/runner/files.robot +++ b/atest/robot/cli/runner/files.robot @@ -2,24 +2,29 @@ Test Template Expected number of tests should be run Resource atest_resource.robot -*** Variables *** -${DATA FORMATS} ${DATADIR}/parsing/data_formats - *** Test Cases *** -Simple filename - -f sample.robot 18 +File name + -f sample.robot 18 + +File path + -f ${DATADIR}/parsing/data_formats${/}robot${/}SAMPLE.robot 18 + +Pattern with name + --files *.robot --files sample.rb? 47 -Pattern - --files *.robot 29 +Pattern with path + -f ${DATADIR}/parsing/data_formats/*/[st]???le.ROBOT 18 -Multiple patterns - --files sample.robot --files tests.robot 27 +Recursive glob + -f ${DATADIR}/**/sample.robot 18 + -f ${DATADIR}/*/sample.robot --run-empty-suite 0 -Combine extension and files - --files sample.rst -F rst 18 +Directories are recursive + -f ${DATADIR}/parsing/data_formats/robot 20 + -f ${DATADIR}/parsing/data_formats/r*t -F robot:rst 40 *** Keywords *** Expected number of tests should be run - [Arguments] ${options} ${expected}=0 - Run Tests ${options} ${DATA FORMATS} + [Arguments] ${options} ${expected} + Run Tests ${options} ${DATADIR}/parsing/data_formats Should Be Equal As Integers ${SUITE.test_count} ${expected} diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index fc6a2713b91..9e78b7cd9ba 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -34,7 +34,7 @@ from .message import Message, Messages from .modelobject import DataDict, ModelObject from .modifier import ModelModifier -from .namepatterns import SuiteNamePatterns, TestNamePatterns, FileNamePatterns +from .namepatterns import SuiteNamePatterns, TestNamePatterns from .statistics import Statistics from .tags import Tags, TagPattern, TagPatterns from .testcase import TestCase, TestCases diff --git a/src/robot/model/namepatterns.py b/src/robot/model/namepatterns.py index 42c19aa91a3..b00764b934c 100644 --- a/src/robot/model/namepatterns.py +++ b/src/robot/model/namepatterns.py @@ -55,12 +55,3 @@ class TestNamePatterns(NamePatterns): def _match_longname(self, name): return self._match(name) - - -class FileNamePatterns(NamePatterns): - - def __init__(self, patterns: Sequence[str] = ()): - super().__init__(patterns, ignore='') - - def _match_longname(self, name): - return self._match(name) diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 99037f2e294..3a57f926074 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -13,12 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import fnmatch +import os.path +import re from abc import ABC, abstractmethod from pathlib import Path from typing import Iterable, Iterator, Sequence from robot.errors import DataError -from robot.model import FileNamePatterns from robot.output import LOGGER from robot.utils import get_error_message @@ -134,8 +136,7 @@ class SuiteStructureBuilder: def __init__(self, extensions: Sequence[str] = ('.robot', '.rbt'), included_files: Sequence[str] = ()): self.extensions = ValidExtensions(extensions) - self.included_files = None if not included_files else \ - FileNamePatterns(included_files) + self.included_files = IncludedFiles(included_files) def _create_included_suites(self, included_suites): for suite in included_suites: @@ -168,11 +169,6 @@ def _build_directory(self, path: Path) -> SuiteStructure: LOGGER.info(f"Ignoring file or directory '{item}'.") return structure - def _is_file_included(self, name, included_files): - if not included_files: - return True - return included_files.match(name) - def _list_dir(self, path: Path) -> 'list[Path]': try: return sorted(path.iterdir(), key=lambda p: p.name.lower()) @@ -193,7 +189,7 @@ def _is_included(self, path: Path) -> bool: return False if not self.extensions.match(path): return False - return self._is_file_included(path.name, self.included_files) + return self.included_files.match(path) def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: structure = SuiteDirectory(self.extensions) @@ -205,3 +201,47 @@ def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: else: structure.add(self._build(path)) return structure + + +class IncludedFiles: + + def __init__(self, patterns: 'Sequence[str|Path]' = ()): + self.patterns = [self._compile(i) for i in patterns] + + def _compile(self, pattern: 'str|Path') -> 're.Pattern': + pattern = self._dir_to_recursive(self._path_to_abs(self._normalize(pattern))) + # Handle recursive glob patterns. + parts = [self._translate(p) for p in pattern.split('**')] + return re.compile('.*'.join(parts), re.IGNORECASE) + + def _normalize(self, pattern: 'str|Path') -> str: + if isinstance(pattern, Path): + pattern = str(pattern) + return os.path.normpath(pattern).replace('\\', '/') + + def _path_to_abs(self, pattern: str) -> str: + if '/' in pattern or '.' not in pattern or os.path.exists(pattern): + pattern = os.path.abspath(pattern) + return pattern + + def _dir_to_recursive(self, pattern: str) -> str: + if '.' not in os.path.basename(pattern) or os.path.isdir(pattern): + pattern += '/**' + return pattern + + def _translate(self, glob_pattern: str) -> str: + # `fnmatch.translate` returns pattern in format `(?s:<pattern>)\Z` but we want + # only the `<pattern>` part. This is a bit risky because the format may change + # in future Python versions, but we have tests and ought to notice that. + re_pattern = fnmatch.translate(glob_pattern)[4:-3] + # Unlike `fnmatch`, we want `*` to match only a single path segment. + return re_pattern.replace('.*', '[^/]*') + + def match(self, path: Path) -> bool: + if not self.patterns: + return True + return self._match(path.name) or self._match(str(path)) + + def _match(self, path: str) -> bool: + path = self._normalize(path) + return any(p.fullmatch(path) for p in self.patterns) diff --git a/utest/parsing/test_suitestructure.py b/utest/parsing/test_suitestructure.py new file mode 100644 index 00000000000..2bac70d56d2 --- /dev/null +++ b/utest/parsing/test_suitestructure.py @@ -0,0 +1,62 @@ +import unittest +from pathlib import Path + +from robot.parsing.suitestructure import IncludedFiles +from robot.utils.asserts import assert_equal + + +class TestIncludedFiles(unittest.TestCase): + + def test_match_when_no_patterns(self): + self._test_match() + + def test_match_name(self): + self._test_match('match.robot') + self._test_match('no_match.robot', match=False) + + def test_match_path(self): + self._test_match(Path('match.robot').absolute()) + self._test_match(Path('no_match.robot').absolute(), match=False) + + def test_match_relative_path(self): + self._test_match('test/match.robot', path='test/match.robot') + + def test_glob_name(self): + self._test_match('*.robot') + self._test_match('[mp]???h.robot') + self._test_match('no_*.robot', match=False) + + def test_glob_path(self): + self._test_match(Path('*.r?b?t').absolute()) + self._test_match(Path('../*/match.r?b?t').absolute()) + self._test_match(Path('../*/match.r?b?t')) + self._test_match(Path('*/match.r?b?t'), path='test/match.robot') + self._test_match(Path('no_*.robot').absolute(), match=False) + + def test_recursive_glob(self): + self._test_match(Path('../../**/match.robot').absolute()) + self._test_match(Path('../../*/match.robot').absolute(), match=False) + + def test_case_normalize(self): + self._test_match('MATCH.robot') + self._test_match(Path('match.robot').absolute(), path='MATCH.ROBOT') + + def test_sep_normalize(self): + self._test_match(str(Path('match.robot').absolute()).replace('\\', '/')) + + def test_directories_are_recursive(self): + self._test_match('.') + self._test_match('test', path='test/match.robot') + self._test_match('test', path='test/x/y/x/match.robot') + self._test_match('*', path='test/match.robot') + + def _test_match(self, pattern=None, path='match.robot', match=True): + patterns = [pattern] if pattern else [] + path = Path(path).absolute() + assert_equal(IncludedFiles(patterns).match(path), match) + if pattern: + assert_equal(IncludedFiles(['no', 'match', pattern]).match(path), match) + + +if __name__ == '__main__': + unittest.main() From 79ffaa7f8dbba5ff15efbf4bc7492f83353b2746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 29 May 2023 14:08:21 +0300 Subject: [PATCH 0579/1592] Fix --files with relative paths on Windows --- src/robot/parsing/suitestructure.py | 2 +- utest/parsing/test_suitestructure.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 3a57f926074..608212fc3a7 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -221,7 +221,7 @@ def _normalize(self, pattern: 'str|Path') -> str: def _path_to_abs(self, pattern: str) -> str: if '/' in pattern or '.' not in pattern or os.path.exists(pattern): - pattern = os.path.abspath(pattern) + pattern = os.path.abspath(pattern).replace('\\', '/') return pattern def _dir_to_recursive(self, pattern: str) -> str: diff --git a/utest/parsing/test_suitestructure.py b/utest/parsing/test_suitestructure.py index 2bac70d56d2..c5993b06ae4 100644 --- a/utest/parsing/test_suitestructure.py +++ b/utest/parsing/test_suitestructure.py @@ -34,8 +34,8 @@ def test_glob_path(self): self._test_match(Path('no_*.robot').absolute(), match=False) def test_recursive_glob(self): - self._test_match(Path('../../**/match.robot').absolute()) - self._test_match(Path('../../*/match.robot').absolute(), match=False) + self._test_match('x/**/match.robot', path='x/y/z/match.robot') + self._test_match('x/*/match.robot', path='x/y/z/match.robot', match=False) def test_case_normalize(self): self._test_match('MATCH.robot') From 3302535d83b73f6ca7462acd58431865f9fd3a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 29 May 2023 15:52:27 +0300 Subject: [PATCH 0580/1592] Rename `--files` to `--parse-include` #4687 User Guide still needs to be updated. --- atest/robot/cli/runner/files.robot | 30 --------------------- atest/robot/cli/runner/included_files.robot | 30 +++++++++++++++++++++ src/robot/conf/settings.py | 12 ++++----- src/robot/run.py | 12 +++++---- src/robot/running/builder/builders.py | 4 +-- 5 files changed, 45 insertions(+), 43 deletions(-) delete mode 100644 atest/robot/cli/runner/files.robot create mode 100644 atest/robot/cli/runner/included_files.robot diff --git a/atest/robot/cli/runner/files.robot b/atest/robot/cli/runner/files.robot deleted file mode 100644 index feb9e7f0d7e..00000000000 --- a/atest/robot/cli/runner/files.robot +++ /dev/null @@ -1,30 +0,0 @@ -*** Settings *** -Test Template Expected number of tests should be run -Resource atest_resource.robot - -*** Test Cases *** -File name - -f sample.robot 18 - -File path - -f ${DATADIR}/parsing/data_formats${/}robot${/}SAMPLE.robot 18 - -Pattern with name - --files *.robot --files sample.rb? 47 - -Pattern with path - -f ${DATADIR}/parsing/data_formats/*/[st]???le.ROBOT 18 - -Recursive glob - -f ${DATADIR}/**/sample.robot 18 - -f ${DATADIR}/*/sample.robot --run-empty-suite 0 - -Directories are recursive - -f ${DATADIR}/parsing/data_formats/robot 20 - -f ${DATADIR}/parsing/data_formats/r*t -F robot:rst 40 - -*** Keywords *** -Expected number of tests should be run - [Arguments] ${options} ${expected} - Run Tests ${options} ${DATADIR}/parsing/data_formats - Should Be Equal As Integers ${SUITE.test_count} ${expected} diff --git a/atest/robot/cli/runner/included_files.robot b/atest/robot/cli/runner/included_files.robot new file mode 100644 index 00000000000..247d8e8be63 --- /dev/null +++ b/atest/robot/cli/runner/included_files.robot @@ -0,0 +1,30 @@ +*** Settings *** +Test Template Expected number of tests should be run +Resource atest_resource.robot + +*** Test Cases *** +File name + --parseinclude sample.robot 18 + +File path + --ParseI ${DATADIR}/parsing/data_formats${/}robot${/}SAMPLE.robot 18 + +Pattern with name + --ParseInclude *.robot --parse-include sample.rb? 47 + +Pattern with path + --parse-include ${DATADIR}/parsing/data_formats/*/[st]???le.ROBOT 18 + +Recursive glob + --parse-include ${DATADIR}/**/sample.robot 18 + --parse-include ${DATADIR}/*/sample.robot --run-empty-suite 0 + +Directories are recursive + --parse-include ${DATADIR}/parsing/data_formats/robot 20 + --parse-include ${DATADIR}/parsing/data_formats/r*t -F robot:rst 40 + +*** Keywords *** +Expected number of tests should be run + [Arguments] ${options} ${expected} + Run Tests ${options} ${DATADIR}/parsing/data_formats + Should Be Equal As Integers ${SUITE.test_count} ${expected} diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index aab66ede221..405004af76c 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -42,7 +42,7 @@ class _BaseSettings: 'TestNames' : ('test', []), 'TaskNames' : ('task', []), 'SuiteNames' : ('suite', []), - 'FilePatterns' : ('files', []), + 'ParseInclude' : ('parseinclude', []), 'SetTag' : ('settag', []), 'Include' : ('include', []), 'Exclude' : ('exclude', []), @@ -393,10 +393,6 @@ def split_log(self): def suite_names(self): return self._filter_empty(self['SuiteNames']) - @property - def file_patterns(self): - return self._filter_empty(self['FilePatterns']) - def _filter_empty(self, items): return [i for i in items if i] or None @@ -412,6 +408,10 @@ def include(self): def exclude(self): return self._filter_empty(self['Exclude']) + @property + def parse_include(self): + return self['ParseInclude'] + @property def pythonpath(self): return self['PythonPath'] @@ -489,7 +489,7 @@ class RobotSettings(_BaseSettings): def get_rebot_settings(self): settings = RebotSettings() settings.start_timestamp = self.start_timestamp - not_copied = {'Include', 'Exclude', 'TestNames', 'SuiteNames', 'FilePatterns', + not_copied = {'Include', 'Exclude', 'TestNames', 'SuiteNames', 'ParseInclude', 'Name', 'Doc', 'Metadata', 'SetTag', 'Output', 'LogLevel', 'TimestampOutputs'} for opt in settings._opts: diff --git a/src/robot/run.py b/src/robot/run.py index 22c4499c310..90598994e01 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -96,10 +96,12 @@ extension is needed, separate them with a colon. Examples: `--extension txt`, `--extension robot:txt` Only `*.robot` files are parsed by default. - -f --files pattern * Parse only files with a name that matches one of the - specified patterns when executing a directory. - Has no effect when running individual files or when - using resource files. + --parseinclude pattern * Parse only files matching `pattern`. It can be: + - a file name or pattern like `example.robot` or + `*.robot` to parse all files matching that name, + - a file path like `path/to/example.robot`, or + - a directory path like `path/to/example` to parse + all files in that directory, recursively. -N --name name Set the name of the top level suite. By default the name is created based on the executed file or directory. @@ -436,7 +438,7 @@ def main(self, datasources, **options): if settings.pythonpath: sys.path = settings.pythonpath + sys.path builder = TestSuiteBuilder(included_extensions=settings.extension, - included_files=settings.file_patterns, + included_files=settings.parse_include, custom_parsers=settings.parsers, rpa=settings.rpa, lang=settings.languages, diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 5bd1a1980e0..ceea6fc9fa0 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -74,8 +74,8 @@ def __init__(self, included_suites: str = 'DEPRECATED', :param included_extensions: List of extensions of files to parse. Same as ``--extension``. :param included_files: - List of filename patterns to include. If no files are specified, all - files are parsed. Same as `--files`. New in RF 6.1. + List of names, paths or directory paths of files to parse. All files + are parsed by default. Same as `--parse-include`. New in RF 6.1. :param custom_parsers: Custom parsers as names or paths (same as ``--parser``) or as parser objects. New in RF 6.1. From e77f4f57e6d8306726d76e33401bd674fe730678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 29 May 2023 19:17:44 +0300 Subject: [PATCH 0581/1592] Remove dead code, add TODO --- src/robot/parsing/suitestructure.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 608212fc3a7..62441191d26 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -138,13 +138,6 @@ def __init__(self, extensions: Sequence[str] = ('.robot', '.rbt'), self.extensions = ValidExtensions(extensions) self.included_files = IncludedFiles(included_files) - def _create_included_suites(self, included_suites): - for suite in included_suites: - yield suite - while '.' in suite: - suite = suite.split('.', 1)[1] - yield suite - def build(self, *paths: Path) -> SuiteStructure: if len(paths) == 1: return self._build(paths[0]) @@ -160,6 +153,7 @@ def _build_directory(self, path: Path) -> SuiteStructure: for item in self._list_dir(path): if self._is_init_file(item): if structure.init_file: + # TODO: This error should fail parsing for good. LOGGER.error(f"Ignoring second test suite init file '{item}'.") else: structure.init_file = item From 4979535c861cc60ab37a8fc63e002aee185fe87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 29 May 2023 21:14:58 +0300 Subject: [PATCH 0582/1592] Enhance `--files` so that matching files are always parsed. Earlier `--files *.rst` required using also `--extension rst`. Now the former is enough on its own. Part of #4687. --- atest/robot/cli/runner/included_files.robot | 15 +++-- atest/robot/parsing/custom_parsers.robot | 4 +- .../testdata/parsing/custom/do_not_parse.ext | 1 + src/robot/parsing/suitestructure.py | 61 ++++++++++--------- src/robot/running/builder/builders.py | 10 ++- 5 files changed, 55 insertions(+), 36 deletions(-) create mode 100644 atest/testdata/parsing/custom/do_not_parse.ext diff --git a/atest/robot/cli/runner/included_files.robot b/atest/robot/cli/runner/included_files.robot index 247d8e8be63..a1ddd221895 100644 --- a/atest/robot/cli/runner/included_files.robot +++ b/atest/robot/cli/runner/included_files.robot @@ -15,16 +15,23 @@ Pattern with name Pattern with path --parse-include ${DATADIR}/parsing/data_formats/*/[st]???le.ROBOT 18 -Recursive glob +Single '*' is not recursive + --parse-include ${DATADIR}/*/sample.robot 0 + +Recursive glob requires '**' --parse-include ${DATADIR}/**/sample.robot 18 - --parse-include ${DATADIR}/*/sample.robot --run-empty-suite 0 Directories are recursive --parse-include ${DATADIR}/parsing/data_formats/robot 20 - --parse-include ${DATADIR}/parsing/data_formats/r*t -F robot:rst 40 + --parse-include ${DATADIR}/parsing/*/robot 20 + +Non-standard files matching patterns with extension are parsed + --parse-include *.rst 20 + --parse-include ${DATADIR}/parsing/**/*.rst 20 + --parse-include ${DATADIR}/parsing/data_formats/rest 0 *** Keywords *** Expected number of tests should be run [Arguments] ${options} ${expected} - Run Tests ${options} ${DATADIR}/parsing/data_formats + Run Tests ${options} --run-empty-suite ${DATADIR}/parsing/data_formats Should Be Equal As Integers ${SUITE.test_count} ${expected} diff --git a/atest/robot/parsing/custom_parsers.robot b/atest/robot/parsing/custom_parsers.robot index ea512d4823c..cc8bccad2ae 100644 --- a/atest/robot/parsing/custom_parsers.robot +++ b/atest/robot/parsing/custom_parsers.robot @@ -23,10 +23,10 @@ Directory with init Validate Directory Suite init=True Extension with multiple parts - Run Tests --parser ${DIR}/CustomParser.py:multi.part.ext ${DIR} + [Documentation] Also tests usage with `--parse-include`. + Run Tests --parser ${DIR}/CustomParser.py:multi.part.ext --parse-include *.multi.part.ext ${DIR} Validate Suite ${SUITE} Custom ${DIR} custom=False ... Passing=PASS - ... Test in Robot file=PASS Validate Suite ${SUITE.suites[0]} Tests ${DIR}/tests.multi.part.ext ... Passing=PASS diff --git a/atest/testdata/parsing/custom/do_not_parse.ext b/atest/testdata/parsing/custom/do_not_parse.ext new file mode 100644 index 00000000000..0f920498c23 --- /dev/null +++ b/atest/testdata/parsing/custom/do_not_parse.ext @@ -0,0 +1 @@ +'.multi.part.ext' files should be parsed but '.ext' files not. diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index 62441191d26..d14a9238f2e 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -25,36 +25,12 @@ from robot.utils import get_error_message -class ValidExtensions: - - def __init__(self, extensions: Iterable[str]): - self.extensions = {ext.lstrip('.').lower() for ext in extensions} - - def match(self, path: Path) -> bool: - for ext in self._extensions_from(path): - if ext in self.extensions: - return True - return False - - def get_extension(self, path: Path) -> str: - for ext in self._extensions_from(path): - if ext in self.extensions: - return ext - return path.suffix.lower()[1:] - - def _extensions_from(self, path: Path) -> Iterator[str]: - suffixes = path.suffixes - while suffixes: - yield ''.join(suffixes).lower()[1:] - suffixes.pop(0) - - class SuiteStructure(ABC): source: 'Path|None' init_file: 'Path|None' children: 'list[SuiteStructure]|None' - def __init__(self, extensions: ValidExtensions, source: 'Path|None', + def __init__(self, extensions: 'ValidExtensions', source: 'Path|None', init_file: 'Path|None' = None, children: 'Sequence[SuiteStructure]|None' = None): self._extensions = extensions @@ -79,7 +55,7 @@ def visit(self, visitor: 'SuiteStructureVisitor'): class SuiteFile(SuiteStructure): source: Path - def __init__(self, extensions: ValidExtensions, source: Path): + def __init__(self, extensions: 'ValidExtensions', source: Path): super().__init__(extensions, source) def _get_source_file(self) -> Path: @@ -92,7 +68,7 @@ def visit(self, visitor: 'SuiteStructureVisitor'): class SuiteDirectory(SuiteStructure): children: 'list[SuiteStructure]' - def __init__(self, extensions: ValidExtensions, source: 'Path|None' = None, + def __init__(self, extensions: 'ValidExtensions', source: 'Path|None' = None, init_file: 'Path|None' = None, children: Sequence[SuiteStructure] = ()): super().__init__(extensions, source, init_file, children) @@ -135,7 +111,7 @@ class SuiteStructureBuilder: def __init__(self, extensions: Sequence[str] = ('.robot', '.rbt'), included_files: Sequence[str] = ()): - self.extensions = ValidExtensions(extensions) + self.extensions = ValidExtensions(extensions, included_files) self.included_files = IncludedFiles(included_files) def build(self, *paths: Path) -> SuiteStructure: @@ -197,6 +173,35 @@ def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: return structure +class ValidExtensions: + + def __init__(self, extensions: Sequence[str], + included_files: Sequence[str] = ()): + self.extensions = {ext.lstrip('.').lower() for ext in extensions} + for pattern in included_files: + ext = os.path.splitext(pattern)[1] + if ext: + self.extensions.add(ext.lstrip('.').lower()) + + def match(self, path: Path) -> bool: + for ext in self._extensions_from(path): + if ext in self.extensions: + return True + return False + + def get_extension(self, path: Path) -> str: + for ext in self._extensions_from(path): + if ext in self.extensions: + return ext + return path.suffix.lower()[1:] + + def _extensions_from(self, path: Path) -> Iterator[str]: + suffixes = path.suffixes + while suffixes: + yield ''.join(suffixes).lower()[1:] + suffixes.pop(0) + + class IncludedFiles: def __init__(self, patterns: 'Sequence[str|Path]' = ()): diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index ceea6fc9fa0..9720bf3475f 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -177,12 +177,18 @@ def _get_parsers(self, paths: 'Sequence[Path]') -> 'dict[str|None, Parser]': parsers = {None: NoInitFileDirectoryParser(), **self.custom_parsers} robot_parser = self.standard_parsers['robot'] for ext in chain(self.included_extensions, - [p.suffix for p in paths if p.is_file()]): + [self._get_ext(pattern) for pattern in self.included_files], + [self._get_ext(pth) for pth in paths if pth.is_file()]): ext = ext.lstrip('.').lower() - if ext not in parsers: + if ext.isalnum() and ext not in parsers: parsers[ext] = self.standard_parsers.get(ext, robot_parser) return parsers + def _get_ext(self, path: 'str|Path') -> str: + if not isinstance(path, Path): + path = Path(path) + return ''.join(path.suffixes) + def _validate_not_empty(self, suite: TestSuite, multi_source: bool = False): if multi_source: for child in suite.suites: From 07fc644538c50361d8972087eb4679f9ecf9b8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 29 May 2023 22:57:06 +0300 Subject: [PATCH 0583/1592] Parse `.robot.rst` files automatically. Fixes #4777. --- atest/robot/cli/runner/included_files.robot | 2 +- atest/robot/parsing/data_formats/rest.robot | 6 ++++++ .../{sub_suite2.rst => sub_suite2.robot.rst} | 0 .../src/CreatingTestData/TestDataSyntax.rst | 13 +++++++++---- src/robot/conf/settings.py | 2 +- src/robot/model/testsuite.py | 6 +++--- src/robot/parsing/suitestructure.py | 2 +- src/robot/running/builder/builders.py | 5 +++-- src/robot/running/builder/parsers.py | 5 ++++- 9 files changed, 28 insertions(+), 13 deletions(-) rename atest/testdata/parsing/data_formats/rest/with_init/{sub_suite2.rst => sub_suite2.robot.rst} (100%) diff --git a/atest/robot/cli/runner/included_files.robot b/atest/robot/cli/runner/included_files.robot index a1ddd221895..2439a731b8a 100644 --- a/atest/robot/cli/runner/included_files.robot +++ b/atest/robot/cli/runner/included_files.robot @@ -28,7 +28,7 @@ Directories are recursive Non-standard files matching patterns with extension are parsed --parse-include *.rst 20 --parse-include ${DATADIR}/parsing/**/*.rst 20 - --parse-include ${DATADIR}/parsing/data_formats/rest 0 + --parse-include ${DATADIR}/parsing/data_formats/rest 1 *** Keywords *** Expected number of tests should be run diff --git a/atest/robot/parsing/data_formats/rest.robot b/atest/robot/parsing/data_formats/rest.robot index 7120a31e463..7134e09b14b 100644 --- a/atest/robot/parsing/data_formats/rest.robot +++ b/atest/robot/parsing/data_formats/rest.robot @@ -17,3 +17,9 @@ ReST Directory Directory With reST Init Previous Run Should Have Been Successful Check Suite With Init ${SUITE.suites[1]} + +'.robot.rst' files are parsed automatically + Run Tests ${EMPTY} ${RESTDIR}/with_init + Should Be Equal ${SUITE.name} With Init + Should Be Equal ${SUITE.suites[0].name} Sub Suite2 + Should Contain Tests ${SUITE} Suite2 Test diff --git a/atest/testdata/parsing/data_formats/rest/with_init/sub_suite2.rst b/atest/testdata/parsing/data_formats/rest/with_init/sub_suite2.robot.rst similarity index 100% rename from atest/testdata/parsing/data_formats/rest/with_init/sub_suite2.rst rename to atest/testdata/parsing/data_formats/rest/with_init/sub_suite2.robot.rst diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 812dc7a839a..4e541966cc7 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -294,10 +294,12 @@ marked using the `code` directive, but Robot Framework supports also def example(): print('Hello, world!') -Robot Framework supports reStructuredText files using both :file:`.rst` and -:file:`.rest` extension. When executing a directory containing reStucturedText -files, the :option:`--extension` option must be used to explicitly tell that -`these files should be parsed`__. +Robot Framework supports reStructuredText files using :file:`.robot.rst`, +:file:`.rst` and :file:`.rest` extensions. To avoid parsing unrelated +reStructuredText files, only files with the :file:`.robot.rst` extension +are parsed by default when executing a directory. Parsing files with +other extensions `can be enabled`__ by using either :option:`--parseinclude` +or :option:`--extension` option. __ `Selecting files to parse`_ @@ -306,6 +308,9 @@ When Robot Framework parses reStructuredText files, errors below level and other such markup. This may hide also real errors, but they can be seen when processing files using reStructuredText tooling normally. +.. note:: Parsing :file:`.robot.rst` files automatically is new in + Robot Framework 6.1. + Rules for parsing the data -------------------------- diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index 405004af76c..a8b2f2fbd35 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -457,7 +457,7 @@ def rpa(self, value): class RobotSettings(_BaseSettings): - _extra_cli_opts = {'Extension' : ('extension', ('.robot', '.rbt')), + _extra_cli_opts = {'Extension' : ('extension', ('.robot', '.rbt', '.robot.rst')), 'Output' : ('output', 'output.xml'), 'LogLevel' : ('loglevel', 'INFO'), 'MaxErrorLines' : ('maxerrorlines', 40), diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 0571277133d..37586d1ab14 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -71,14 +71,14 @@ def name_from_source(source: 'Path|str|None', extension: Sequence[str] = ()) -> External parsers and other tools that want to produce suites with names matching names created by Robot Framework can use this method as well. This method is also used if :attr:`name` is not set and someone - accessess it. + accesses it. The algorithm is as follows: - If the source is ``None`` or empty, return an empty string. - Get the base name of the source. Read more below. - Remove possible prefix separated with ``__``. - - Convert underscrores to spaces. + - Convert underscores to spaces. - If the name is all lower case, title case it. The base name of files is got by calling `Path.stem`__ that drops @@ -115,7 +115,7 @@ def _get_base_name(path: Path, extensions: Sequence[str]) -> str: extensions = [extensions] for ext in extensions: ext = '.' + ext.lower().lstrip('.') - if path.name.endswith(ext): + if path.name.lower().endswith(ext): return path.name[:-len(ext)] raise ValueError(f"File '{path}' does not have extension " f"{seq2str(extensions, lastsep=' or ')}.") diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py index d14a9238f2e..d4572c2cb4b 100644 --- a/src/robot/parsing/suitestructure.py +++ b/src/robot/parsing/suitestructure.py @@ -109,7 +109,7 @@ class SuiteStructureBuilder: ignored_prefixes = ('_', '.') ignored_dirs = ('CVS',) - def __init__(self, extensions: Sequence[str] = ('.robot', '.rbt'), + def __init__(self, extensions: Sequence[str] = ('.robot', '.rbt', '.robot.rst'), included_files: Sequence[str] = ()): self.extensions = ValidExtensions(extensions, included_files) self.included_files = IncludedFiles(included_files) diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 9720bf3475f..2ece0ff2210 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -57,7 +57,7 @@ class TestSuiteBuilder: """ def __init__(self, included_suites: str = 'DEPRECATED', - included_extensions: Sequence[str] = ('.robot', '.rbt'), + included_extensions: Sequence[str] = ('.robot', '.rbt', '.robot.rst'), included_files: Sequence[str] = (), custom_parsers: Sequence[str] = (), defaults: 'TestDefaults|None' = None, @@ -122,6 +122,7 @@ def _get_standard_parsers(self, lang: LanguagesLike, 'robot': robot_parser, 'rst': rest_parser, 'rest': rest_parser, + 'robot.rst': rest_parser, 'rbt': json_parser, 'json': json_parser } @@ -180,7 +181,7 @@ def _get_parsers(self, paths: 'Sequence[Path]') -> 'dict[str|None, Parser]': [self._get_ext(pattern) for pattern in self.included_files], [self._get_ext(pth) for pth in paths if pth.is_file()]): ext = ext.lstrip('.').lower() - if ext.isalnum() and ext not in parsers: + if ext not in parsers and ext.replace('.', '').isalnum(): parsers[ext] = self.standard_parsers.get(ext, robot_parser) return parsers diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index b15ff002729..b21c9ee8573 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -44,6 +44,7 @@ def parse_resource_file(self, source: Path) -> ResourceFile: class RobotParser(Parser): + extensions = () def __init__(self, lang: LanguagesLike = None, process_curdir: bool = True): self.lang = lang @@ -52,7 +53,8 @@ def __init__(self, lang: LanguagesLike = None, process_curdir: bool = True): def parse_suite_file(self, source: Path, defaults: TestDefaults) -> TestSuite: model = get_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) - suite = TestSuite(name=TestSuite.name_from_source(source), source=source) + suite = TestSuite(name=TestSuite.name_from_source(source, self.extensions), + source=source) SuiteBuilder(suite, FileSettings(defaults)).build(model) return suite @@ -85,6 +87,7 @@ def parse_resource_file(self, source: Path) -> ResourceFile: class RestParser(RobotParser): + extensions = ('.robot.rst', '.rst', '.rest') def _get_source(self, source: Path) -> str: with FileReader(source) as reader: From 0d1b2fca03a76c1a1385076ab3f2d0fb749d6122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 29 May 2023 23:34:18 +0300 Subject: [PATCH 0584/1592] Add `-I` short option to `--parseinclude`. #4687 --- atest/robot/cli/runner/included_files.robot | 4 ++-- src/robot/run.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/atest/robot/cli/runner/included_files.robot b/atest/robot/cli/runner/included_files.robot index 2439a731b8a..681442d40b1 100644 --- a/atest/robot/cli/runner/included_files.robot +++ b/atest/robot/cli/runner/included_files.robot @@ -7,10 +7,10 @@ File name --parseinclude sample.robot 18 File path - --ParseI ${DATADIR}/parsing/data_formats${/}robot${/}SAMPLE.robot 18 + -I ${DATADIR}/parsing/data_formats${/}robot${/}SAMPLE.robot 18 Pattern with name - --ParseInclude *.robot --parse-include sample.rb? 47 + --ParseInclude *.robot --parse-include sample.rb? -I no.match 47 Pattern with path --parse-include ${DATADIR}/parsing/data_formats/*/[st]???le.ROBOT 18 diff --git a/src/robot/run.py b/src/robot/run.py index 90598994e01..91f04ec2cac 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -96,7 +96,7 @@ extension is needed, separate them with a colon. Examples: `--extension txt`, `--extension robot:txt` Only `*.robot` files are parsed by default. - --parseinclude pattern * Parse only files matching `pattern`. It can be: + -I --parseinclude pattern * Parse only files matching `pattern`. It can be: - a file name or pattern like `example.robot` or `*.robot` to parse all files matching that name, - a file path like `path/to/example.robot`, or From 0194c377639a229ae3f7831583c0fa07f5075e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 29 May 2023 23:36:27 +0300 Subject: [PATCH 0585/1592] Fix example in --help --- src/robot/run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/run.py b/src/robot/run.py index 91f04ec2cac..869615e4850 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -157,9 +157,9 @@ given without `${}`. See --variablefile for a more powerful variable setting mechanism. Examples: - --variable str:Hello => ${str} = `Hello` - -v hi:Hi_World -E space:_ => ${hi} = `Hi World` - -v x: -v y:42 => ${x} = ``, ${y} = `42` + --variable name:Robot => ${name} = `Robot` + -v "hello:Hello world" => ${hello} = `Hello world` + -v x: -v y:42 => ${x} = ``, ${y} = `42` -V --variablefile path * Python or YAML file file to read variables from. Possible arguments to the variable file can be given after the path using colon or semicolon as separator. From ad62da71080b1dbfa67c2bf52e6d02076a0c2636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 30 May 2023 00:32:12 +0300 Subject: [PATCH 0586/1592] Avoid deprecation warning with Python 3.12 --- doc/userguide/ug2html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/userguide/ug2html.py b/doc/userguide/ug2html.py index f041dab9f57..73deecdf7ac 100755 --- a/doc/userguide/ug2html.py +++ b/doc/userguide/ug2html.py @@ -245,7 +245,7 @@ def copy(source, dest): print(f'Copying {source!r} -> {dest!r}') shutil.copy(source, dest) - link_regexp = re.compile(''' + link_regexp = re.compile(r''' (<(a|img)\s+.*?) (\s+(href|src)="(.*?)"|>) ''', re.VERBOSE | re.DOTALL | re.IGNORECASE) From a037d26c4eada37b4273be312b5bed7c1d0c6f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 30 May 2023 00:32:59 +0300 Subject: [PATCH 0587/1592] Minor UG style tuning --- doc/userguide/src/userguide.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/userguide/src/userguide.css b/doc/userguide/src/userguide.css index 6e12221faec..165db9910e9 100644 --- a/doc/userguide/src/userguide.css +++ b/doc/userguide/src/userguide.css @@ -556,7 +556,7 @@ table.messages td.warn { /* Roles -- explained in roles.rst file */ -code, .codesc, .option { +code, .codesc, .option, .file { background: var(--code-bg); font-family: monospace; } From 4347a72e66b61825f93623022c0af66be611ceeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 30 May 2023 00:55:35 +0300 Subject: [PATCH 0588/1592] UG: Document `--parseinclude`. #4687 Also enhance the Selecting files to parse section in general. The section now covers also `.json` and `.rbt` extensions even though the JSON format (#3902) isn't otherwise documented yet. --- .../src/Appendices/CommandLineOptions.rst | 2 +- .../src/CreatingTestData/TestDataSyntax.rst | 5 + .../ConfiguringExecution.rst | 179 +++++++++++++----- 3 files changed, 137 insertions(+), 49 deletions(-) diff --git a/doc/userguide/src/Appendices/CommandLineOptions.rst b/doc/userguide/src/Appendices/CommandLineOptions.rst index 9bbf65627ea..4448201beb9 100644 --- a/doc/userguide/src/Appendices/CommandLineOptions.rst +++ b/doc/userguide/src/Appendices/CommandLineOptions.rst @@ -18,7 +18,7 @@ Command line options for test execution of a `built-in language <Translations_>`__, or a path or a module name of a custom language file. -F, --extension <value> `Parse only these files`_ when executing a directory. - -f, --files <pattern> `Parse only matching files`_ when executing a directory. + -I, --parseinclude <pattern> `Parse only matching files`_ when executing a directory. -N, --name <name> `Sets the name`_ of the top-level test suite. -D, --doc <document> `Sets the documentation`_ of the top-level test suite. -M, --metadata <name:value> `Sets free metadata`_ for the top level test suite. diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 4e541966cc7..6c9896d5dfd 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -311,6 +311,11 @@ when processing files using reStructuredText tooling normally. .. note:: Parsing :file:`.robot.rst` files automatically is new in Robot Framework 6.1. +JSON format +~~~~~~~~~~~ + +FIXME + Rules for parsing the data -------------------------- diff --git a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst index 5162dbd9f86..6bbc93175af 100644 --- a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst @@ -13,49 +13,138 @@ the next section. Selecting files to parse ------------------------ -When executing a single file, Robot Framework tries to parse and run it -regardless the name or the file extension. The file is expected to use the `plain text -format`__ or, if it has :file:`.rst` or :file:`.rest` extension, -the `reStructuredText format`_:: +Executing individual files +~~~~~~~~~~~~~~~~~~~~~~~~~~ - robot example.robot # Common case. - robot example.tsv # Must be compatible with the plain text format. - robot example.rst # reStructuredText format. +When executing individual files, Robot Framework tries to parse and run them +regardless the name or the file extension. What parser to use depends +on the extension: -__ `Supported file formats`_ +- :file:`.robot` files and files that are not recognized are parsed using + the normal `Robot Framework parser`__. +- :file:`.rst` and :file:`.rest` files are parsed using the `reStructuredText parser`__. +- :file:`.rbt` and :file:`.json` files are parsed using the `JSON parser`__. +- Files supported by `custom parsers`__ are parsed by a matching parser. -When executing a directory__, Robot Framework ignores all files and directories -starting with a dot (:file:`.`) or an underscore (:file:`_`) and, by default, -only parses files with the :file:`.robot` extension. If files use other -extensions, the :option:`--extension (-F)` option must be used to explicitly -tell the framework to parse also them. If there is a need to parse more -than one kind of files, it is possible to use a colon `:` to separate -extensions. Matching extensions is case insensitive and the leading `.` -can be omitted. You can additionally filter files before parsing using -the :option:`--files (-f)` option with a `simple pattern`_. The option -can be used multiple times to match multiple patterns. Arguments to the -:option:`--files (-f)` option are case- and space-insensitive. -If the :option:`--files (-f)` option matches files with a non-default extension, -the :option:`--extension (-F)` option must be added in order for those files -to also be parsed. -:: +Examples:: - robot path/to/tests/ # Parse only *.robot files. - robot --extension TSV path/to/tests # Parse only *.tsv files. - robot -F robot:rst path/to/tests # Parse *.robot and *.rst files. - robot --files foo.robot path/to/tests # Parse only files named foo.robot. - robot -f foo* path/to/tests # Parse only .robot files starting with foo. - robot -f foo* -F txt path/to/tests # Parse only .txt files starting with foo. + robot example.robot # Standard Robot Framework parser. + robot example.tsv # Must be compatible with the standard parser. + robot example.rst # reStructuredText parser. + robot x.robot y.rst # Parse both files using an appropriate parser. -If files in one format use different extensions like :file:`.rst` and -:file:`.rest`, they must be specified separately. Using just one of them -would mean that other files in that format are skipped. +__ `Supported file formats`_ +__ `reStructuredText format`_ +__ `JSON format`_ +__ `Using custom parsers`_ -.. note:: Prior to Robot Framework 3.1 also TXT, TSV and HTML files were - parsed by default. Starting from Robot Framework 3.2 HTML files - are not supported at all. +Included and excluded files +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When executing a directory__, files and directories are parsed using +the following rules: + +- All files and directories starting with a dot (:file:`.`) or an underscore + (:file:`_`) are ignored. +- :file:`.robot` files are parsed using the normal `Robot Framework parser`__. +- :file:`.robot.rst` files are parsed using the `reStructuredText parser`__. +- :file:`.rbt` files are parsed using the `JSON parser`__. +- Files supported by `custom parsers`__ are parsed by a matching parser. +- Other files are ignored unless parsing them has been enabled by using + the :option:`--parseinclude` or :option:`--extension` options discussed + in the subsequent sections. __ `Suite directories`_ +__ `Supported file formats`_ +__ `reStructuredText format`_ +__ `JSON format`_ +__ `Using custom parsers`_ + +Selecting files by name or path +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When executing a directory, it is possible to parse only certain files based on +their name or path by using the :option:`--parseinclude (-I)` option. This option +has slightly different semantics depending on the value it is used with: + +- If the value is just a file name like `example.robot`, files matching + the name in all directories will be parsed. + +- To match only a certain file in a certain directory, files can be given + as relative or absolute paths like `path/to/tests.robot`. + +- If the value is a path to a directory, all files inside that directory are parsed, + recursively. + +Examples:: + + robot --parseinclude example.robot tests # Parse `example.robot` files anywhere under `tests`. + robot -I example_*.robot -I ???.robot tests # Parse files matching `example_*.robot` or `???.robot` under `tests`. + robot -I tests/example.robot tests # Parse only `tests/example.robot`. + robot --parseinclude tests/example tests # Parse files under `tests/example` directory, recursively. + +Values used with :option:`--parseinclude` are case-insensitive and support +`glob patterns <Simple patterns_>`__ like `example_*.robot`. There are, however, +two small differences compared to how patterns typically work with Robot Framework: + +- `*` matches only a single path segment. For example, `path/*/tests.robot` + matches :file:`path/to/tests.robot` but not :file:`path/to/nested/tests.robot`. + +- `**` can be used to enable recursive matching. For example, `path/**/tests.robot` + matches both :file:`path/to/tests.robot` and :file:`path/to/nested/tests.robot`. + +If the pattern contains an extension, files with that extension are parsed +even if they by `default would not be`__. What parser to use depends on +the used extension: + +- :file:`.rst` and :file:`.rest` files are parsed using the `reStructuredText parser`__. +- :file:`.json` files are parsed using the `JSON parser`__. +- Other files are parsed using the normal `Robot Framework parser`__. + +Notice that when you use a pattern like `*.robot` and there exists a file that +matches the pattern in the execution directory, the shell may resolve +the pattern before Robot Framework is called and the value passed to +it is the file name, not the original pattern. In such cases you need +to quote or escape the pattern like `'*.robot'` or `\*.robot`. + +__ `Included and excluded files`_ +__ `reStructuredText format`_ +__ `JSON format`_ +__ `Supported file formats`_ + +.. note:: `--parseinclude` is new in Robot Framework 6.1. + +Selecting files by extension +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In addition to using the :option:`--parseinclude` option discussed in the +previous section, it is also possible to enable parsing files that are `not +parsed by default`__ by using the :option:`--extension (-F)` option. +Matching extensions is case insensitive and the leading dot can be omitted. +If there is a need to parse more than one kind of files, it is possible to +use a colon `:` to separate extensions:: + + robot --extension rst path/to/tests # Parse only *.rst files. + robot -F robot:rst path/to/tests # Parse *.robot and *.rst files. + +The above is equivalent to the following :option:`--parseinclude` usage:: + + robot --parseinclude *.rst path/to/tests + robot -I *.robot -I *.rst path/to/tests + +Because the :option:`--parseinclude` option is more powerful and covers all +same use cases as the :option:`--extension` option, the latter is likely to be +deprecated in the future. Users are recommended to use :option:`--parseinclude` +already now. + +__ `Included and excluded files`_ + +Using custom parsers +~~~~~~~~~~~~~~~~~~~~ + +External parsers can parse files that Robot Framework does not recognize +otherwise. For more information about creating and using such parsers see +the `Parser interface`_ section. Selecting test cases -------------------- @@ -139,22 +228,16 @@ on higher level are not executed:: # Root suite is 'Example' and possible higher level setups and teardowns are ignored. robot path/to/tests/example.robot -When using the :option:`--suite` option, Robot Framework does not parse -files that do not match the given suite name. For example, when using -`--suite example`, only files that have a name :file:`example.robot` or are in -a directory :file:`example` are parsed. This is done for performance reasons -to avoid the parsing overhead with larger directory structures. Unfortunately -this approach does not work well with the new :setting:`Name` setting that can -be used for setting a custom `suite name`_. In practice the new setting and -the :option:`--suite` option are incompatible. This will be changed in Robot -Framework 7.0 so that `files are not excluded`__ when using the :option:`--suite` -option. The plan is to add an explicit option for `selecting files to parse`__ -before that. +Prior to Robot Framework 6.1, files not matching the :option:`--suite` option +were not parsed at all for performance reasons. This optimization was not +possible anymore after suites got a new :setting:`Name` setting that can override +the default suite name got from the file or directory name. New +:option:`--parseinclude` option has been added to `explicitly select which +files are parsed`__ if this kind of parsing optimization is needed. __ https://github.com/robotframework/robotframework/issues/4720 __ https://github.com/robotframework/robotframework/issues/4721 -__ https://github.com/robotframework/robotframework/issues/4688 -__ https://github.com/robotframework/robotframework/issues/4687 +__ `Selecting files by name or path`_ By tag names ~~~~~~~~~~~~ From bd298fda8544496aa1b2d2840e42dd2e7e751c81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 May 2023 01:03:53 +0300 Subject: [PATCH 0589/1592] Bump actions/setup-python from 4.6.0 to 4.6.1 (#4775) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.6.0 to 4.6.1. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.6.0...v4.6.1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 7cc2f67985f..15aa4f02ee3 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: '3.10' architecture: 'x64' @@ -49,7 +49,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 3d5bc9a83ce..597613ebee5 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: '3.11' 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@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 96e311f303a..835e2820d66 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 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 7f96e7a1e4e..21553589059 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From cb59448d55bc0c732b2b8867efa57558a7ea110d Mon Sep 17 00:00:00 2001 From: Serhiy1 <serhiy1@live.co.uk> Date: Mon, 29 May 2023 23:08:27 +0100 Subject: [PATCH 0590/1592] Enhance setup and teardown typing for TestSuite and TestCase (#4773) Part of #4570 --- src/robot/model/testcase.py | 18 ++++++++++-------- src/robot/model/testsuite.py | 37 +++++++++++++++++++++--------------- src/robot/result/model.py | 8 ++------ src/robot/running/model.py | 10 +++------- 4 files changed, 37 insertions(+), 36 deletions(-) diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index eaa6ca64c01..3970d9fa973 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -14,7 +14,7 @@ # limitations under the License. from pathlib import Path -from typing import Any, Sequence, Type, TYPE_CHECKING, TypeVar +from typing import Any, Generic, Sequence, Type, TYPE_CHECKING, TypeVar from robot.utils import setter @@ -31,16 +31,18 @@ TC = TypeVar('TC', bound='TestCase') +KW = TypeVar('KW', bound='Keyword', covariant=True) -class TestCase(ModelObject): +class TestCase(ModelObject, Generic[KW]): """Base model for a single test case. Extended by :class:`robot.running.model.TestCase` and :class:`robot.result.model.TestCase`. """ body_class = Body - fixture_class = Keyword + # See model.TestSuite on removing the type ignore directive + fixture_class: Type[KW] = Keyword # type: ignore repr_args = ('name',) __slots__ = ['parent', 'name', 'doc', 'timeout', 'lineno', '_setup', '_teardown'] @@ -57,8 +59,8 @@ def __init__(self, name: str = '', self.lineno = lineno self.parent = parent self.body = [] - self._setup: 'Keyword|None' = None - self._teardown: 'Keyword|None' = None + self._setup: 'KW|None' = None + self._teardown: 'KW|None' = None @setter def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: @@ -71,7 +73,7 @@ def tags(self, tags: Sequence[str]) -> Tags: return Tags(tags) @property - def setup(self) -> Keyword: + def setup(self) -> KW: """Test setup as a :class:`~.model.keyword.Keyword` object. This attribute is a ``Keyword`` object also when a test has no setup @@ -100,7 +102,7 @@ def setup(self) -> Keyword: return self._setup @setup.setter - def setup(self, setup: 'Keyword|DataDict|None'): + def setup(self, setup: 'KW|DataDict|None'): self._setup = create_fixture(self.fixture_class, setup, self, Keyword.SETUP) @property @@ -128,7 +130,7 @@ def teardown(self) -> Keyword: return self._teardown @teardown.setter - def teardown(self, teardown: 'Keyword|DataDict|None'): + def teardown(self, teardown: 'KW|DataDict|None'): self._teardown = create_fixture(self.fixture_class, teardown, self, Keyword.TEARDOWN) @property diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 37586d1ab14..0f665e7ef8b 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -15,7 +15,7 @@ from collections.abc import Mapping from pathlib import Path -from typing import Any, Iterator, Sequence, Type, TypeVar +from typing import Any, Generic, Iterator, Sequence, Type, TypeVar from robot.utils import seq2str, setter @@ -30,18 +30,24 @@ from .testcase import TestCase, TestCases from .visitor import SuiteVisitor +TS = TypeVar('TS', bound='TestSuite') +KW = TypeVar('KW', bound=Keyword, covariant=True) +TC = TypeVar('TC', bound=TestCase, covariant=True) -TS = TypeVar('TS', bound="TestSuite") - -class TestSuite(ModelObject): +class TestSuite(ModelObject, Generic[KW, TC]): """Base model for single suite. Extended by :class:`robot.running.model.TestSuite` and :class:`robot.result.model.TestSuite`. """ - test_class = TestCase #: Internal usage only. - fixture_class = Keyword #: Internal usage only. + # FIXME: Type Ignore declarations: Typevars only accept subclasses of the bound class + # assiging `Type[KW]` to `Keyword` results in an error. In RF 7 the class should be + # made impossible to instantiate directly, and the assignments can be replaced with + # KnownAtRuntime + fixture_class: Type[KW] = Keyword # type: ignore + test_class: Type[TC] = TestCase # type: ignore + repr_args = ('name',) __slots__ = ['parent', '_name', 'doc', '_setup', '_teardown', 'rpa', '_my_visitors'] @@ -59,8 +65,8 @@ def __init__(self, name: str = '', self.rpa = rpa self.suites = [] self.tests = [] - self._setup: 'Keyword|None' = None - self._teardown: 'Keyword|None' = None + self._setup: 'KW|None' = None + self._teardown: 'KW|None' = None self._my_visitors: 'list[SuiteVisitor]' = [] @staticmethod @@ -158,15 +164,15 @@ def metadata(self, metadata: 'Mapping[str, str]|None') -> Metadata: return Metadata(metadata) @setter - def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> 'TestSuites[TestSuite]': + def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> 'TestSuites[TestSuite[KW, TC]]': return TestSuites['TestSuite'](self.__class__, self, suites) @setter - def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases[TestCase]: - return TestCases[TestCase](self.test_class, self, tests) + def tests(self, tests: 'Sequence[TC|DataDict]') -> TestCases[TC]: + return TestCases[TC](self.test_class, self, tests) @property - def setup(self) -> Keyword: + def setup(self) -> KW: """Suite setup. This attribute is a ``Keyword`` object also when a suite has no setup @@ -196,7 +202,7 @@ def setup(self) -> Keyword: return self._setup @setup.setter - def setup(self, setup: 'Keyword|DataDict|None'): + def setup(self, setup: 'KW|DataDict|None'): self._setup = create_fixture(self.fixture_class, setup, self, Keyword.SETUP) @property @@ -214,7 +220,7 @@ def has_setup(self) -> bool: return bool(self._setup) @property - def teardown(self) -> Keyword: + def teardown(self) -> KW: """Suite teardown. See :attr:`setup` for more information. @@ -224,7 +230,7 @@ def teardown(self) -> Keyword: return self._teardown @teardown.setter - def teardown(self, teardown: 'Keyword|DataDict|None'): + def teardown(self, teardown: 'KW|DataDict|None'): self._teardown = create_fixture(self.fixture_class, teardown, self, Keyword.TEARDOWN) @property @@ -378,6 +384,7 @@ def to_dict(self) -> 'dict[str, Any]': data['suites'] = self.suites.to_dicts() return data + class TestSuites(ItemList[TS]): __slots__ = [] diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 48983fddf6d..56930d9d943 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -764,7 +764,7 @@ def tags(self, tags: Sequence[str]) -> model.Tags: return Tags(tags) -class TestCase(model.TestCase, StatusMixin): +class TestCase(model.TestCase[Keyword], StatusMixin): """Represents results of a single test case. See the base class for documentation of attributes not documented here. @@ -809,7 +809,7 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) -class TestSuite(model.TestSuite, StatusMixin): +class TestSuite(model.TestSuite[Keyword, TestCase], StatusMixin): """Represents results of a single test suite. See the base class for documentation of attributes not documented here. @@ -908,10 +908,6 @@ def elapsedtime(self) -> int: def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> TestSuites['TestSuite']: return TestSuites['TestSuite'](self.__class__, self, suites) - @setter - def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases[TestCase]: - return TestCases[TestCase](self.test_class, self, tests) - def remove_keywords(self, how: str): """Remove keywords based on the given condition. diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 5ac59642860..1cae0ccef5e 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -37,7 +37,7 @@ import sys import warnings from pathlib import Path -from typing import Any, cast, Mapping, Sequence, TYPE_CHECKING, Union +from typing import Any, Mapping, Sequence, TYPE_CHECKING, Union if sys.version_info >= (3, 8): from typing import Literal @@ -367,7 +367,7 @@ def to_dict(self) -> DataDict: return data -class TestCase(model.TestCase): +class TestCase(model.TestCase[Keyword]): """Represents a single executable test case. See the base class for documentation of attributes not documented here. @@ -404,7 +404,7 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) -class TestSuite(model.TestSuite): +class TestSuite(model.TestSuite[Keyword, TestCase]): """Represents a single executable test suite. See the base class for documentation of attributes not documented here. @@ -537,10 +537,6 @@ def randomize(self, suites: bool = True, tests: bool = True, def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> TestSuites['TestSuite']: return TestSuites['TestSuite'](self.__class__, self, suites) - @setter - def tests(self, tests: 'Sequence[TestCase|DataDict]') -> TestCases[TestCase]: - return TestCases[TestCase](self.test_class, self, tests) - def run(self, settings=None, **options): """Executes the suite based on the given ``settings`` or ``options``. From b1243c71bef2db87ecd54fdc5e46480d76e8b894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 30 May 2023 01:46:19 +0300 Subject: [PATCH 0591/1592] Fix tests failing due to different error messages on PyPy --- atest/testdata/variables/return_values.robot | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/atest/testdata/variables/return_values.robot b/atest/testdata/variables/return_values.robot index d5029a85799..7e9a74a856d 100644 --- a/atest/testdata/variables/return_values.robot +++ b/atest/testdata/variables/return_values.robot @@ -458,18 +458,16 @@ Item assign to immutable object fails ${tuple_variable}[0]= Set Variable 0 Item assign expects iterable fails - [Documentation] FAIL - ... Setting value to list variable '${list_variable}' at index [:1] failed: \ - ... TypeError: can only assign an iterable + [Documentation] FAIL STARTS: + ... Setting value to list variable '${list_variable}' at index [:1] failed: TypeError: ${list_variable}= Create List 1 2 3 ${list_variable}[:1]= Evaluate 0 Log To Console ${list_variable} Index not found error when item assign to list - [Documentation] FAIL - ... Setting value to list variable '${list_variable}[0]' at index [2] failed: \ - ... IndexError: list assignment index out of range + [Documentation] FAIL STARTS: + ... Setting value to list variable '${list_variable}[0]' at index [2] failed: IndexError: ${list_variable}= Create List ${{ [1, 2] }} ${list_variable}[0][2]= Set Variable 3 From 609b677bf1eae11a19c3e87dd451aec4d061a3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 31 May 2023 15:33:26 +0300 Subject: [PATCH 0592/1592] Hack to fix Python 3.6 compatibility. Python 3.6 doesn't support using `typing.Generic` with custom metaclasses like our `SetterAwareType`. --- src/robot/model/testcase.py | 3 ++- src/robot/model/testsuite.py | 3 ++- src/robot/utils/setter.py | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index 3970d9fa973..c427dcef40d 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys from pathlib import Path from typing import Any, Generic, Sequence, Type, TYPE_CHECKING, TypeVar @@ -34,7 +35,7 @@ KW = TypeVar('KW', bound='Keyword', covariant=True) -class TestCase(ModelObject, Generic[KW]): +class TestCase(ModelObject, Generic[KW] if sys.version_info >= (3, 7) else object): """Base model for a single test case. Extended by :class:`robot.running.model.TestCase` and diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 0f665e7ef8b..60817e33b88 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys from collections.abc import Mapping from pathlib import Path from typing import Any, Generic, Iterator, Sequence, Type, TypeVar @@ -35,7 +36,7 @@ TC = TypeVar('TC', bound=TestCase, covariant=True) -class TestSuite(ModelObject, Generic[KW, TC]): +class TestSuite(ModelObject, Generic[KW, TC] if sys.version_info >= (3, 7) else object): """Base model for single suite. Extended by :class:`robot.running.model.TestSuite` and diff --git a/src/robot/utils/setter.py b/src/robot/utils/setter.py index be7ccfb26ec..dd429263b1c 100644 --- a/src/robot/utils/setter.py +++ b/src/robot/utils/setter.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys from typing import Callable, Generic, overload, TypeVar, Type, Union @@ -92,3 +93,7 @@ def __new__(cls, name, bases, dct): slots.append(item.attr_name) dct['__slots__'] = slots return type.__new__(cls, name, bases, dct) + + if sys.version_info < (3, 7): + def __getitem__(self, item): + return self From 249c4cb50536c55fb5298fd1982ed774a26b2457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 31 May 2023 18:16:59 +0300 Subject: [PATCH 0593/1592] Small utest tuning. --- utest/model/test_modelobject.py | 23 ++++++++--------------- utest/model/test_testsuite.py | 13 +++++++------ 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/utest/model/test_modelobject.py b/utest/model/test_modelobject.py index 6340884db1c..56018451408 100644 --- a/utest/model/test_modelobject.py +++ b/utest/model/test_modelobject.py @@ -78,32 +78,25 @@ def test_failure_converting_to_tuple(self): class TestFromDictAndJson(unittest.TestCase): - def test_init_args(self): - class X(ModelObject): - def __init__(self, a=1, b=2): - self.a = a - self.b = b - x = X.from_dict({'a': 3}) - assert_equal(x.a, 3) - assert_equal(x.b, 2) - x = X.from_json('{"a": "A", "b": true}') - assert_equal(x.a, 'A') - assert_equal(x.b, True) - - def test_other_attributes(self): + def test_attributes(self): obj = Example.from_dict({'a': 1}) assert_equal(obj.a, 1) - obj = Example.from_json('{"a": null, "b": 42}') + assert_equal(obj.b, None) + assert_equal(obj.c, None) + obj = Example.from_json('{"a": null, "b": 42, "c": true}') assert_equal(obj.a, None) assert_equal(obj.b, 42) + assert_equal(obj.c, True) - def test_not_accepted_attribute(self): + def test_non_existing_attribute(self): assert_raises_with_msg( DataError, f"Creating '{__name__}.Example' object from dictionary failed: " f"'{__name__}.Example' object does not have attribute 'nonex'", Example.from_dict, {'nonex': 'attr'} ) + + def test_setting_attribute_fails(self): assert_raises_with_msg( DataError, f"Creating '{__name__}.Example' object from dictionary failed: " diff --git a/utest/model/test_testsuite.py b/utest/model/test_testsuite.py index 004e456551c..107e38dc916 100644 --- a/utest/model/test_testsuite.py +++ b/utest/model/test_testsuite.py @@ -53,12 +53,13 @@ def test_name_from_source(self): assert_equal(suite.name, 'new name') if inp: assert_equal(TestSuite(source=Path(inp)).name, exp) - assert_equal(TestSuite(source=Path(inp).resolve()).name, exp) + assert_equal(TestSuite(source=Path(inp).absolute()).name, exp) def test_name_from_source_with_extensions(self): - for ext, exp in [('z', 'X.Y'), ('.z', 'X.Y'), ('y.z', 'X'), - (['x', 'y', 'z'], 'X.Y')]: + for ext, exp in [('z', 'X.Y'), ('.z', 'X.Y'), ('Z', 'X.Y'), ('y.z', 'X'), + ('Y.z', 'X'), (['x', 'y', 'z'], 'X.Y')]: assert_equal(TestSuite.name_from_source('x.y.z', ext), exp) + assert_equal(TestSuite.name_from_source('X.Y.Z', ext), exp) def test_name_from_source_with_bad_extensions(self): assert_raises_with_msg( @@ -174,18 +175,18 @@ class TestStringRepresentation(unittest.TestCase): def setUp(self): self.empty = TestSuite() self.ascii = TestSuite(name='Kekkonen') - self.non_ascii = TestSuite(name=u'hyv\xe4 nimi') + self.non_ascii = TestSuite(name='hyvä nimi') def test_str(self): for tc, expected in [(self.empty, ''), (self.ascii, 'Kekkonen'), - (self.non_ascii, u'hyv\xe4 nimi')]: + (self.non_ascii, 'hyvä nimi')]: assert_equal(str(tc), expected) def test_repr(self): for tc, expected in [(self.empty, "TestSuite(name='')"), (self.ascii, "TestSuite(name='Kekkonen')"), - (self.non_ascii, u"TestSuite(name=%r)" % u'hyv\xe4 nimi')]: + (self.non_ascii, "TestSuite(name='hyvä nimi')")]: assert_equal(repr(tc), 'robot.model.' + expected) From ead7c004ae7267aaa4d2882a2299d6cb2e71f80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 31 May 2023 23:06:23 +0300 Subject: [PATCH 0594/1592] regen --- doc/api/autodoc/robot.utils.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/api/autodoc/robot.utils.rst b/doc/api/autodoc/robot.utils.rst index 6caa912cc5f..8f846dbeb09 100644 --- a/doc/api/autodoc/robot.utils.rst +++ b/doc/api/autodoc/robot.utils.rst @@ -273,6 +273,14 @@ robot.utils.text module :undoc-members: :show-inheritance: +robot.utils.typehints module +---------------------------- + +.. automodule:: robot.utils.typehints + :members: + :undoc-members: + :show-inheritance: + robot.utils.unic module ----------------------- From 4868b39e1d12599cc322f18c27e00bd2354bafe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 31 May 2023 23:07:20 +0300 Subject: [PATCH 0595/1592] Add `TestSuite.adjust_source`. Makes it possible to make suite source relative and to add a custom root to it. This is especially useful when moving data around as JSON (#3902). Also add docstring to `Metadata`. --- src/robot/model/metadata.py | 4 ++++ src/robot/model/testsuite.py | 40 ++++++++++++++++++++++++++++++- utest/model/test_testsuite.py | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/robot/model/metadata.py b/src/robot/model/metadata.py index db8ceadab1d..8be03af8dd8 100644 --- a/src/robot/model/metadata.py +++ b/src/robot/model/metadata.py @@ -19,6 +19,10 @@ class Metadata(NormalizedDict[str]): + """Free suite metadata as a mapping. + + Keys are case, space, and underscore insensitive. + """ def __init__(self, initial: 'Mapping[str, str]|Iterable[tuple[str, str]]|None' = None): super().__init__(initial, ignore='_') diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 60817e33b88..45e2828723c 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -152,6 +152,44 @@ def name(self, name: str): def source(self, source: 'Path|str|None') -> 'Path|None': return source if isinstance(source, (Path, type(None))) else Path(source) + def adjust_source(self, relative_to: 'Path|str|None' = None, + root: 'Path|str|None' = None): + """Adjust suite source and child suite sources, recursively. + + :param relative_to: Make suite source relative to the given path. Calls + `pathlib.Path.relative_to()`__ internally. Raises ``ValueError`` + if creating a relative path is not possible. + :param root: Make given path a new root directory for the source. Raises + ``ValueError`` if suite source is absolute. + + Adjusting the source is especially useful when moving data around as JSON:: + + from robot.running import TestSuite + + # Create a suite, adjust source and convert to JSON. + suite = TestSuite.from_file_system('/path/to/data') + suite.adjust_source(relative_to='/path/to') + suite.to_json('data.json') + + # Recreate suite elsewhere and adjust source accordingly. + suite = TestSuite.from_json('data.json') + suite.adjust_source(root='/new/path/to') + + New in Robot Framework 6.1. + + __ https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.relative_to + """ + if not self.source: + raise ValueError('Suite has no source.') + if relative_to: + self.source = self.source.relative_to(relative_to) + if root: + if self.source.is_absolute(): + raise ValueError(f"Cannot set root for absolute source '{self.source}'.") + self.source = root / self.source + for suite in self.suites: + suite.adjust_source(relative_to, root) + @property def longname(self) -> str: """Suite name prefixed with the long name of the parent suite.""" @@ -161,7 +199,7 @@ def longname(self) -> str: @setter def metadata(self, metadata: 'Mapping[str, str]|None') -> Metadata: - """Free suite metadata as dictionary-like ``Metadata`` object.""" + """Free suite metadata as a :class:`~.metadata.Metadata` object.""" return Metadata(metadata) @setter diff --git a/utest/model/test_testsuite.py b/utest/model/test_testsuite.py index 107e38dc916..9f4d781634a 100644 --- a/utest/model/test_testsuite.py +++ b/utest/model/test_testsuite.py @@ -91,6 +91,50 @@ def test_nested_subsuites(self): assert_equal(list(suite.suites), [sub1]) assert_equal(list(sub1.suites), [sub2]) + def test_adjust_source(self): + absolute = Path('.').absolute() + suite = TestSuite(source='dir') + suite.suites = [TestSuite(source='dir/x.robot'), + TestSuite(source='dir/y.robot')] + assert_equal(suite.source, Path('dir')) + assert_equal(suite.suites[0].source, Path('dir/x.robot')) + assert_equal(suite.suites[1].source, Path('dir/y.robot')) + suite.adjust_source(root=absolute) + assert_equal(suite.source, absolute / 'dir') + assert_equal(suite.suites[0].source, absolute / 'dir/x.robot') + assert_equal(suite.suites[1].source, absolute / 'dir/y.robot') + suite.adjust_source(relative_to=absolute) + assert_equal(suite.source, Path('dir')) + assert_equal(suite.suites[0].source, Path('dir/x.robot')) + assert_equal(suite.suites[1].source, Path('dir/y.robot')) + suite.adjust_source(root='relative') + assert_equal(suite.source, Path('relative/dir')) + assert_equal(suite.suites[0].source, Path('relative/dir/x.robot')) + assert_equal(suite.suites[1].source, Path('relative/dir/y.robot')) + suite.adjust_source(relative_to='relative/dir', root=str(absolute)) + assert_equal(suite.source, absolute) + assert_equal(suite.suites[0].source, absolute / 'x.robot') + assert_equal(suite.suites[1].source, absolute / 'y.robot') + + def test_adjust_source_failures(self): + absolute = Path('x.robot').absolute() + assert_raises_with_msg( + ValueError, 'Suite has no source.', + TestSuite().adjust_source + ) + assert_raises_with_msg( + ValueError, f"Cannot set root for absolute source '{absolute}'.", + TestSuite(source=absolute).adjust_source, root='whatever' + ) + assert_raises( + ValueError, + TestSuite(source=absolute).adjust_source, relative_to='relative' + ) + assert_raises( + ValueError, + TestSuite(source='relative').adjust_source, relative_to=absolute, + ) + def test_set_tags(self): suite = TestSuite() suite.tests.create() From c8dcb225feafcf988dd763984d16cbedcd8d234a Mon Sep 17 00:00:00 2001 From: Markus Neifer <mneiferbag@users.noreply.github.com> Date: Wed, 31 May 2023 22:48:36 +0200 Subject: [PATCH 0596/1592] Clarify code example (#4778) Update documentation setting to reflect that this is for keywords, not tests. --- doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index c22e80affef..f8ebc4b8fdd 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -157,11 +157,11 @@ Both user keywords and `library keywords`_ can have tags. Similarly as when *** Keywords *** No own tags - [Documentation] This test has tag 'gui'. + [Documentation] This keyword has tag 'gui'. No Operation Own tags - [Documentation] This test has tags 'gui', 'own' and 'tags'. + [Documentation] This keyword has tags 'gui', 'own' and 'tags'. [Tags] own tags No Operation From 4fb5bdfe20d8f43a90192b535438cd54773425f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 1 Jun 2023 21:27:50 +0300 Subject: [PATCH 0597/1592] Tuning `Tags`. - Accept tags as `Iterable` instead of `Sequence`. - Fix `__eq__`. - Use `isinstance` instead of `is_string`. --- src/robot/model/tags.py | 42 +++++++++++++++++++++------------------- src/robot/utils/match.py | 6 +++--- utest/model/test_tags.py | 4 ++++ 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/robot/model/tags.py b/src/robot/model/tags.py index 00eb5d29f08..f838c4ec1c8 100644 --- a/src/robot/model/tags.py +++ b/src/robot/model/tags.py @@ -14,15 +14,15 @@ # limitations under the License. from abc import ABC, abstractmethod -from typing import Iterator, overload, Sequence +from typing import Any, Iterable, Iterator, overload, Sequence -from robot.utils import is_string, normalize, NormalizedDict, Matcher +from robot.utils import normalize, NormalizedDict, Matcher class Tags(Sequence[str]): __slots__ = ['_tags', '_reserved'] - def __init__(self, tags: Sequence[str] = ()): + def __init__(self, tags: Iterable[str] = ()): if isinstance(tags, Tags): self._tags, self._reserved = tags._tags, tags._reserved else: @@ -38,7 +38,7 @@ def robot(self, name: str) -> bool: def _init_tags(self, tags) -> 'tuple[tuple[str, ...], tuple[str, ...]]': if not tags: return (), () - if is_string(tags): + if isinstance(tags, str): tags = (tags,) return self._normalize(tags) @@ -51,17 +51,17 @@ def _normalize(self, tags): reserved = tuple(tag[6:] for tag in nd.normalized_keys if tag[:6] == 'robot:') return tuple(nd), reserved - def add(self, tags: Sequence[str]): + def add(self, tags: Iterable[str]): self.__init__(tuple(self) + tuple(Tags(tags))) - def remove(self, tags: Sequence[str]): + def remove(self, tags: Iterable[str]): match = TagPatterns(tags).match self.__init__([t for t in self if not match(t)]) - def match(self, tags: Sequence[str]) -> bool: + def match(self, tags: Iterable[str]) -> bool: return TagPatterns(tags).match(self) - def __contains__(self, tags) -> bool: + def __contains__(self, tags: Iterable[str]) -> bool: return self.match(tags) def __len__(self) -> int: @@ -77,7 +77,9 @@ def __str__(self) -> str: def __repr__(self) -> str: return repr(list(self)) - def __eq__(self, other) -> bool: + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Iterable): + return False if not isinstance(other, Tags): other = Tags(other) self_normalized = [normalize(tag, ignore='_') for tag in self] @@ -97,16 +99,16 @@ def __getitem__(self, index: 'int|slice') -> 'str|Tags': return Tags(self._tags[index]) return self._tags[index] - def __add__(self, other: Sequence[str]) -> 'Tags': + def __add__(self, other: Iterable[str]) -> 'Tags': return Tags(tuple(self) + tuple(Tags(other))) class TagPatterns(Sequence['TagPattern']): - def __init__(self, patterns: Sequence[str]): + def __init__(self, patterns: Iterable[str]): self._patterns = tuple(TagPattern.from_string(p) for p in Tags(patterns)) - def match(self, tags: Sequence[str]) -> bool: + def match(self, tags: Iterable[str]) -> bool: if not self._patterns: return False tags = tags if isinstance(tags, Tags) else Tags(tags) @@ -144,7 +146,7 @@ def from_string(cls, pattern: str) -> 'TagPattern': return SingleTagPattern(pattern) @abstractmethod - def match(self, tags: Sequence[str]) -> bool: + def match(self, tags: Iterable[str]) -> bool: raise NotImplementedError @abstractmethod @@ -161,7 +163,7 @@ class SingleTagPattern(TagPattern): def __init__(self, pattern: str): self._matcher = Matcher(pattern, ignore='_') - def match(self, tags: Sequence[str]) -> bool: + def match(self, tags: Iterable[str]) -> bool: return self._matcher.match_any(tags) def __iter__(self) -> Iterator['TagPattern']: @@ -176,10 +178,10 @@ def __bool__(self) -> bool: class AndTagPattern(TagPattern): - def __init__(self, patterns: Sequence[str]): + def __init__(self, patterns: Iterable[str]): self._patterns = tuple(TagPattern.from_string(p) for p in patterns) - def match(self, tags: Sequence[str]) -> bool: + def match(self, tags: Iterable[str]) -> bool: return all(p.match(tags) for p in self._patterns) def __iter__(self) -> Iterator['TagPattern']: @@ -191,10 +193,10 @@ def __str__(self) -> str: class OrTagPattern(TagPattern): - def __init__(self, patterns: Sequence[str]): + def __init__(self, patterns: Iterable[str]): self._patterns = tuple(TagPattern.from_string(p) for p in patterns) - def match(self, tags: Sequence[str]) -> bool: + def match(self, tags: Iterable[str]) -> bool: return any(p.match(tags) for p in self._patterns) def __iter__(self) -> Iterator['TagPattern']: @@ -206,11 +208,11 @@ def __str__(self) -> str: class NotTagPattern(TagPattern): - def __init__(self, must_match: str, must_not_match: Sequence[str]): + def __init__(self, must_match: str, must_not_match: Iterable[str]): self._first = TagPattern.from_string(must_match) self._rest = OrTagPattern(must_not_match) - def match(self, tags: Sequence[str]) -> bool: + def match(self, tags: Iterable[str]) -> bool: if not self._first: return not self._rest.match(tags) return self._first.match(tags) and not self._rest.match(tags) diff --git a/src/robot/utils/match.py b/src/robot/utils/match.py index d79b7d869eb..e40c789590a 100644 --- a/src/robot/utils/match.py +++ b/src/robot/utils/match.py @@ -46,7 +46,7 @@ def _compile(self, pattern, regexp=False): def match(self, string: str) -> bool: return self._regexp.match(self._normalize(string)) is not None - def match_any(self, strings: Sequence[str]) -> bool: + def match_any(self, strings: Iterable[str]) -> bool: return any(self.match(s) for s in strings) def __bool__(self) -> bool: @@ -55,7 +55,7 @@ def __bool__(self) -> bool: class MultiMatcher(Iterable[Matcher]): - def __init__(self, patterns: Sequence[str] = (), ignore: Sequence[str] = (), + def __init__(self, patterns: Iterable[str] = (), ignore: Sequence[str] = (), caseless: bool = True, spaceless: bool = True, match_if_no_patterns: bool = False, regexp: bool = False): self.matchers = [Matcher(pattern, ignore, caseless, spaceless, regexp) @@ -74,7 +74,7 @@ def match(self, string: str) -> bool: return any(m.match(string) for m in self.matchers) return self.match_if_no_patterns - def match_any(self, strings: Sequence[str]) -> bool: + def match_any(self, strings: Iterable[str]) -> bool: return any(self.match(s) for s in strings) def __len__(self) -> int: diff --git a/utest/model/test_tags.py b/utest/model/test_tags.py index 80350b36fe6..bbce0a28305 100644 --- a/utest/model/test_tags.py +++ b/utest/model/test_tags.py @@ -158,6 +158,10 @@ def test__eq__converts_other_to_tags(self): assert_equal(Tags(['X']), 'x') assert_not_equal(Tags(['X']), 'y') + def test__eq__with_other_that_cannot_be_converted_to_tags(self): + assert_not_equal(Tags(), 1) + assert_not_equal(Tags(), None) + def test__eq__normalized(self): assert_equal(Tags(['Hello world', 'Foo', 'Not_world']), Tags(['nOT WORLD', 'FOO', 'hello world'])) From 704bcf66bd002ceafde2df0352426278f0b57281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 1 Jun 2023 23:58:32 +0300 Subject: [PATCH 0598/1592] Enhance performance of `--include` and `--exclude` Fixes #3579. --- src/robot/model/tags.py | 29 ++++++++++++++++++++++++----- src/robot/utils/match.py | 7 ++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/robot/model/tags.py b/src/robot/model/tags.py index f838c4ec1c8..05ec4e92088 100644 --- a/src/robot/model/tags.py +++ b/src/robot/model/tags.py @@ -111,7 +111,7 @@ def __init__(self, patterns: Iterable[str]): def match(self, tags: Iterable[str]) -> bool: if not self._patterns: return False - tags = tags if isinstance(tags, Tags) else Tags(tags) + tags = normalize_tags(tags) return any(p.match(tags) for p in self._patterns) def __contains__(self, tag: str) -> bool: @@ -161,9 +161,13 @@ def __str__(self) -> str: class SingleTagPattern(TagPattern): def __init__(self, pattern: str): - self._matcher = Matcher(pattern, ignore='_') + # Normalization is handled here, not in Matcher, for performance reasons. + # This way we can normalize tags only once. + self._matcher = Matcher(normalize(pattern, ignore='_'), + caseless=False, spaceless=False) def match(self, tags: Iterable[str]) -> bool: + tags = normalize_tags(tags) return self._matcher.match_any(tags) def __iter__(self) -> Iterator['TagPattern']: @@ -182,6 +186,7 @@ def __init__(self, patterns: Iterable[str]): self._patterns = tuple(TagPattern.from_string(p) for p in patterns) def match(self, tags: Iterable[str]) -> bool: + tags = normalize_tags(tags) return all(p.match(tags) for p in self._patterns) def __iter__(self) -> Iterator['TagPattern']: @@ -197,6 +202,7 @@ def __init__(self, patterns: Iterable[str]): self._patterns = tuple(TagPattern.from_string(p) for p in patterns) def match(self, tags: Iterable[str]) -> bool: + tags = normalize_tags(tags) return any(p.match(tags) for p in self._patterns) def __iter__(self) -> Iterator['TagPattern']: @@ -213,9 +219,9 @@ def __init__(self, must_match: str, must_not_match: Iterable[str]): self._rest = OrTagPattern(must_not_match) def match(self, tags: Iterable[str]) -> bool: - if not self._first: - return not self._rest.match(tags) - return self._first.match(tags) and not self._rest.match(tags) + tags = normalize_tags(tags) + return ((self._first.match(tags) or not self._first) + and not self._rest.match(tags)) def __iter__(self) -> Iterator['TagPattern']: yield self._first @@ -223,3 +229,16 @@ def __iter__(self) -> Iterator['TagPattern']: def __str__(self) -> str: return ' NOT '.join(str(pattern) for pattern in self).lstrip() + + +def normalize_tags(tags: Iterable[str]) -> Iterable[str]: + """Performance optimization to normalize tags only once.""" + if isinstance(tags, NormalizedTags): + return tags + if isinstance(tags, str): + tags = [tags] + return NormalizedTags([normalize(t, ignore='_') for t in tags]) + + +class NormalizedTags(list): + pass diff --git a/src/robot/utils/match.py b/src/robot/utils/match.py index e40c789590a..3f9e2b03fec 100644 --- a/src/robot/utils/match.py +++ b/src/robot/utils/match.py @@ -15,7 +15,6 @@ import re import fnmatch -from functools import partial from typing import Iterable, Iterator, Sequence from .normalizing import normalize @@ -34,8 +33,10 @@ class Matcher: def __init__(self, pattern: str, ignore: Sequence[str] = (), caseless: bool = True, spaceless: bool = True, regexp: bool = False): self.pattern = pattern - self._normalize = partial(normalize, ignore=ignore, caseless=caseless, - spaceless=spaceless) + if caseless or spaceless or ignore: + self._normalize = lambda s: normalize(s, ignore, caseless, spaceless) + else: + self._normalize = lambda s: s self._regexp = self._compile(self._normalize(pattern), regexp=regexp) def _compile(self, pattern, regexp=False): From b70f396b644ba0a524fae7920312cde1bde99ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Jun 2023 00:10:39 +0300 Subject: [PATCH 0599/1592] Add localizations for the new `Name` setting (#4583) Localization added to cs, nl, de, pt-BT, pt and sv. --- src/robot/conf/languages.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 3e02d292b1a..61e59f6b603 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -367,6 +367,7 @@ class Cs(Language): library_setting = 'Knihovna' resource_setting = 'Zdroj' variables_setting = 'Proměnná' + name_setting = 'Název' documentation_setting = 'Dokumentace' metadata_setting = 'Metadata' suite_setup_setting = 'Příprava sady' @@ -408,6 +409,7 @@ class Nl(Language): library_setting = 'Bibliotheek' resource_setting = 'Resource' variables_setting = 'Variabele' + name_setting = 'Naam' documentation_setting = 'Documentatie' metadata_setting = 'Metadata' suite_setup_setting = 'Suite Preconditie' @@ -571,6 +573,7 @@ class De(Language): library_setting = 'Bibliothek' resource_setting = 'Ressource' variables_setting = 'Variablen' + name_setting = 'Name' documentation_setting = 'Dokumentation' metadata_setting = 'Metadaten' suite_setup_setting = 'Suitevorbereitung' @@ -612,6 +615,7 @@ class PtBr(Language): library_setting = 'Biblioteca' resource_setting = 'Recurso' variables_setting = 'Variável' + name_setting = 'Nome' documentation_setting = 'Documentação' metadata_setting = 'Metadados' suite_setup_setting = 'Configuração da Suíte' @@ -653,6 +657,7 @@ class Pt(Language): library_setting = 'Biblioteca' resource_setting = 'Recurso' variables_setting = 'Variável' + name_setting = 'Nome' documentation_setting = 'Documentação' metadata_setting = 'Metadados' suite_setup_setting = 'Inicialização de Suíte' @@ -733,7 +738,7 @@ class Pl(Language): library_setting = 'Biblioteka' resource_setting = 'Zasób' variables_setting = 'Zmienne' - name_setting = "Nazwa" + name_setting = 'Nazwa' documentation_setting = 'Dokumentacja' metadata_setting = 'Metadane' suite_setup_setting = 'Inicjalizacja zestawu' @@ -1017,6 +1022,7 @@ class Sv(Language): library_setting = 'Bibliotek' resource_setting = 'Resurs' variables_setting = 'Variabel' + name_setting = 'Namn' documentation_setting = 'Dokumentation' metadata_setting = 'Metadata' suite_setup_setting = 'Svit konfigurering' From 346db49b71bdec8620e6e74c9c39b5634781196e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Jun 2023 10:09:30 +0300 Subject: [PATCH 0600/1592] Fix back navigation in log, report and Libdoc outputs. Use `Element.scrollIntoView()` instead of `window.location.hash = ''` hack for scrolling to element. The latter adds unnecessary history entries. Fixes #4754. --- src/robot/htmldata/libdoc/libdoc.html | 6 +++--- src/robot/htmldata/rebot/log.html | 2 +- src/robot/htmldata/rebot/report.html | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/robot/htmldata/libdoc/libdoc.html b/src/robot/htmldata/libdoc/libdoc.html index de829cc5ea3..0de208ca180 100644 --- a/src/robot/htmldata/libdoc/libdoc.html +++ b/src/robot/htmldata/libdoc/libdoc.html @@ -122,9 +122,9 @@ <h1>Opening library documentation failed</h1> function scrollToHash() { if (window.location.hash) { - var hash = window.location.hash.substring(1).replace('+', ' '); - window.location.hash = ''; - window.location.hash = hash; + var hash = window.location.hash.substring(1); + var id = decodeURIComponent(hash); + document.getElementById(id).scrollIntoView(); } } diff --git a/src/robot/htmldata/rebot/log.html b/src/robot/htmldata/rebot/log.html index 981bf296472..75451f316c2 100644 --- a/src/robot/htmldata/rebot/log.html +++ b/src/robot/htmldata/rebot/log.html @@ -122,8 +122,8 @@ <h1>Opening Robot Framework log failed</h1> util.map(ids, expandElementWithId); if (ids.length) { expandFailed(window.testdata.findLoaded(util.last(ids))); - window.location.hash = ''; window.location.hash = id; + document.getElementById(id).scrollIntoView(); highlightLinkTarget(); } }); diff --git a/src/robot/htmldata/rebot/report.html b/src/robot/htmldata/rebot/report.html index 65fcebc495f..c1a21056a71 100644 --- a/src/robot/htmldata/rebot/report.html +++ b/src/robot/htmldata/rebot/report.html @@ -265,8 +265,8 @@ <h1>Opening Robot Framework report failed</h1> function scrollToSelector(base, query) { $('#test-details-container').css('min-height', $(window).height()); var anchor = query ? base + '?' + encodeURIComponent(query) : base; - window.location.hash = ''; window.location.hash = window.prevLocationHash = anchor; + document.getElementById('test-details-container').scrollIntoView(); } function renderSelector(args, template, stats) { From 81db481e8ea2f5577a5e006b7a31fd8d46d15b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Jun 2023 10:25:15 +0300 Subject: [PATCH 0601/1592] Libdoc: Fix error reporting if format not detected. Fixes #4780. --- atest/robot/libdoc/invalid_usage.robot | 1 + src/robot/libdoc.py | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/atest/robot/libdoc/invalid_usage.robot b/atest/robot/libdoc/invalid_usage.robot index 252fbc18197..082e0625cc4 100644 --- a/atest/robot/libdoc/invalid_usage.robot +++ b/atest/robot/libdoc/invalid_usage.robot @@ -21,6 +21,7 @@ Invalid format --format XML:XXX BuiltIn ${OUT HTML} Format must be 'HTML', 'XML', 'JSON' or 'LIBSPEC', got 'XML:XXX'. --format XML:HTML BuiltIn ${OUT HTML} Format must be 'HTML', 'XML', 'JSON' or 'LIBSPEC', got 'XML:HTML'. BuiltIn out.ext Format must be 'HTML', 'XML', 'JSON' or 'LIBSPEC', got 'EXT'. + BuiltIn BuiltIn Format must be 'HTML', 'XML', 'JSON' or 'LIBSPEC', got ''. Invalid specdocformat -s XXX BuiltIn ${OUT HTML} Spec doc format must be 'RAW' or 'HTML', got 'XXX'. diff --git a/src/robot/libdoc.py b/src/robot/libdoc.py index 0cc95ee0c43..fffb89e8bfc 100755 --- a/src/robot/libdoc.py +++ b/src/robot/libdoc.py @@ -204,17 +204,18 @@ def _get_docformat(self, docformat): def _get_format_and_specdocformat(self, format, specdocformat, output): extension = Path(output).suffix[1:] format = self._validate('Format', format or extension, - 'HTML', 'XML', 'JSON', 'LIBSPEC') + 'HTML', 'XML', 'JSON', 'LIBSPEC', allow_none=False) specdocformat = self._validate('Spec doc format', specdocformat, 'RAW', 'HTML') if format == 'HTML' and specdocformat: raise DataError("The --specdocformat option is not applicable with " "HTML outputs.") return format, specdocformat - def _validate(self, kind, value, *valid): - if not value: + def _validate(self, kind, value, *valid, allow_none=True): + if value: + value = value.upper() + elif allow_none: return None - value = value.upper() if value not in valid: raise DataError(f"{kind} must be {seq2str(valid, lastsep=' or ')}, " f"got '{value}'.") From b4c3c3ede6a865b2227ae0d7f49122a6fb8768ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Jun 2023 10:37:46 +0300 Subject: [PATCH 0602/1592] Update link to NFC normalization info. --- src/robot/libraries/BuiltIn.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 04ea3619f06..8b7439a219d 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -306,7 +306,7 @@ def _convert_to_number_without_precision(self, item): def convert_to_string(self, item): """Converts the given item to a Unicode string. - Strings are also [http://www.macchiato.com/unicode/nfc-faq| + Strings are also [https://en.wikipedia.org/wiki/Unicode_equivalence| NFC normalized]. Use `Encode String To Bytes` and `Decode Bytes To String` keywords @@ -835,7 +835,7 @@ def should_not_be_equal_as_strings(self, first, second, msg=None, values=True, arguments are strings, the comparison is done with all white spaces replaced by a single space character. - Strings are always [http://www.macchiato.com/unicode/nfc-faq| + Strings are always [https://en.wikipedia.org/wiki/Unicode_equivalence| NFC normalized]. ``strip_spaces`` is new in Robot Framework 4.0 and ``collapse_spaces`` is new @@ -877,7 +877,7 @@ def should_be_equal_as_strings(self, first, second, msg=None, values=True, arguments are strings, the comparison is done with all white spaces replaced by a single space character. - Strings are always [http://www.macchiato.com/unicode/nfc-faq| NFC normalized]. + Strings are always [https://en.wikipedia.org/wiki/Unicode_equivalence|NFC normalized]. ``strip_spaces`` is new in Robot Framework 4.0 and ``collapse_spaces`` is new in Robot Framework 4.1. From b86f903142c19fb9860e1cfa2f389968f44c7324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Jun 2023 10:58:44 +0300 Subject: [PATCH 0603/1592] Libdoc: Show `Mapping` as a converted type for `TypedDict`. Fixes #4781. --- atest/robot/libdoc/datatypes_py-json.robot | 2 +- atest/robot/libdoc/datatypes_py-xml.robot | 2 +- src/robot/libdocpkg/datatypes.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/atest/robot/libdoc/datatypes_py-json.robot b/atest/robot/libdoc/datatypes_py-json.robot index f9443b50d9c..b28fbf9b75c 100644 --- a/atest/robot/libdoc/datatypes_py-json.robot +++ b/atest/robot/libdoc/datatypes_py-json.robot @@ -123,7 +123,7 @@ Accepted types ${MODEL}[typedocs][4][type] Custom ${MODEL}[typedocs][4][accepts] [] ${MODEL}[typedocs][7][type] TypedDict - ${MODEL}[typedocs][7][accepts] ['string'] + ${MODEL}[typedocs][7][accepts] ['string', 'Mapping'] ${MODEL}[typedocs][1][type] Enum ${MODEL}[typedocs][1][accepts] ['string'] ${MODEL}[typedocs][11][type] Enum diff --git a/atest/robot/libdoc/datatypes_py-xml.robot b/atest/robot/libdoc/datatypes_py-xml.robot index 5ace2e3f96e..821200ca728 100644 --- a/atest/robot/libdoc/datatypes_py-xml.robot +++ b/atest/robot/libdoc/datatypes_py-xml.robot @@ -75,7 +75,7 @@ Accepted types ... string integer Accepted Types Should Be 4 Custom CustomType2 Accepted Types Should Be 7 TypedDict GeoLocation - ... string + ... string Mapping Accepted Types Should Be 1 Enum AssertionOperator ... string Accepted Types Should Be 11 Enum Small diff --git a/src/robot/libdocpkg/datatypes.py b/src/robot/libdocpkg/datatypes.py index 6e4b66c9770..e2482c31525 100644 --- a/src/robot/libdocpkg/datatypes.py +++ b/src/robot/libdocpkg/datatypes.py @@ -81,7 +81,7 @@ def for_typed_dict(cls, typed_dict): required = key in required_keys if required_keys or optional_keys else None items.append(TypedDictItem(key, typ, required)) return cls(cls.TYPED_DICT, typed_dict.__name__, getdoc(typed_dict), - accepts=(str,), items=items) + accepts=(str, 'Mapping'), items=items) def to_dictionary(self, legacy=False): data = { From ae13950fc941c2d2e2413879c5fc123c64f46474 Mon Sep 17 00:00:00 2001 From: Yuri Verweij <yuri@yufra.nl> Date: Fri, 2 Jun 2023 11:31:10 +0200 Subject: [PATCH 0604/1592] Add support for ignoring keys to `Dictionaries Should Be Equal` (#3942) Issue #2717. --- .../collections/dictionary.robot | 9 +++++++++ .../collections/dictionary.robot | 17 +++++++++++++++++ src/robot/libraries/Collections.py | 11 ++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/atest/robot/standard_libraries/collections/dictionary.robot b/atest/robot/standard_libraries/collections/dictionary.robot index 913ddb52080..bba0248cd54 100644 --- a/atest/robot/standard_libraries/collections/dictionary.robot +++ b/atest/robot/standard_libraries/collections/dictionary.robot @@ -116,6 +116,15 @@ Dictionaries Should Be Equal Dictionaries Of Different Type Should Be Equal Check Test Case ${TEST NAME} +Dictionaries Should Equal With Ignored Key + Check Test Case ${TEST NAME} + +Dictionaries Should Equal With Ignored Key And Missing Key + Check Test Case ${TEST NAME} + +Dictionaries Should Equal With Ignored Key And Missing Key And Own Error Message + Check Test Case ${TEST NAME} + Dictionaries Should Equal With First Dictionary Missing Keys Check Test Case ${TEST NAME} diff --git a/atest/testdata/standard_libraries/collections/dictionary.robot b/atest/testdata/standard_libraries/collections/dictionary.robot index 740bdd1ab8a..f332c64bc5b 100644 --- a/atest/testdata/standard_libraries/collections/dictionary.robot +++ b/atest/testdata/standard_libraries/collections/dictionary.robot @@ -196,6 +196,23 @@ Dictionaries Should Equal With Both Dictionaries Missing Keys ${x} ${y} = Evaluate dict(a=1, b=2), dict(a=0, c=3, d=4) Dictionaries Should Be Equal ${x} ${y} +Dictionaries Should Equal With Ignored Key + [Documentation] PASS + ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, d=4), list('d') + Dictionaries Should Be Equal ${x} ${y} ignore_keys=${z} + +Dictionaries Should Equal With Ignored Key And Missing Key + [Documentation] FAIL + ... Following keys missing from first dictionary: c + ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, c=3, d=4), list('d') + Dictionaries Should Be Equal ${x} ${y} ignore_keys=${z} + +Dictionaries Should Equal With Ignored Key And Missing Key And Own Error Message + [Documentation] FAIL My error message! + ... Following keys missing from first dictionary: c + ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, c=3, d=4), list('d') + Dictionaries Should Be Equal ${x} ${y} My error message! ignore_keys=${z} + Dictionaries Should Be Equal With Different Keys And Own Error Message [Documentation] FAIL My error message! Dictionaries Should Be Equal ${D2} ${D3} My error message! NO values diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index c07774a8a15..24434e9cbf8 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -760,7 +760,7 @@ def dictionary_should_not_contain_value(self, dictionary, value, msg=None): f"Dictionary contains value '{value}'.", msg) - def dictionaries_should_be_equal(self, dict1, dict2, msg=None, values=True): + def dictionaries_should_be_equal(self, dict1, dict2, msg=None, values=True, ignore_keys=None): """Fails if the given dictionaries are not equal. First the equality of dictionaries' keys is checked and after that all @@ -768,11 +768,20 @@ def dictionaries_should_be_equal(self, dict1, dict2, msg=None, values=True): are listed in the error message. The types of the dictionaries do not need to be same. + ``ignore_keys`` can be used to provide a list of keys to ignore in the + comparison. + See `Lists Should Be Equal` for more information about configuring the error message with ``msg`` and ``values`` arguments. """ + + if ignore_keys is None: + ignore_keys = [] self._validate_dictionary(dict1) self._validate_dictionary(dict2, 2) + dict1 = {k: v for k, v in dict1.items() if k not in ignore_keys} + dict2 = {k: v for k, v in dict2.items() if k not in ignore_keys} + keys = self._keys_should_be_equal(dict1, dict2, msg, values) self._key_values_should_be_equal(keys, dict1, dict2, msg, values) From 04b7db3824b88d96aa57e88d353184ee5fde1a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Jun 2023 12:56:36 +0300 Subject: [PATCH 0605/1592] Enhance `Dictionaries Should Be Equal`'s `ignore_keys` support. Fixes #2717. --- .../collections/dictionary.robot | 24 +++++++---- .../collections/dictionary.robot | 43 +++++++++++-------- src/robot/libraries/Collections.py | 39 +++++++++++------ 3 files changed, 67 insertions(+), 39 deletions(-) diff --git a/atest/robot/standard_libraries/collections/dictionary.robot b/atest/robot/standard_libraries/collections/dictionary.robot index bba0248cd54..1c90a67152b 100644 --- a/atest/robot/standard_libraries/collections/dictionary.robot +++ b/atest/robot/standard_libraries/collections/dictionary.robot @@ -116,15 +116,6 @@ Dictionaries Should Be Equal Dictionaries Of Different Type Should Be Equal Check Test Case ${TEST NAME} -Dictionaries Should Equal With Ignored Key - Check Test Case ${TEST NAME} - -Dictionaries Should Equal With Ignored Key And Missing Key - Check Test Case ${TEST NAME} - -Dictionaries Should Equal With Ignored Key And Missing Key And Own Error Message - Check Test Case ${TEST NAME} - Dictionaries Should Equal With First Dictionary Missing Keys Check Test Case ${TEST NAME} @@ -149,6 +140,21 @@ Dictionaries Should Be Equal With Different Values And Own Error Message Dictionaries Should Be Equal With Different Values And Own And Default Error Messages Check Test Case ${TEST NAME} +Dictionaries Should Equal With Ignored Keys + Check Test Case ${TEST NAME} + +Dictionaries Should Equal With Ignored Keys And Missing Key + Check Test Case ${TEST NAME} + +Dictionaries Should Equal With Ignored Keys And Missing Key And Own Error Message + Check Test Case ${TEST NAME} + +Dictionaries Should Equal with non-list ignored keys + Check Test Case ${TEST NAME} + +Dictionaries Should Equal with invalid ignored keys + Check Test Case ${TEST NAME} + Dictionary Should Contain Sub Dictionary Check Test Case ${TEST NAME} diff --git a/atest/testdata/standard_libraries/collections/dictionary.robot b/atest/testdata/standard_libraries/collections/dictionary.robot index f332c64bc5b..a518b4318c1 100644 --- a/atest/testdata/standard_libraries/collections/dictionary.robot +++ b/atest/testdata/standard_libraries/collections/dictionary.robot @@ -196,23 +196,6 @@ Dictionaries Should Equal With Both Dictionaries Missing Keys ${x} ${y} = Evaluate dict(a=1, b=2), dict(a=0, c=3, d=4) Dictionaries Should Be Equal ${x} ${y} -Dictionaries Should Equal With Ignored Key - [Documentation] PASS - ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, d=4), list('d') - Dictionaries Should Be Equal ${x} ${y} ignore_keys=${z} - -Dictionaries Should Equal With Ignored Key And Missing Key - [Documentation] FAIL - ... Following keys missing from first dictionary: c - ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, c=3, d=4), list('d') - Dictionaries Should Be Equal ${x} ${y} ignore_keys=${z} - -Dictionaries Should Equal With Ignored Key And Missing Key And Own Error Message - [Documentation] FAIL My error message! - ... Following keys missing from first dictionary: c - ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, c=3, d=4), list('d') - Dictionaries Should Be Equal ${x} ${y} My error message! ignore_keys=${z} - Dictionaries Should Be Equal With Different Keys And Own Error Message [Documentation] FAIL My error message! Dictionaries Should Be Equal ${D2} ${D3} My error message! NO values @@ -242,6 +225,32 @@ Dictionaries Should Be Equal With Different Values And Own And Default Error Mes ... Key b: 2 != x Dictionaries Should Be Equal ${D2} ${D2B} My error message! +Dictionaries Should Equal With Ignored Keys + ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, d=4), list('d') + Dictionaries Should Be Equal ${x} ${y} ignore_keys=${z} + +Dictionaries Should Equal With Ignored Keys And Missing Key + [Documentation] FAIL + ... Following keys missing from first dictionary: c + ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, c=3, d=4), list('d') + Dictionaries Should Be Equal ${x} ${y} ignore_keys=${z} + +Dictionaries Should Equal With Ignored Keys And Missing Key And Own Error Message + [Documentation] FAIL My error message! + ... Following keys missing from first dictionary: c + ${x} ${y} ${z} = Evaluate dict(a=1, b=2), dict(a=1, b=2, c=3, d=4), list('d') + Dictionaries Should Be Equal ${x} ${y} My error message! ignore_keys=${z} + +Dictionaries Should Equal with non-list ignored keys + [Documentation] FAIL ValueError: 'ignore_keys' must be list-like, got integer. + ${x} ${y} = Evaluate dict(a=1, b=2), dict(a=1, b=2, d=4) + Dictionaries Should Be Equal ${x} ${y} ignore_keys=42 + +Dictionaries Should Equal with invalid ignored keys + [Documentation] FAIL STARTS: ValueError: Converting 'ignore_keys' to a list failed: SyntaxError: + ${x} ${y} = Evaluate dict(a=1, b=2), dict(a=1, b=2, d=4) + Dictionaries Should Be Equal ${x} ${y} ignore_keys=!?# + Dictionary Should Contain Sub Dictionary Dictionary Should Contain Sub Dictionary ${D3} ${D2} Dictionary Should Contain Sub Dictionary ${D3} ${D0} diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 24434e9cbf8..8ddce82960a 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -14,10 +14,11 @@ # limitations under the License. import copy +from ast import literal_eval from robot.api import logger -from robot.utils import (is_dict_like, is_list_like, is_string, is_truthy, Matcher, - plural_or_not as s, seq2str, seq2str2, type_name) +from robot.utils import (get_error_message, is_dict_like, is_list_like, is_truthy, + Matcher, plural_or_not as s, seq2str, seq2str2, type_name) from robot.utils.asserts import assert_equal from robot.version import get_version @@ -760,7 +761,8 @@ def dictionary_should_not_contain_value(self, dictionary, value, msg=None): f"Dictionary contains value '{value}'.", msg) - def dictionaries_should_be_equal(self, dict1, dict2, msg=None, values=True, ignore_keys=None): + def dictionaries_should_be_equal(self, dict1, dict2, msg=None, values=True, + ignore_keys=None): """Fails if the given dictionaries are not equal. First the equality of dictionaries' keys is checked and after that all @@ -769,19 +771,31 @@ def dictionaries_should_be_equal(self, dict1, dict2, msg=None, values=True, igno need to be same. ``ignore_keys`` can be used to provide a list of keys to ignore in the - comparison. + comparison. It can be an actual list or a Python list literal. This + option is new in Robot Framework 6.1. + + Examples: + | Dictionaries Should Be Equal | ${dict} | ${expected} | + | Dictionaries Should Be Equal | ${dict} | ${expected} | ignore_keys=${ignored} | + | Dictionaries Should Be Equal | ${dict} | ${expected} | ignore_keys=['key1', 'key2'] | See `Lists Should Be Equal` for more information about configuring the error message with ``msg`` and ``values`` arguments. """ - - if ignore_keys is None: - ignore_keys = [] self._validate_dictionary(dict1) self._validate_dictionary(dict2, 2) - dict1 = {k: v for k, v in dict1.items() if k not in ignore_keys} - dict2 = {k: v for k, v in dict2.items() if k not in ignore_keys} - + if ignore_keys: + if isinstance(ignore_keys, str): + try: + ignore_keys = literal_eval(ignore_keys) + except Exception: + raise ValueError("Converting 'ignore_keys' to a list failed: " + + get_error_message()) + if not is_list_like(ignore_keys): + raise ValueError(f"'ignore_keys' must be list-like, " + f"got {type_name(ignore_keys)}.") + dict1 = {k: v for k, v in dict1.items() if k not in ignore_keys} + dict2 = {k: v for k, v in dict2.items() if k not in ignore_keys} keys = self._keys_should_be_equal(dict1, dict2, msg, values) self._key_values_should_be_equal(keys, dict1, dict2, msg, values) @@ -1039,7 +1053,7 @@ def _verify_condition(condition, default_msg, msg, values=False): def _get_matches_in_iterable(iterable, pattern, case_insensitive=False, whitespace_insensitive=False): - if not is_string(pattern): + if not isinstance(pattern, str): raise TypeError(f"Pattern must be string, got '{type_name(pattern)}'.") regexp = False if pattern.startswith('regexp='): @@ -1051,5 +1065,4 @@ def _get_matches_in_iterable(iterable, pattern, case_insensitive=False, caseless=is_truthy(case_insensitive), spaceless=is_truthy(whitespace_insensitive), regexp=regexp) - return [string for string in iterable - if is_string(string) and matcher.match(string)] + return [item for item in iterable if isinstance(item, str) and matcher.match(item)] From 79eb2725ac7b5728aaed75711754e315b415ab1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Jun 2023 15:52:06 +0300 Subject: [PATCH 0606/1592] Bump octokit/request-action from 2.1.7 to 2.1.8 (#4779) Bumps [octokit/request-action](https://github.com/octokit/request-action) from 2.1.7 to 2.1.8. - [Release notes](https://github.com/octokit/request-action/releases) - [Commits](https://github.com/octokit/request-action/compare/89a1754fe82ca777b044ca8e79e9881a42f15a93...352d2ae93e1805721b5fe308598555ba3bd2c8e2) --- updated-dependencies: - dependency-name: octokit/request-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/acceptance_tests_cpython_pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 15aa4f02ee3..d927397784b 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -123,7 +123,7 @@ jobs: echo "JOB_STATUS=$(python -c "print('${{ job.status }}'.lower())")" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: always() && job.status == 'failure' && runner.os == 'Windows' - - uses: octokit/request-action@89a1754fe82ca777b044ca8e79e9881a42f15a93 + - uses: octokit/request-action@352d2ae93e1805721b5fe308598555ba3bd2c8e2 name: Update status with Github Status API id: update_status with: diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 597613ebee5..c8cd723f56a 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -111,7 +111,7 @@ jobs: echo "JOB_STATUS=$(python -c "print('${{ job.status }}'.lower())")" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: always() && job.status == 'failure' && runner.os == 'Windows' - - uses: octokit/request-action@89a1754fe82ca777b044ca8e79e9881a42f15a93 + - uses: octokit/request-action@352d2ae93e1805721b5fe308598555ba3bd2c8e2 name: Update status with Github Status API id: update_status with: From 3f1c3ddaeb823cbb5627866e214c300ff6d4865f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 2 Jun 2023 16:20:52 +0300 Subject: [PATCH 0607/1592] Add localizations for the new `Name` setting (#4583) Localization added to fr, es, ro and it. --- src/robot/conf/languages.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 61e59f6b603..0bf529ef463 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -532,6 +532,7 @@ class Fr(Language): library_setting = 'Bibliothèque' resource_setting = 'Ressource' variables_setting = 'Variable' + name_setting = 'Nom' documentation_setting = 'Documentation' metadata_setting = 'Méta-donnée' suite_setup_setting = 'Mise en place de suite' @@ -819,6 +820,7 @@ class Es(Language): library_setting = 'Biblioteca' resource_setting = 'Recursos' variables_setting = 'Variable' + name_setting = 'Nombre' documentation_setting = 'Documentación' metadata_setting = 'Metadatos' suite_setup_setting = 'Configuración de la Suite' @@ -1105,6 +1107,7 @@ class Ro(Language): library_setting = 'Librarie' resource_setting = 'Resursa' variables_setting = 'Variabila' + name_setting = 'Nume' documentation_setting = 'Documentatie' metadata_setting = 'Metadate' suite_setup_setting = 'Configurare De Suita' @@ -1146,6 +1149,7 @@ class It(Language): library_setting = 'Libreria' resource_setting = 'Risorsa' variables_setting = 'Variabile' + name_setting = 'Nome' documentation_setting = 'Documentazione' metadata_setting = 'Metadati' suite_setup_setting = 'Configurazione Suite' From f1997a68389ea5aef8e7044603e488ef670fb915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 5 Jun 2023 17:47:05 +0300 Subject: [PATCH 0608/1592] Small documentation enhancements --- .../src/ExtendingRobotFramework/ParserInterface.rst | 4 ++-- src/robot/model/testsuite.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst index 5e2f70f76e0..00a7ee7ff30 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ParserInterface.rst @@ -193,11 +193,11 @@ that supports headers in format `=== Test Cases ===` in addition to extension = '.robot' def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: - name = TestSuite.name_from_source(source) data = source.read_text() for header in 'Settings', 'Variables', 'Test Cases', 'Keywords': data = data.replace(f'=== {header} ===', f'*** {header} ***') - return TestSuite.from_string(data, defaults=defaults).config(name=name) + suite = TestSuite.from_string(data, defaults=defaults) + return suite.config(name=TestSuite.name_from_source(source), source=source) __ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_string __ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_model diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 45e2828723c..a2f62a1c9fa 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -164,15 +164,15 @@ def adjust_source(self, relative_to: 'Path|str|None' = None, Adjusting the source is especially useful when moving data around as JSON:: - from robot.running import TestSuite + from robot.api import TestSuite # Create a suite, adjust source and convert to JSON. suite = TestSuite.from_file_system('/path/to/data') suite.adjust_source(relative_to='/path/to') - suite.to_json('data.json') + suite.to_json('data.rbt') # Recreate suite elsewhere and adjust source accordingly. - suite = TestSuite.from_json('data.json') + suite = TestSuite.from_json('data.rbt') suite.adjust_source(root='/new/path/to') New in Robot Framework 6.1. From c7d94b4036c433305cefabb575a48cdcf327e6bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 5 Jun 2023 17:47:21 +0300 Subject: [PATCH 0609/1592] Release notes for 6.1rc1 --- doc/releasenotes/rf-6.1rc1.rst | 1355 ++++++++++++++++++++++++++++++++ 1 file changed, 1355 insertions(+) create mode 100644 doc/releasenotes/rf-6.1rc1.rst diff --git a/doc/releasenotes/rf-6.1rc1.rst b/doc/releasenotes/rf-6.1rc1.rst new file mode 100644 index 00000000000..c35e2be20ef --- /dev/null +++ b/doc/releasenotes/rf-6.1rc1.rst @@ -0,0 +1,1355 @@ +======================================= +Robot Framework 6.1 release candidate 1 +======================================= + +.. default-role:: code + +`Robot Framework`_ 6.1 is a new feature release with support for converting +Robot Framework data to JSON and back, a new external parser API, possibility +to mix embedded and normal arguments, and various other interesting new features +both for normal users and for external tool developers. This release candidate +contains all planned fixes and features. We hope that all Robot Framework users +could test it in their environments to help us find possible regressions before +the final release. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.1rc1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.1 rc 1 was released on Monday June 5, 2023. The final release +is targeted for Monday, June 12, 2023. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +JSON data format +---------------- + +The biggest new feature in Robot Framework 6.1 is the possibility to convert +test/task data to JSON and back (`#3902`_). This functionality has three main +use cases: + +- Transferring suites between processes and machines. A suite can be converted + to JSON in one machine and recreated somewhere else. +- Possibility to save a suite, possible a nested suite, constructed from data + on the file system into a single file that is faster to parse. +- Alternative data format for external tools generating tests or tasks. + +This feature is designed more for tool developers than for regular Robot Framework +users and we expect new interesting tools to emerge in the future. The main +functionalities are explained below: + +1. You can serialize a suite structure into JSON by using `TestSuite.to_json`__ + method. When used without arguments, it returns JSON data as a string, but + it also accepts a path or an open file where to write JSON data along with + configuration options related to JSON formatting: + + .. sourcecode:: python + + from robot.api import TestSuite + + suite = TestSuite.from_file_system('/path/to/data') + suite.to_json('data.rbt') + + If you would rather work with Python data and then convert that to JSON + or some other format yourself, you can use `TestSuite.to_dict`__ instead. + +2. You can create a suite based on JSON data using `TestSuite.from_json`__. + It works both with JSON strings and paths to JSON files: + + .. sourcecode:: python + + from robot.api import TestSuite + + suite = TestSuite.from_json('data.rbt') + + If you hava data as a Python dictionary, you can use `TestSuite.from_dict`__ + instead. + +3. When using the `robot` command normally, JSON files with the `.rbt` extension + are parsed automatically. This includes running individual JSON files like + `robot tests.rbt` and running directories containing `.rbt` files. + +Suite source information in the data got from `TestSuite.to_json` and +`TestSuite.to_dict` is in absolute format. If a suite is recreated later on +a different machine, the source may thus not match the directory structure on +that machine. To avoid such problems, it is possible to use the new +`TestSuite.adjust_source`__ method to make the suite source relative +before getting the data and add a correct root directory after the suite is +recreated: + +.. sourcecode:: python + + from robot.api import TestSuite + + # Create a suite, adjust source and convert to JSON. + suite = TestSuite.from_file_system('/path/to/data') + suite.adjust_source(relative_to='/path/to') + suite.to_json('data.rbt') + + # Recreate suite elsewhere and adjust source accordingly. + suite = TestSuite.from_json('data.rbt') + suite.adjust_source(root='/new/path/to') + +Ths JSON serialization support can be enhanced in future Robot Framework versions. +We try to keep the data format stable, but it is possible that some changes are +needed. If you have an enhancement idea or believe you have encountered a bug, +please submit an issue issue start a discussion thread on the `#devel` channel +on our Slack_. + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_json +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_dict +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_json +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_dict +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.adjust_source + +External parser API +------------------- + +The parser API is another important new interface targeted for tool developers +(`#1283`_). It makes it possible to create custom parsers that can handle their +own data formats or even override Robot Framework's own parser. + +Parsers are taken into use from the command line using the new `--parser` option +the same way as, for example, listeners. This includes specifying parsers as +names or paths, giving arguments to parser classes, and so on:: + + robot --parser MyParser tests.custom + robot --parser path/to/MyParser.py tests.custom + robot --parser Parser1:arg --parser Parser2:a1:a2 path/to/tests + +In simple cases parsers can be implemented as modules. They only thing they +need is an `EXTENSION` or `extension` attribute that specifies the extension +or extensions they support, and a `parse` method that gets the path of the +source file to parse as an argument: + +.. sourcecode:: python + + from robot.api import TestSuite + + EXTENSION = '.example' + + def parse(source): + suite = TestSuite(name='Example', source=source) + test = suite.tests.create(name='Test') + test.body.create_keyword(name='Log', args=['Hello!']) + return suite + +As the example demonstrates, the `parse` method must return a TestSuite__ +instance. In the above example the suite contains only some dummy data and +the source file is not actually parsed. + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite + +Parsers can also be implemented as classes which makes it possible for them to +preserve state and allows passing arguments from the command like. The following +example illustrates that and, unlike the previous example, actually processes the +source file: + +.. sourcecode:: python + + from pathlib import Path + from robot.api import TestSuite + + + class ExampleParser: + + def __init__(self, extension: str): + self.extension = extension + + def parse(self, source: Path) -> TestSuite: + suite = TestSuite(TestSuite.name_from_source(source), source=source) + for line in source.read_text().splitlines(): + test = suite.tests.create(name=line) + test.body.create_keyword(name='Log', args=['Hello!']) + return suite + +As the earlier examples have demonstrated, parsers do not need to extend any +explicit base class or interface. There is, however, an optional Parser__ +base class that can be extended. The following example +does that and has also two other differences compared to earlier examples: + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.api.html#robot.api.interfaces.Parser + +- The parser has optional `parse_init` file for parsing suite initialization files. +- Both `parse` and `parse_init` accept optional `defaults` argument. When this + second argument is present, the `parse` method gets a TestDefaults__ instance + that contains possible test related default values (setup, teardown, tags and + timeout) from initialization files. Also `parse_init` can get it and possible + changes are seen by subsequently called `parse` methods. + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.builder.html#robot.running.builder.settings.TestDefaults + +.. sourcecode:: python + + from pathlib import Path + from robot.api import TestSuite + from robot.api.interfaces import Parser, TestDefaults + + + class ExampleParser(Parser): + extension = ('example', 'another') + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + """Create a suite and set possible defaults from init files to tests.""" + suite = TestSuite(TestSuite.name_from_source(source), source=source) + for line in source.read_text().splitlines(): + test = suite.tests.create(name=line, doc='Example') + test.body.create_keyword(name='Log', args=['Hello!']) + defaults.set_to(test) + return suite + + def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: + """Create a dummy suite and set some defaults. + + This method is called only if there is an initialization file with + a supported extension. + """ + defaults.tags = ('tags', 'from init') + defaults.setup = {'name': 'Log', 'args': ['Hello from init!']} + return TestSuite(TestSuite.name_from_source(source.parent), doc='Example', + source=source, metadata={'Example': 'Value'}) + +The final parser acts as a preprocessor for Robot Framework data files that +supports headers in format `=== Test Cases ===` in addition to +`*** Test Cases ***`. In this kind of usage it is convenient to use +`TestSuite.from_string`__, `TestSuite.from_model`__ or +`TestSuite.from_file_system`__ factory methods for constructing the returned suite. + +.. sourcecode:: python + + from pathlib import Path + from robot.running import TestDefaults, TestSuite + + class RobotPreprocessor: + extension = '.robot' + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + data = source.read_text() + for header in 'Settings', 'Variables', 'Test Cases', 'Keywords': + data = data.replace(f'=== {header} ===', f'*** {header} ***') + suite = TestSuite.from_string(data, defaults=defaults) + return suite.config(name=TestSuite.name_from_source(source), source=source) + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_string +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_model +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_file_system + +User keywords with both embedded and normal arguments +----------------------------------------------------- + +User keywords can nowadays mix embedded arguments and normal arguments (`#4234`_). +For example, this kind of usage is possible: + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Number of horses is 2 + Number of dogs is 3 + + *** Keywords *** + Number of ${animals} is + [Arguments] ${count} + Log to console There are ${count} ${animals}. + +This only works with user keywords at least for now. If there is interest, +the support can be extended to library keywords in future releases. + +Support item assignment with lists and dictionaries +--------------------------------------------------- + +Robot Framework 6.1 makes it possible to assign return values from keywords +to list and dictionary items (`#4546`_):: + + ${list}[0] = Keyword + ${dict}[key] = Keyword + ${result}[users][0] = Keyword + +Possibility to flatten keyword structures during execution +---------------------------------------------------------- + +With nested keyword structures, especially with recursive keyword calls and with +WHILE and FOR loops, the log file can get hard to understand with many different +nesting levels. Such nested structures also increase the size of the output.xml +file. For example, even a simple keyword like: + +.. sourcecode:: robotframework + + *** Keywords *** + Example + Log Robot + Log Framework + +creates this much content in output.xml: + +.. sourcecode:: xml + + <kw name="Example"> + <kw name="Log" library="BuiltIn"> + <arg>Robot</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.663"/> + </kw> + <kw name="Log" library="BuiltIn"> + <arg>Framework</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + +We already have the `--flattenkeywords` option for "flattening" such structures +and it works great. When a keyword is flattened, its child keywords and control +structures are removed otherwise, but all their messages (`<msg>` elements) are +preserved. Using `--flattenkeywords` does not affect output.xml generated during +execution, but flattening happens when output.xml files are parsed and can save +huge amounts of memory. When `--flattenkeywords` is used with Rebot, it is +possible to create a new flattened output.xml. For example, the above structure +is converted into this if the `Example` keyword is flattened using `--flattenkeywords`: + +.. sourcecode:: xml + + <kw name="Keyword"> + <doc>_*Content flattened.*_</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + +Starting from Robot Framework 6.1, this kind of flattening can be done also +during execution and without using command line options. The only thing needed +is using the new keyword tag `robot:flatten` (`#4584`_) and flattening is done +automatically. For example, if the earlier `Keyword` is changed to: + +.. sourcecode:: robotframework + + *** Keywords *** + Example + [Tags] robot:flatten + Log Robot + Log Framework + +the result in output.xml will be this: + +.. sourcecode:: xml + + <kw name="Example"> + <tag>robot:flatten</tag> + <msg timestamp="20230317 00:54:34.772" level="INFO">Robot</msg> + <msg timestamp="20230317 00:54:34.772" level="INFO">Framework</msg> + <status status="PASS" starttime="20230317 00:54:34.771" endtime="20230317 00:54:34.772"/> + </kw> + +The main benefit of using `robot:flatten` instead of `--flattenkeywords` is that +it is used already during execution making the resulting output.xml file +smaller. `--flattenkeywords` has more configuration options than `robot:flatten`, +though, but `robot:flatten` can be enhanced in that regard later if there are +needs. + +Type information added to public APIs +------------------------------------- + +Robot Framework has several public APIs that library and tool developers can +use. These APIs nowadays have type hints making their usage easier: + +- The `TestSuite` structure used by listeners, model modifiers, external parsers, + and various other tools (`#4570`_) +- Listener API (`#4568`_) +- Dynamic and hybrid library APIs (`#4567`_) +- Parsing API (`#4740`_) +- Visitor API (`#4569`_) + +Custom argument converters can access library +--------------------------------------------- + +Support for custom argument converters was added in Robot Framework 5.0 +(`#4088`__) and they have turned out to be really useful. This functionality +is now enhanced so that converters can easily get an access to the +library containing the keyword that is used and can thus do conversion +based on the library state (`#4510`_). This can be done simply by creating +a converter that accepts two values. The first value is the value used in +the data, exactly as earlier, and the second is the library instance or module: + +.. sourcecode:: python + + def converter(value, library): + ... + +Converters accepting only one argument keep working as earlier. There are no +plans to require changing them to accept two values. + +__ https://github.com/robotframework/robotframework/issues/4088 + +JSON variable file support +-------------------------- + +It has been possible to create variable files using YAML in addition to Python +for long time, and nowadays also JSON variable files are supported (`#4532`_). +For example, a JSON file containing: + +.. sourcecode:: json + + { + "STRING": "Hello, world!", + "INTEGER": 42 + } + +could be used like this: + +.. sourcecode:: robotframework + + *** Settings *** + Variables example.json + + *** Test Cases *** + Example + Should Be Equal ${STRING} Hello, world! + Should Be Equal ${INTEGER} ${42} + + +`WHILE` loop enhancements +------------------------- + +Robot Framework's WHILE__ loop has been enhanced in several different ways: + +- The biggest enhancement is that `WHILE` loops got an optional + `on_limit` configuration option that controls what to do if the configured + loop `limit` is reached (`#4562`_). By default execution fails, but setting + the option to `PASS` changes that. For example, the following loop runs ten + times and continues execution afterwards: + + .. sourcecode:: robotframework + + *** Test Cases *** + WHILE with 'limit' and 'on_limit' + WHILE True limit=10 on_limit=PASS + Log to console Hello! + END + Log to console Hello once more! + +- The loop condition is nowadays optional (`#4576`_). For example, the above + loop header could be simplified to this:: + + WHILE limit=10 on_limit=PASS + +- New `on_limit_message` configuration option can be used to set the message + that is used if the loop limit exceeds and the loop fails (`#4575`_). + +- A bug with the loop limit in teardowns has been fixed (`#4744`_). + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#while-loops + +`FOR IN ZIP` loop behavior if lists lengths differ can be configured +-------------------------------------------------------------------- + +Robot Framework's `FOR IN ZIP`__ loop behaves like Python's zip__ function so +that if lists lengths are not the same, items from longer ones are ignored. +For example, the following loop is executed only twice: + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#for-in-zip-loop +__ https://docs.python.org/3/library/functions.html#zip + +.. sourcecode:: robotframework + + *** Variables *** + @{ANIMALS} dog cat horse cow elephant + @{ELÄIMET} koira kissa + + *** Test Cases *** + Example + FOR ${en} ${fi} IN ZIP ${ANIMALS} ${ELÄIMET} + Log ${en} is ${fi} in Finnish + END + +This behavior can cause problems when iterating over items received from +the automated system. For example, the following test would pass regardless +how many things `Get something` returns as long as the returned items match +the expected values. The example succeeds if `Get something` returns ten items +if three first ones match. What's even worse, it succeeds also if `Get something` +returns nothing. + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Validate something expected 1 expected 2 expected 3 + + *** Keywords **** + Validate something + [Arguments] @{expected} + @{actual} = Get something + FOR ${act} ${exp} IN ZIP ${actual} ${expected} + Validate one thing ${act} ${exp} + END + +This situation is pretty bad because it can cause false positives where +automation succeeds but nothing is actually done. Python itself has this +same issue, and Python 3.10 added new optional `strict` argument to `zip` +(`PEP 681`__). In addition to that, Python has for long time had a separate +`zip_longest`__ function that loops over all values possibly filling-in +values to shorter lists. + +__ https://peps.python.org/pep-0618/ +__ https://docs.python.org/3/library/itertools.html#itertools.zip_longest + +To support the same features as Python, Robot Framework's `FOR IN ZIP` +loops now have an optional `mode` configuration option that accepts three +values (`#4682`_): + +- `STRICT`: Lists must have equal lengths. If not, execution fails. This is + the same as using `strict=True` with Python's `zip` function. +- `SHORTEST`: Items in longer lists are ignored. Infinitely long lists are supported + in this mode as long as one of the lists is exhausted. This is the current + default behavior. +- `LONGEST`: The longest list defines how many iterations there are. Missing + values in shorter lists are filled-in with value specified using the `fill` + option or `None` if it is not used. This is the same as using Python's + `zip_longest` function except that it has `fillvalue` argument instead of + `fill`. + +All these modes are illustrated by the following examples: + +.. sourcecode:: robotframework + + *** Variables *** + @{CHARACTERS} a b c d f + @{NUMBERS} 1 2 3 + + *** Test Cases *** + STRICT mode + [Documentation] This loop fails due to lists lengths being different. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=STRICT + Log ${c}: ${n} + END + + SHORTEST mode + [Documentation] This loop executes three times. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=SHORTEST + Log ${c}: ${n} + END + + LONGEST mode + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `None`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST + Log ${c}: ${n} + END + + LONGEST mode with custom fill value + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `-`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST fill=- + Log ${c}: ${n} + END + +This enhancement makes it easy to activate strict validation and avoid +false positives. The default behavior is still problematic, though, and +the plan is to change it to `STRICT` `in the future`__. +Those who want to keep using the `SHORTEST` mode need to enable it explicitly. + +__ https://github.com/robotframework/robotframework/issues/4686 + +New pseudo log level `CONSOLE` +------------------------------ + +There are often needs to log something to the console while tests or tasks +are running. Some keywords support it out-of-the-box and there is also +separate `Log To Console` keyword for that purpose. + +The new `CONSOLE` pseudo log level (`#4536`_) adds this support to *any* +keyword that accepts a log level such as `Log List` in Collections and +`Page Should Contain` in SeleniumLibrary. When this level is used, the message +is logged both to the console and on `INFO` level to the log file. + +Configuring virtual root suite when running multiple suites +----------------------------------------------------------- + +When execution multiple suites like `robot first.robot second.robot`, +Robot Framework creates a virtual root suite containing the executed +suites as child suites. Earlier this virtual suite could be +configured only by using command line options like `--name`, but now +it is possible to use normal suite initialization files (`__init__.robot`) +for that purpose (`#4015`_). If an initialization file is included +in the call like:: + + robot __init__.robot first.robot second.robot + +the root suite is configured based on data it contains. + +The most important feature this enhancement allows is specifying suite +setup and teardown to the virtual root suite. Earlier that was not possible +at all. + +Support for asynchronous functions and methods as keywords +---------------------------------------------------------- + +It is nowadays possible to use asynchronous functions (created using +`async def`) as keywords just like normal functions (`#4089`_). For example, +the following async functions could be used as keyword `Gather Something` and +`Async Sleep`: + +.. sourcecode:: python + + from asyncio import gather, sleep + + async def gather_something(): + print('start') + await gather(something(1), something(2), something(3)) + print('done') + + async def async_sleep(time: int): + await sleep(time) + +`zipapp` compatibility +---------------------- + +Robot Framework 6.1 is compatible with zipapp__ (`#4613`_). This makes it possible +to create standalone distributions using either only the `zipapp` module or +with a help from an external packaging tool like PDM__. + +__ https://docs.python.org/3/library/zipapp.html +__ https://pdm.fming.dev + +Python 3.12 compatibility +------------------------- + +Python 3.12 will be released in `October 2023`__. It contains a `subtle change +to tokenization`__ that affects Robot Framework's Python evaluation when the +special `$var` syntax is used. This issue has been fixed and Robot Framework 6.1 +is also otherwise Python 3.12 compatible (`#4771`_). + +__ https://peps.python.org/pep-0693/ +__ https://github.com/python/cpython/issues/104802 + + +Backwards incompatible changes +============================== + +We try to avoid backwards incompatible changes in general and especially in +non-major version. They cannot always be avoided, though, and there are some +features and fixes in this release that are not fully backwards compatible. +These changes *should not* cause problems in normal usage, but especially +tools using Robot Framework may nevertheless be affected. + +Changes to output.xml +--------------------- + +Syntax errors such as invalid settings like `[Setpu]` or `END` in a wrong place +are nowadays reported better (`#4683`_). Part of that change was storing +invalid constructs in output.xml as `<error>` elements. Tools processing +output.xml files so that they go through all elements need to take `<error>` +elements into account, but tools just querying information using xpath +expression or otherwise should not be affected. + +Another change is that with `FOR IN ENUMERATE` loops the `<for>` element +may get `start` attribute (`#4684`_) and with `FOR IN ZIP` loops it may get +`mode` and `fill` attributes (`#4682`_). This affects tools processing +all possible attributes, but such tools ought to be very rare. + +Changes to `TestSuite` model structure +-------------------------------------- + +The aforementioned enhancements for handling invalid syntax better (`#4683`_) +required changes also to the TestSuite__ model structure. Syntax errors are +nowadays represented as Error__ objects and they can appear in the `body` of +TestCase__, Keyword__, and other such model objects. Tools interacting with +the `TestSuite` structure should take `Error` objects into account, but tools +using the `visitor API`__ should in general not be affected. + +Another related change is that `doc`, `tags`, `timeout` and `teardown` attributes +were removed from the `robot.running.Keyword`__ object (`#4589`_). They were +left there accidentally and were not used for anything by Robot Framework. +Tools accessing them need to be updated. + +Finally, the `TestSuite.source`__ attribute is nowadays a `pathlib.Path`__ +instance instead of a string (`#4596`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testsuite.TestSuite +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.control.Error +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testcase.TestCase +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.keyword.Keyword +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#module-robot.model.visitor +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.Keyword +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testsuite.TestSuite.source +__ https://docs.python.org/3/library/pathlib.html + +Changes to parsing model +------------------------ + +Invalid section headers like `*** Bad ***` are nowadays represented in the +parsing model as InvalidSection__ objects when they earlier were generic +Error__ objects (`#4689`_). + +New ReturnSetting__ object has been introduced as an alias for Return__. +This does not yet change anything, but in the future `Return` will be used +for other purposes and tools using it should be updated to use `ReturnSetting` +instead (`#4656`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.blocks.InvalidSection +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Error +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Return +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.ReturnSetting + +Files are not excluded from parsing when using `--suite` option +--------------------------------------------------------------- + +Earlier when the `--suite` option was used, files not matching the specified +suite name were excluded from parsing altogether. This performance enhancement +was convenient especially with bigger suite structures, but it needed to be removed +(`#4688`_) because the new `Name` setting (`#4583`_) made it impossible to +get the suite name solely based on the file name. +Users who are affected by this change can use the new `--parseinclude` option +that explicitly specifies which files should be parsed (`#4687`_). + +Changes to Libdoc spec files +---------------------------- + +Libdoc did not handle parameterized types like `list[int]` properly earlier. +Fixing that problem required storing information about nested types into +the spec files along with the top level type. In addition to the parameterized +types, also unions are now handled differently than earlier, but with normal +types there are no changes. With JSON spec files changes were pretty small, +but XML spec files required a bit bigger changes. See issue `#4538`_ for more +details about what exactly has changed and how. + +Argument conversion changes +--------------------------- + +If an argument has multiple types, Robot Framework tries to do argument +conversion with all of them, from left to right, until one of them succeeds. +Earlier if a type was not recognized at all, the used value was returned +as-is without trying conversion with the remaining types. For example, if +a keyword like: + +.. sourcecode:: python + + def example(arg: Union[UnknownType, int]): + ... + +would be called like:: + + Example 42 + +the integer conversion would not be attempted and the keyword would get +string `42`. This was changed so that unrecognized types are just skipped +and in the above case integer conversion is nowadays done (`#4648`_). That +obviously changes the value the keyword gets to an integer. + +Another argument conversion change is that the `Any` type is now recognized +so that any value is accepted without conversion (`#4647`_). This change is +mostly backwards compatible, but in a special case where such an argument has +a default value like `arg: Any = 1` the behavior changes. Earlier when `Any` +was not recognized at all, conversion was attempted based on the default value +type. Nowadays when `Any` is recognized and explicitly not converted, +no conversion based on the default value is done either. The behavior change +can be avoided by using `arg: Union[int, Any] = 1` which is much better +typing in general. + +Changes affecting execution +--------------------------- + +Invalid settings in tests and keywords like `[Tasg]` are nowadays considered +syntax errors that cause failures at execution time (`#4683`_). They were +reported also earlier, but they did not affect execution. + +All invalid sections in resource files are considered to be syntax errors that +prevent importing the resource file (`#4689`_). Earlier having a `*** Test Cases ***` +header in a resource file caused such an error, but other invalid headers were +just reported as errors but imports succeeded. + +Deprecated features +=================== + +Python 3.7 support +------------------ + +Python 3.7 will reach its end-of-life in `June 2023`__. We have decided to +support it with Robot Framework 6.1 and its bug fix releases, but +Robot Framework 7.0 will not support it anymore (`#4637`_). + +We have already earlier deprecated Python 3.6 that reached its end-of-life +already in `December 2021`__ the same way. The reason we still support it +is that it is the default Python version in Red Hat Enterprise Linux 8 +that is still `actively supported`__. + +__ https://peps.python.org/pep-0537/ +__ https://peps.python.org/pep-0494/ +__ https://endoflife.date/rhel + +Old elements in Libdoc spec files +--------------------------------- + +Libdoc spec files have been enhanced in latest releases. For backwards +compatibility reasons old information has been preserved, but all such data +will be removed in Robot Framework 7.0. For more details about what will be +removed see issue `#4667`__. + +__ https://github.com/robotframework/robotframework/issues/4667 + +Other deprecated features +------------------------- + +- Return__ node in the parsing model has been deprecated and ReturnSetting__ + should be used instead (`#4656`_). +- `name` argument of `TestSuite.from_model`__ has been deprecated and will be + removed in the future (`#4598`_). +- `accept_plain_values` argument of `robot.utils.timestr_to_secs` has been + deprecated and will be removed in the future (`#4522`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_model +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Return +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.ReturnSetting + + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its over 60 member organizations. If your organization is using Robot Framework +and benefiting from it, consider joining the foundation to support its +development as well. + +Robot Framework 6.1 team funded by the foundation consists of +`Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the community has provided several great contributions: + +- `@Serhiy1 <https://github.com/Serhiy1>`__ helped massively with adding type + information to the `TestSuite` structure (`#4570`_). + +- `@Vincema <https://github.com/Vincema>`__ added support for long command line + options with hyphens like `--pre-run-modifier` (`#4547`_) and implemented + possibility to assign keyword return values directly to list and dictionary items + (`#4546`_). + +- `@sunday2 <https://github.com/sunday2>`__ implemented JSON variable file support + (`#4532`_) and fixed User Guide generation on Windows (`#4680`_). + +- `Tatu Aalto <https://github.com/aaltat>`__ added positional-only argument + support to the dynamic library API (`#4660`_). + +- `@otemek <https://github.com/otemek>`__ implemented possibility to give + a custom name to a suite using a new `Name` setting (`#4583`_). + +- `@franzhaas <https://github.com/franzhaas>`__ made Robot Framework + `zipapp <https://docs.python.org/3/library/zipapp.html>`__ compatible (`#4613`_). + +- `Ygor Pontelo <https://github.com/ygorpontelo>`__ added support for using + asynchronous functions and methods as keywords (`#4089`_). + +- `@ursa-h <https://github.com/ursa-h>`__ enhanced keyword conflict resolution + so that library search order has higher precedence (`#4609`_). + +- `Jonathan Arns <https://github.com/JonathanArns>`__ and + `Fabian Zeiher <https://github.com/cetceeve>`__ made the initial implementation + to limit which files are parsed (`#4687`_). + +- `@asaout <https://github.com/asaout>`__ added `on_limit_message` option to WHILE + loops to control the failure message used if the loop limit is exceeded (`#4575`_). + +- `@turunenm <https://github.com/turunenm>`__ implemented `CONSOLE` pseudo log level + (`#4536`_). + +- `Yuri Verweij <https://github.com/yuriverweij>`__ enhanced `Dictionaries Should Be Equal` + so that it supports ignoring keys (`#2717`_). + +Big thanks to Robot Framework Foundation for the continued support, to community +members listed above for their valuable contributions, and to everyone else who +has submitted bug reports, proposed enhancements, debugged problems, or otherwise +helped to make Robot Framework 6.1 such a great release! + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + - Added + * - `#1283`_ + - enhancement + - critical + - External parser API for custom parsers + - beta 1 + * - `#3902`_ + - enhancement + - critical + - Support serializing executable suite into JSON + - alpha 1 + * - `#4234`_ + - enhancement + - critical + - Support user keywords with both embedded and normal arguments + - alpha 1 + * - `#4771`_ + - enhancement + - critical + - Python 3.12 compatibility + - rc 1 + * - `#4705`_ + - bug + - high + - Items are not converted when using generics like `list[int]` and passing object, not string + - beta 1 + * - `#4744`_ + - bug + - high + - WHILE limit doesn't work in teardown + - beta 1 + * - `#4015`_ + - enhancement + - high + - Support configuring virtual suite created when running multiple suites with `__init__.robot` + - alpha 1 + * - `#4089`_ + - enhancement + - high + - Support asynchronous functions and methods as keywords + - beta 1 + * - `#4510`_ + - enhancement + - high + - Make it possible for custom converters to get access to the library + - alpha 1 + * - `#4532`_ + - enhancement + - high + - JSON variable file support + - alpha 1 + * - `#4536`_ + - enhancement + - high + - Add new pseudo log level `CONSOLE` that logs to console and to log file + - alpha 1 + * - `#4546`_ + - enhancement + - high + - Support item assigment with lists and dicts like `${x}[key] = Keyword` + - rc 1 + * - `#4562`_ + - enhancement + - high + - Possibility to continue execution after WHILE limit is reached + - beta 1 + * - `#4570`_ + - enhancement + - high + - Add type information to `TestSuite` structure + - rc 1 + * - `#4584`_ + - enhancement + - high + - New `robot:flatten` tag for "flattening" keyword structures + - alpha 1 + * - `#4613`_ + - enhancement + - high + - Make Robot Framework compatible with `zipapp` + - beta 1 + * - `#4637`_ + - enhancement + - high + - Deprecate Python 3.7 + - alpha 1 + * - `#4682`_ + - enhancement + - high + - Make `FOR IN ZIP` loop behavior if lists have different lengths configurable + - alpha 1 + * - `#4538`_ + - bug + - medium + - Libdoc doesn't handle parameterized types like `list[int]` properly + - alpha 1 + * - `#4571`_ + - bug + - medium + - Suite setup and teardown are executed even if all tests are skipped + - alpha 1 + * - `#4589`_ + - bug + - medium + - Remove unused attributes from `robot.running.Keyword` model object + - alpha 1 + * - `#4604`_ + - bug + - medium + - Listeners do not get source information for keywords executed with `Run Keyword` + - alpha 1 + * - `#4626`_ + - bug + - medium + - Inconsistent argument conversion when using `None` as default value with Python 3.11 and earlier + - alpha 1 + * - `#4635`_ + - bug + - medium + - Dialogs created by `Dialogs` on Windows don't have focus + - alpha 1 + * - `#4648`_ + - bug + - medium + - Argument conversion should be attempted with all possible types even if some type wouldn't be recognized + - alpha 1 + * - `#4670`_ + - bug + - medium + - Parsing model: `Documentation.from_params(...).value` doesn't work + - beta 1 + * - `#4680`_ + - bug + - medium + - User Guide generation broken on Windows + - alpha 1 + * - `#4689`_ + - bug + - medium + - Invalid sections are not represented properly in parsing model + - alpha 1 + * - `#4692`_ + - bug + - medium + - `ELSE IF` condition not passed to listeners + - alpha 1 + * - `#4695`_ + - bug + - medium + - Accessing `id` property of model objects may cause `ValueError` + - beta 1 + * - `#4716`_ + - bug + - medium + - Variable nodes with nested variables report a parsing error, but work properly in the runtime + - beta 1 + * - `#4754`_ + - bug + - medium + - Back navigation does not work properly in HTML outputs (log, report, Libdoc) + - rc 1 + * - `#4756`_ + - bug + - medium + - Failed keywords inside skipped tests are not expanded + - rc 1 + * - `#2717`_ + - enhancement + - medium + - `Dictionaries Should Be Equal` should support ignoring keys + - rc 1 + * - `#3579`_ + - enhancement + - medium + - Enhance performance of selecting tests using `--include` and `--exclude` + - rc 1 + * - `#4210`_ + - enhancement + - medium + - Enhance error detection at parsing time + - alpha 1 + * - `#4547`_ + - enhancement + - medium + - Support long command line options with hyphens like `--pre-run-modifier` + - alpha 1 + * - `#4567`_ + - enhancement + - medium + - Add optional typed base class for dynamic library API + - alpha 1 + * - `#4568`_ + - enhancement + - medium + - Add optional typed base classes for listener API + - alpha 1 + * - `#4569`_ + - enhancement + - medium + - Add type information to the visitor API + - alpha 1 + * - `#4575`_ + - enhancement + - medium + - Add `on_limit_message` option to WHILE loops to control message used if loop limit is exceeded + - beta 1 + * - `#4576`_ + - enhancement + - medium + - Make the WHILE loop condition optional + - beta 1 + * - `#4583`_ + - enhancement + - medium + - Possibility to give a custom name to a suite using `Name` setting + - beta 1 + * - `#4601`_ + - enhancement + - medium + - Add `robot.running.TestSuite.from_string` method + - alpha 1 + * - `#4609`_ + - enhancement + - medium + - If multiple keywords match, resolve conflict first using search order + - rc 1 + * - `#4647`_ + - enhancement + - medium + - Add explicit argument converter for `Any` that does no conversion + - alpha 1 + * - `#4660`_ + - enhancement + - medium + - Dynamic API: Support positional-only arguments + - beta 1 + * - `#4666`_ + - enhancement + - medium + - Add public API to query is Robot running and is dry-run active + - alpha 1 + * - `#4676`_ + - enhancement + - medium + - Propose using `$var` syntax if evaluation IF or WHILE condition using `${var}` fails + - alpha 1 + * - `#4683`_ + - enhancement + - medium + - Report syntax errors better in log file + - alpha 1 + * - `#4684`_ + - enhancement + - medium + - Handle start index with `FOR IN ENUMERATE` loops already in parser + - alpha 1 + * - `#4687`_ + - enhancement + - medium + - Add explicit command line option to limit which files are parsed + - rc 1 + * - `#4688`_ + - enhancement + - medium + - Do not exclude files during parsing if using `--suite` option + - rc 1 + * - `#4729`_ + - enhancement + - medium + - Leading and internal spaces should be preserved in documentation + - beta 1 + * - `#4740`_ + - enhancement + - medium + - Add type hints to parsing API + - beta 1 + * - `#4765`_ + - enhancement + - medium + - Add forward compatible `start_time`, `end_time` and `elapsed_time` propertys to result objects + - rc 1 + * - `#4777`_ + - enhancement + - medium + - Parse files with `.robot.rst` extension automatically + - rc 1 + * - `#4627`_ + - enhancement + - medium + - Support custom converters that accept only `*varargs` + - beta 1 + * - `#4611`_ + - bug + - low + - Some unit tests cannot be run independently + - alpha 1 + * - `#4634`_ + - bug + - low + - Dialogs created by `Dialogs` are not centered and their minimum size is too small + - alpha 1 + * - `#4638`_ + - bug + - low + - Using bare `Union` as annotation is not handled properly + - alpha 1 + * - `#4646`_ + - bug + - low + - Bad error message when function is annotated with an empty tuple `()` + - alpha 1 + * - `#4663`_ + - bug + - low + - `BuiltIn.Log` documentation contains a defect + - alpha 1 + * - `#4736`_ + - bug + - low + - Backslash preventing newline in documentation can form escape sequence like `\n` + - beta 1 + * - `#4749`_ + - bug + - low + - Process: `Split/Join Command Line` do not work properly with `pathlib.Path` objects + - beta 1 + * - `#4780`_ + - bug + - low + - Libdoc crashes if it does not detect documentation format + - rc 1 + * - `#4781`_ + - bug + - low + - Libdoc: Type info for `TypedDict` doesn't list `Mapping` in converted types + - rc 1 + * - `#4522`_ + - enhancement + - low + - Deprecate `accept_plain_values` argument used by `timestr_to_secs` + - alpha 1 + * - `#4596`_ + - enhancement + - low + - Make `TestSuite.source` attribute `pathlib.Path` instance + - alpha 1 + * - `#4598`_ + - enhancement + - low + - Deprecate `name` argument of `TestSuite.from_model` + - alpha 1 + * - `#4619`_ + - enhancement + - low + - Dialogs created by `Dialogs` should bind `Enter` key to `OK` button + - alpha 1 + * - `#4636`_ + - enhancement + - low + - Buttons in dialogs created by `Dialogs` should get keyboard shortcuts + - alpha 1 + * - `#4656`_ + - enhancement + - low + - Deprecate `Return` node in parsing model + - alpha 1 + * - `#4709`_ + - enhancement + - low + - Add `__repr__()` method to NormalizedDict + - beta 1 + +Altogether 74 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1>`__. + +.. _#1283: https://github.com/robotframework/robotframework/issues/1283 +.. _#3902: https://github.com/robotframework/robotframework/issues/3902 +.. _#4234: https://github.com/robotframework/robotframework/issues/4234 +.. _#4771: https://github.com/robotframework/robotframework/issues/4771 +.. _#4705: https://github.com/robotframework/robotframework/issues/4705 +.. _#4744: https://github.com/robotframework/robotframework/issues/4744 +.. _#4015: https://github.com/robotframework/robotframework/issues/4015 +.. _#4089: https://github.com/robotframework/robotframework/issues/4089 +.. _#4510: https://github.com/robotframework/robotframework/issues/4510 +.. _#4532: https://github.com/robotframework/robotframework/issues/4532 +.. _#4536: https://github.com/robotframework/robotframework/issues/4536 +.. _#4546: https://github.com/robotframework/robotframework/issues/4546 +.. _#4562: https://github.com/robotframework/robotframework/issues/4562 +.. _#4570: https://github.com/robotframework/robotframework/issues/4570 +.. _#4584: https://github.com/robotframework/robotframework/issues/4584 +.. _#4613: https://github.com/robotframework/robotframework/issues/4613 +.. _#4637: https://github.com/robotframework/robotframework/issues/4637 +.. _#4682: https://github.com/robotframework/robotframework/issues/4682 +.. _#4538: https://github.com/robotframework/robotframework/issues/4538 +.. _#4571: https://github.com/robotframework/robotframework/issues/4571 +.. _#4589: https://github.com/robotframework/robotframework/issues/4589 +.. _#4604: https://github.com/robotframework/robotframework/issues/4604 +.. _#4626: https://github.com/robotframework/robotframework/issues/4626 +.. _#4635: https://github.com/robotframework/robotframework/issues/4635 +.. _#4648: https://github.com/robotframework/robotframework/issues/4648 +.. _#4670: https://github.com/robotframework/robotframework/issues/4670 +.. _#4680: https://github.com/robotframework/robotframework/issues/4680 +.. _#4689: https://github.com/robotframework/robotframework/issues/4689 +.. _#4692: https://github.com/robotframework/robotframework/issues/4692 +.. _#4695: https://github.com/robotframework/robotframework/issues/4695 +.. _#4716: https://github.com/robotframework/robotframework/issues/4716 +.. _#4754: https://github.com/robotframework/robotframework/issues/4754 +.. _#4756: https://github.com/robotframework/robotframework/issues/4756 +.. _#2717: https://github.com/robotframework/robotframework/issues/2717 +.. _#3579: https://github.com/robotframework/robotframework/issues/3579 +.. _#4210: https://github.com/robotframework/robotframework/issues/4210 +.. _#4547: https://github.com/robotframework/robotframework/issues/4547 +.. _#4567: https://github.com/robotframework/robotframework/issues/4567 +.. _#4568: https://github.com/robotframework/robotframework/issues/4568 +.. _#4569: https://github.com/robotframework/robotframework/issues/4569 +.. _#4575: https://github.com/robotframework/robotframework/issues/4575 +.. _#4576: https://github.com/robotframework/robotframework/issues/4576 +.. _#4583: https://github.com/robotframework/robotframework/issues/4583 +.. _#4601: https://github.com/robotframework/robotframework/issues/4601 +.. _#4609: https://github.com/robotframework/robotframework/issues/4609 +.. _#4647: https://github.com/robotframework/robotframework/issues/4647 +.. _#4660: https://github.com/robotframework/robotframework/issues/4660 +.. _#4666: https://github.com/robotframework/robotframework/issues/4666 +.. _#4676: https://github.com/robotframework/robotframework/issues/4676 +.. _#4683: https://github.com/robotframework/robotframework/issues/4683 +.. _#4684: https://github.com/robotframework/robotframework/issues/4684 +.. _#4687: https://github.com/robotframework/robotframework/issues/4687 +.. _#4688: https://github.com/robotframework/robotframework/issues/4688 +.. _#4729: https://github.com/robotframework/robotframework/issues/4729 +.. _#4740: https://github.com/robotframework/robotframework/issues/4740 +.. _#4765: https://github.com/robotframework/robotframework/issues/4765 +.. _#4777: https://github.com/robotframework/robotframework/issues/4777 +.. _#4627: https://github.com/robotframework/robotframework/issues/4627 +.. _#4611: https://github.com/robotframework/robotframework/issues/4611 +.. _#4634: https://github.com/robotframework/robotframework/issues/4634 +.. _#4638: https://github.com/robotframework/robotframework/issues/4638 +.. _#4646: https://github.com/robotframework/robotframework/issues/4646 +.. _#4663: https://github.com/robotframework/robotframework/issues/4663 +.. _#4736: https://github.com/robotframework/robotframework/issues/4736 +.. _#4749: https://github.com/robotframework/robotframework/issues/4749 +.. _#4780: https://github.com/robotframework/robotframework/issues/4780 +.. _#4781: https://github.com/robotframework/robotframework/issues/4781 +.. _#4522: https://github.com/robotframework/robotframework/issues/4522 +.. _#4596: https://github.com/robotframework/robotframework/issues/4596 +.. _#4598: https://github.com/robotframework/robotframework/issues/4598 +.. _#4619: https://github.com/robotframework/robotframework/issues/4619 +.. _#4636: https://github.com/robotframework/robotframework/issues/4636 +.. _#4656: https://github.com/robotframework/robotframework/issues/4656 +.. _#4709: https://github.com/robotframework/robotframework/issues/4709 From 57b5d84949248cb2e1ed0397275f31a99ac8adac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 5 Jun 2023 17:47:48 +0300 Subject: [PATCH 0610/1592] Updated version to 6.1rc1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 8eb6bc4daa2..e674e32752b 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1b2.dev1' +VERSION = '6.1rc1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 03f64da6fc9..dd12fdf67a8 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1b2.dev1' +VERSION = '6.1rc1' def get_version(naked=False): From 202dd47342ed3f456275466d2c14d477e9d3a741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 5 Jun 2023 18:41:01 +0300 Subject: [PATCH 0611/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index e674e32752b..dec0a213064 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1rc1' +VERSION = '6.1rc2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index dd12fdf67a8..dc2b1d2b41f 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1rc1' +VERSION = '6.1rc2.dev1' def get_version(naked=False): From f9da9e8570dadc4607caf37d7303326ac298c662 Mon Sep 17 00:00:00 2001 From: Elout van Leeuwen <66635066+leeuwe@users.noreply.github.com> Date: Fri, 9 Jun 2023 13:54:46 +0200 Subject: [PATCH 0612/1592] Update languages.py with Vietnamese (#4791) Fixes #4792. --- src/robot/conf/languages.py | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 0bf529ef463..6c9adbf4fba 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -1219,3 +1219,45 @@ class Hi(Language): but_prefixes = ['परंतु'] true_strings = ['यथार्थ', 'निश्चित', 'हां', 'पर'] false_strings = ['गलत', 'नहीं', 'हालाँकि', 'यद्यपि', 'नहीं', 'हैं'] + + +class Vi(Language): + """Vietnamese""" + settings_header = 'Cài Đặt' + variables_header = 'Các biến số' + test_cases_header = 'Các kịch bản kiểm thử' + tasks_header = 'Các nghiệm vụ' + keywords_header = 'Các từ khóa' + comments_header = 'Các chú thích' + library_setting = 'Thư viện' + resource_setting = 'Tài nguyên' + variables_setting = 'Biến số' + name_setting = 'Tên' + documentation_setting = 'Tài liệu hướng dẫn' + metadata_setting = 'Dữ liệu tham chiếu' + suite_setup_setting = 'Tiền thiết lập bộ kịch bản kiểm thử' + suite_teardown_setting = 'Hậu thiết lập bộ kịch bản kiểm thử' + test_setup_setting = 'Tiền thiết lập kịch bản kiểm thử' + test_teardown_setting = 'Hậu thiết lập kịch bản kiểm thử' + test_template_setting = 'Mẫu kịch bản kiểm thử' + test_timeout_setting = 'Thời gian chờ kịch bản kiểm thử' + test_tags_setting = 'Các nhãn kịch bản kiểm thử' + task_setup_setting = 'Tiền thiểt lập nhiệm vụ' + task_teardown_setting = 'Hậu thiết lập nhiệm vụ' + task_template_setting = 'Mẫu nhiễm vụ' + task_timeout_setting = 'Thời gian chờ nhiệm vụ' + task_tags_setting = 'Các nhãn nhiệm vụ' + keyword_tags_setting = 'Các từ khóa nhãn' + tags_setting = 'Các thẻ' + setup_setting = 'Tiền thiết lập' + teardown_setting = 'Hậu thiết lập' + template_setting = 'Mẫu' + timeout_setting = 'Thời gian chờ' + arguments_setting = 'Các đối số' + given_prefixes = ['Đã cho'] + when_prefixes = ['Khi'] + then_prefixes = ['Thì'] + and_prefixes = ['Và'] + but_prefixes = ['Nhưng'] + true_strings = ['Đúng', 'Vâng', 'Mở'] + false_strings = ['Sai', 'Không', 'Tắt', 'Không Có Gì'] From 59d9a8a01e140d1d7aee4002d42e52bb9ab93646 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Jun 2023 22:21:52 +0300 Subject: [PATCH 0613/1592] Bump octokit/request-action from 2.1.8 to 2.1.9 (#4783) Bumps [octokit/request-action](https://github.com/octokit/request-action) from 2.1.8 to 2.1.9. - [Release notes](https://github.com/octokit/request-action/releases) - [Commits](https://github.com/octokit/request-action/compare/352d2ae93e1805721b5fe308598555ba3bd2c8e2...89697eb6635e52c6e1e5559f15b5c91ba5100cb0) --- updated-dependencies: - dependency-name: octokit/request-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/acceptance_tests_cpython_pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index d927397784b..2a79240e9ac 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -123,7 +123,7 @@ jobs: echo "JOB_STATUS=$(python -c "print('${{ job.status }}'.lower())")" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: always() && job.status == 'failure' && runner.os == 'Windows' - - uses: octokit/request-action@352d2ae93e1805721b5fe308598555ba3bd2c8e2 + - uses: octokit/request-action@89697eb6635e52c6e1e5559f15b5c91ba5100cb0 name: Update status with Github Status API id: update_status with: diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index c8cd723f56a..f3c1c490f6c 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -111,7 +111,7 @@ jobs: echo "JOB_STATUS=$(python -c "print('${{ job.status }}'.lower())")" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: always() && job.status == 'failure' && runner.os == 'Windows' - - uses: octokit/request-action@352d2ae93e1805721b5fe308598555ba3bd2c8e2 + - uses: octokit/request-action@89697eb6635e52c6e1e5559f15b5c91ba5100cb0 name: Update status with Github Status API id: update_status with: From f574b1ac6b06706950df24a608186a7c6a836da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 7 Jun 2023 16:59:57 +0300 Subject: [PATCH 0614/1592] regen --- doc/userguide/src/Appendices/Translations.rst | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index 79280944289..0eb3d91ad3a 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -320,7 +320,7 @@ Settings * - Variables - Proměnná * - Name - - + - Název * - Documentation - Dokumentace * - Metadata @@ -447,7 +447,7 @@ Settings * - Variables - Variablen * - Name - - + - Name * - Documentation - Dokumentation * - Metadata @@ -574,7 +574,7 @@ Settings * - Variables - Variable * - Name - - + - Nombre * - Documentation - Documentación * - Metadata @@ -828,7 +828,7 @@ Settings * - Variables - Variable * - Name - - + - Nom * - Documentation - Documentation * - Metadata @@ -1082,7 +1082,7 @@ Settings * - Variables - Variabile * - Name - - + - Nome * - Documentation - Documentazione * - Metadata @@ -1209,7 +1209,7 @@ Settings * - Variables - Variabele * - Name - - + - Naam * - Documentation - Documentatie * - Metadata @@ -1463,7 +1463,7 @@ Settings * - Variables - Variável * - Name - - + - Nome * - Documentation - Documentação * - Metadata @@ -1590,7 +1590,7 @@ Settings * - Variables - Variável * - Name - - + - Nome * - Documentation - Documentação * - Metadata @@ -1717,7 +1717,7 @@ Settings * - Variables - Variabila * - Name - - + - Nume * - Documentation - Documentatie * - Metadata @@ -1971,7 +1971,7 @@ Settings * - Variables - Variabel * - Name - - + - Namn * - Documentation - Dokumentation * - Metadata From a5a37e48dd3060c9e0b2d69ab68f370dbecdd0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 9 Jun 2023 19:21:38 +0300 Subject: [PATCH 0615/1592] Minor fine-tuning. - Make `Keyword.args` and `assign` tuples. Earlier their type was not enforced. - Change `ModelObject.copy` and `deepcopy` to preserve tuples and disallow setting non-existing attributes. --- src/robot/model/keyword.py | 8 ++++---- src/robot/model/modelobject.py | 10 ++-------- utest/model/test_keyword.py | 22 +++++++++++++--------- utest/running/test_run_model.py | 18 +++++++++--------- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index 0f596541fb4..e96beff0035 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -40,8 +40,8 @@ def __init__(self, name: 'str|None' = '', type: str = BodyItem.KEYWORD, parent: BodyItemParent = None): self.name = name - self.args = args - self.assign = assign + self.args = tuple(args) + self.assign = tuple(assign) self.type = type self.parent = parent @@ -74,9 +74,9 @@ def __str__(self) -> str: def to_dict(self) -> DataDict: data: DataDict = {'name': self.name} if self.args: - data['args'] = list(self.args) + data['args'] = self.args if self.assign: - data['assign'] = list(self.assign) + data['assign'] = self.assign return data diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index 2b5c46220c7..7261a97fe17 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -150,10 +150,7 @@ def copy(self: T, **attributes) -> T: __ https://docs.python.org/3/library/copy.html """ - copied = copy.copy(self) - for name in attributes: - setattr(copied, name, attributes[name]) - return copied + return copy.copy(self).config(**attributes) def deepcopy(self: T, **attributes) -> T: """Return a deep copy of this object. @@ -167,10 +164,7 @@ def deepcopy(self: T, **attributes) -> T: __ https://docs.python.org/3/library/copy.html """ - copied = copy.deepcopy(self) - for name in attributes: - setattr(copied, name, attributes[name]) - return copied + return copy.deepcopy(self).config(**attributes) def __repr__(self) -> str: arguments = [(name, getattr(self, name)) for name in self.repr_args] diff --git a/utest/model/test_keyword.py b/utest/model/test_keyword.py index 160fa3ef0ea..cc727934e8c 100644 --- a/utest/model/test_keyword.py +++ b/utest/model/test_keyword.py @@ -78,13 +78,13 @@ def test_string_reprs(self): "Keyword(name='Name', args=(), assign=('${x}', '${y}'))"), (Keyword('Name', assign=['${x}='], args=['x']), '${x}= Name x', - "Keyword(name='Name', args=['x'], assign=['${x}='])"), + "Keyword(name='Name', args=('x',), assign=('${x}=',))"), (Keyword('Name', args=(1, 2, 3)), 'Name 1 2 3', "Keyword(name='Name', args=(1, 2, 3), assign=())"), - (Keyword(assign=['${\xe3}'], name='\xe4', args=['\xe5']), - '${\xe3} \xe4 \xe5', - 'Keyword(name=%r, args=[%r], assign=[%r])' % ('\xe4', '\xe5', '${\xe3}')) + (Keyword(assign=['${ã}'], name='ä', args=['å']), + '${ã} ä å', + "Keyword(name='ä', args=('å',), assign=('${ã}',))") ]: assert_equal(str(kw), exp_str) assert_equal(repr(kw), 'robot.model.' + exp_repr) @@ -98,24 +98,28 @@ def test_copy(self): assert_equal(kw.name, copy.name) copy.name += ' copy' assert_not_equal(kw.name, copy.name) - assert_equal(id(kw.args), id(copy.args)) + assert_equal(kw.args, copy.args) def test_copy_with_attributes(self): - kw = Keyword(name='Orig', args=['orig']) + kw = Keyword(name='Orig', args=('orig',)) copy = kw.copy(name='New', args=['new']) assert_equal(copy.name, 'New') - assert_equal(copy.args, ['new']) + assert_equal(copy.args, ('new',)) def test_deepcopy(self): kw = Keyword(name='Keyword', args=['a']) copy = kw.deepcopy() assert_equal(kw.name, copy.name) - assert_not_equal(id(kw.args), id(copy.args)) + assert_equal(kw.args, copy.args) def test_deepcopy_with_attributes(self): copy = Keyword(name='Orig').deepcopy(name='New', args=['New']) assert_equal(copy.name, 'New') - assert_equal(copy.args, ['New']) + assert_equal(copy.args, ('New',)) + + def test_copy_and_deepcopy_with_non_existing_attributes(self): + assert_raises(AttributeError, Keyword().copy, bad='attr') + assert_raises(AttributeError, Keyword().deepcopy, bad='attr') class TestKeywords(unittest.TestCase): diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 77356fffd83..e45d3f6621b 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -272,8 +272,8 @@ class TestToFromDictAndJson(unittest.TestCase): def test_keyword(self): self._verify(Keyword(), name='') self._verify(Keyword('Name'), name='Name') - self._verify(Keyword('N', tuple('args'), ('${result}',)), - name='N', args=list('args'), assign=['${result}']) + self._verify(Keyword('N', 'args', ('${result}',)), + name='N', args=tuple('args'), assign=('${result}',)) self._verify(Keyword('Setup', type=Keyword.SETUP, lineno=1), name='Setup', lineno=1) @@ -312,7 +312,7 @@ def test_if_structure(self): self._verify(root, type='IF/ELSE ROOT', body=[{'type': 'IF', 'condition': '$c', 'body': [{'name': 'K1'}]}, - {'type': 'ELSE', 'body': [{'name': 'K2', 'args': ['a']}]}]) + {'type': 'ELSE', 'body': [{'name': 'K2', 'args': ('a',)}]}]) def test_try(self): self._verify(Try(), type='TRY/EXCEPT ROOT', body=[]) @@ -365,15 +365,15 @@ def test_test_structure(self): test = TestCase('TC') test.setup.config(name='Setup') test.teardown.config(name='Teardown', args='a') - test.body.create_keyword('K1') - test.body.create_if().body.create_branch().body.create_keyword('K2') + test.body.create_keyword('K1', 'a') + test.body.create_if().body.create_branch('IF', '$c').body.create_keyword('K2') self._verify(test, name='TC', setup={'name': 'Setup'}, - teardown={'name': 'Teardown', 'args': ['a']}, - body=[{'name': 'K1'}, + teardown={'name': 'Teardown', 'args': ('a',)}, + body=[{'name': 'K1', 'args': ('a',)}, {'type': 'IF/ELSE ROOT', - 'body': [{'type': 'IF', 'condition': None, + 'body': [{'type': 'IF', 'condition': '$c', 'body': [{'name': 'K2'}]}]}]) def test_suite(self): @@ -391,7 +391,7 @@ def test_suite_structure(self): self._verify(suite, name='Root', setup={'name': 'Setup'}, - teardown={'name': 'Teardown', 'args': ['a']}, + teardown={'name': 'Teardown', 'args': ('a',)}, tests=[{'name': 'T1', 'body': [{'name': 'K'}]}], suites=[{'name': 'Child', 'tests': [{'name': 'T2', 'body': []}], From 41dcf398ac76d031dce97de5a3d710c0c0976d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 9 Jun 2023 19:24:56 +0300 Subject: [PATCH 0616/1592] Enhance test performance. Avoid parsing schema separately with each test. --- utest/libdoc/test_libdoc.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/utest/libdoc/test_libdoc.py b/utest/libdoc/test_libdoc.py index 689c0d37a27..cee45b5e16d 100644 --- a/utest/libdoc/test_libdoc.py +++ b/utest/libdoc/test_libdoc.py @@ -4,7 +4,7 @@ import unittest from pathlib import Path -from jsonschema import validate +from jsonschema import Draft202012Validator from robot.utils import PY_VERSION from robot.utils.asserts import assert_equal @@ -18,6 +18,9 @@ CURDIR = Path(__file__).resolve().parent DATADIR = (CURDIR / '../../atest/testdata/libdoc/').resolve() TEMPDIR = Path(os.getenv('TEMPDIR') or tempfile.gettempdir()) +VALIDATOR = Draft202012Validator( + json.loads((CURDIR / '../../doc/schema/libdoc.json').read_text()) +) try: from typing_extensions import TypedDict @@ -41,9 +44,7 @@ def verify_keyword_shortdoc(doc_format, doc_input, expected): def run_libdoc_and_validate_json(filename): library = DATADIR / filename json_spec = LibraryDocumentation(library).to_json() - with open(CURDIR / '../../doc/schema/libdoc.json') as file: - schema = json.load(file) - validate(instance=json.loads(json_spec), schema=schema) + VALIDATOR.validate(instance=json.loads(json_spec)) class TestHtmlToDoc(unittest.TestCase): From 82f3b04704104571d7c54e424dd2f22b896e942b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 9 Jun 2023 20:23:10 +0300 Subject: [PATCH 0617/1592] Add schema for `robot.running` JSON model. Part of #3902. Also minor changes to the JSON model itself. Most importatnly, fix `While` to include `on_limit`. --- doc/schema/README.rst | 15 +- doc/schema/running.json | 825 ++++++++++++++++++++++++++++++ doc/schema/running_json_schema.py | 174 +++++++ src/robot/model/control.py | 10 +- src/robot/model/testsuite.py | 2 +- src/robot/running/model.py | 5 +- utest/running/test_run_model.py | 74 ++- 7 files changed, 1083 insertions(+), 22 deletions(-) create mode 100644 doc/schema/running.json create mode 100755 doc/schema/running_json_schema.py diff --git a/doc/schema/README.rst b/doc/schema/README.rst index 9ce98adfb49..d8e94a3e09b 100644 --- a/doc/schema/README.rst +++ b/doc/schema/README.rst @@ -1,8 +1,8 @@ Robot Framework and Libdoc schema definitions ============================================= -This directory contains schema definitions for Robot Frameworks XML output files -as well as for XML and JSON spec files created by Libdoc_. +This directory contains schema definitions for various Robot Framework and +Libdoc_ output files. Only the latest schema versions are directly available and they may not be compatible with older Robot Framework versions. If you need to access old @@ -13,6 +13,7 @@ Schema files ------------ - `<robot.xsd>`_ - Robot Framework XML output schema in XSD_ format. +- `<running.json>`_ - `JSON Schema`_ for ``robot.running.TestSuite`` model structure. - `<libdoc.xsd>`_ - Libdoc XML spec schema in XSD_ format. - `<libdoc.json>`_ - Libdoc JSON spec schema in `JSON Schema`_ format. @@ -24,16 +25,18 @@ Schema files themselves contain embedded documentation and comments explaining the structure in more detail. They also contain instructions how to make them XSD 1.1 compatible if needed. -Libdoc's JSON schema uses JSON Schema Draft 2020-12. +JSON schemas use JSON Schema Draft 2020-12. Updating schemas ---------------- XSD schemas are created by hand and updates need to be done directly to them. -Libdoc's JSON schema is generated based on a model created using pydantic_. -To modify the schema, update the model in `<libdoc_json_schema.py>`_ file -and execute the file to regenerate `<libdoc.json>`_. +JSON schemas are generated based on models created using pydantic_. +To modify these schemas, first update the appropriate pydantic model either +in `<running_json_schema.py>`_ or `<libdoc_json_schema.py>`_ +and then execute that file to regenerate the actual schema file in +`<running.json>`_ or `<libdoc.json>`_, respectively. Testing schemas --------------- diff --git a/doc/schema/running.json b/doc/schema/running.json new file mode 100644 index 00000000000..d332bbd68bd --- /dev/null +++ b/doc/schema/running.json @@ -0,0 +1,825 @@ +{ + "$ref": "#/definitions/TestSuite", + "definitions": { + "Keyword": { + "title": "Keyword", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "args": { + "title": "Args", + "type": "array", + "items": { + "type": "string" + } + }, + "assign": { + "title": "Assign", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + }, + "Error": { + "title": "Error", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "values": { + "title": "Values", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "title": "Type", + "default": "ERROR", + "const": "ERROR", + "type": "string" + } + }, + "required": [ + "error", + "values" + ] + }, + "Break": { + "title": "Break", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "type": { + "title": "Type", + "default": "BREAK", + "const": "BREAK", + "type": "string" + } + } + }, + "Continue": { + "title": "Continue", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "type": { + "title": "Type", + "default": "CONTINUE", + "const": "CONTINUE", + "type": "string" + } + } + }, + "Return": { + "title": "Return", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "values": { + "title": "Values", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "title": "Type", + "default": "RETURN", + "const": "RETURN", + "type": "string" + } + }, + "required": [ + "values" + ] + }, + "TryBranch": { + "title": "TryBranch", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "type": { + "title": "Type", + "enum": [ + "TRY", + "EXCEPT", + "ELSE", + "FINALLY" + ], + "type": "string" + }, + "patterns": { + "title": "Patterns", + "type": "array", + "items": { + "type": "string" + } + }, + "pattern_type": { + "title": "Pattern Type", + "type": "string" + }, + "variable": { + "title": "Variable", + "type": "string" + }, + "body": { + "title": "Body", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/Keyword" + }, + { + "$ref": "#/definitions/For" + }, + { + "$ref": "#/definitions/While" + }, + { + "$ref": "#/definitions/If" + }, + { + "$ref": "#/definitions/Try" + }, + { + "$ref": "#/definitions/Error" + }, + { + "$ref": "#/definitions/Break" + }, + { + "$ref": "#/definitions/Continue" + }, + { + "$ref": "#/definitions/Return" + } + ] + } + } + }, + "required": [ + "type", + "body" + ] + }, + "Try": { + "title": "Try", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "body": { + "title": "Body", + "type": "array", + "items": { + "$ref": "#/definitions/TryBranch" + } + }, + "type": { + "title": "Type", + "default": "TRY/EXCEPT ROOT", + "const": "TRY/EXCEPT ROOT", + "type": "string" + } + }, + "required": [ + "body" + ] + }, + "IfBranch": { + "title": "IfBranch", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "type": { + "title": "Type", + "enum": [ + "IF", + "ELSE IF", + "ELSE" + ], + "type": "string" + }, + "condition": { + "title": "Condition", + "type": "string" + }, + "body": { + "title": "Body", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/Keyword" + }, + { + "$ref": "#/definitions/For" + }, + { + "$ref": "#/definitions/While" + }, + { + "$ref": "#/definitions/If" + }, + { + "$ref": "#/definitions/Try" + }, + { + "$ref": "#/definitions/Error" + }, + { + "$ref": "#/definitions/Break" + }, + { + "$ref": "#/definitions/Continue" + }, + { + "$ref": "#/definitions/Return" + } + ] + } + } + }, + "required": [ + "type", + "body" + ] + }, + "If": { + "title": "If", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "body": { + "title": "Body", + "type": "array", + "items": { + "$ref": "#/definitions/IfBranch" + } + }, + "type": { + "title": "Type", + "default": "IF/ELSE ROOT", + "const": "IF/ELSE ROOT", + "type": "string" + } + }, + "required": [ + "body" + ] + }, + "While": { + "title": "While", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "condition": { + "title": "Condition", + "type": "string" + }, + "limit": { + "title": "Limit", + "type": "string" + }, + "on_limit": { + "title": "On Limit", + "type": "string" + }, + "on_limit_message": { + "title": "On Limit Message", + "type": "string" + }, + "body": { + "title": "Body", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/Keyword" + }, + { + "$ref": "#/definitions/For" + }, + { + "$ref": "#/definitions/While" + }, + { + "$ref": "#/definitions/If" + }, + { + "$ref": "#/definitions/Try" + }, + { + "$ref": "#/definitions/Error" + }, + { + "$ref": "#/definitions/Break" + }, + { + "$ref": "#/definitions/Continue" + }, + { + "$ref": "#/definitions/Return" + } + ] + } + }, + "type": { + "title": "Type", + "default": "WHILE", + "const": "WHILE", + "type": "string" + } + }, + "required": [ + "body" + ] + }, + "For": { + "title": "For", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "variables": { + "title": "Variables", + "type": "array", + "items": { + "type": "string" + } + }, + "flavor": { + "title": "Flavor", + "type": "string" + }, + "values": { + "title": "Values", + "type": "array", + "items": { + "type": "string" + } + }, + "start": { + "title": "Start", + "type": "string" + }, + "mode": { + "title": "Mode", + "type": "string" + }, + "fill": { + "title": "Fill", + "type": "string" + }, + "body": { + "title": "Body", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/Keyword" + }, + { + "$ref": "#/definitions/For" + }, + { + "$ref": "#/definitions/While" + }, + { + "$ref": "#/definitions/If" + }, + { + "$ref": "#/definitions/Try" + }, + { + "$ref": "#/definitions/Error" + }, + { + "$ref": "#/definitions/Break" + }, + { + "$ref": "#/definitions/Continue" + }, + { + "$ref": "#/definitions/Return" + } + ] + } + }, + "type": { + "title": "Type", + "default": "FOR", + "const": "FOR", + "type": "string" + } + }, + "required": [ + "variables", + "flavor", + "values", + "body" + ] + }, + "TestCase": { + "title": "TestCase", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "doc": { + "title": "Doc", + "type": "string" + }, + "tags": { + "title": "Tags", + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "title": "Template", + "type": "string" + }, + "timeout": { + "title": "Timeout", + "type": "string" + }, + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "setup": { + "$ref": "#/definitions/Keyword" + }, + "teardown": { + "$ref": "#/definitions/Keyword" + }, + "body": { + "title": "Body", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/Keyword" + }, + { + "$ref": "#/definitions/For" + }, + { + "$ref": "#/definitions/While" + }, + { + "$ref": "#/definitions/If" + }, + { + "$ref": "#/definitions/Try" + }, + { + "$ref": "#/definitions/Error" + } + ] + } + } + }, + "required": [ + "name", + "body" + ] + }, + "Import": { + "title": "Import", + "type": "object", + "properties": { + "type": { + "title": "Type", + "enum": [ + "LIBRARY", + "RESOURCE", + "VARIABLES" + ], + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "args": { + "title": "Args", + "type": "array", + "items": { + "type": "string" + } + }, + "alias": { + "title": "Alias", + "type": "string" + }, + "lineno": { + "title": "Lineno", + "type": "integer" + } + }, + "required": [ + "type", + "name" + ] + }, + "Variable": { + "title": "Variable", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "value": { + "title": "Value", + "type": "array", + "items": { + "type": "string" + } + }, + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + }, + "UserKeyword": { + "title": "UserKeyword", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "args": { + "title": "Args", + "type": "array", + "items": { + "type": "string" + } + }, + "doc": { + "title": "Doc", + "type": "string" + }, + "tags": { + "title": "Tags", + "type": "array", + "items": { + "type": "string" + } + }, + "return_": { + "title": "Return ", + "type": "array", + "items": { + "type": "string" + } + }, + "timeout": { + "title": "Timeout", + "type": "string" + }, + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "body": { + "title": "Body", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/Keyword" + }, + { + "$ref": "#/definitions/For" + }, + { + "$ref": "#/definitions/While" + }, + { + "$ref": "#/definitions/If" + }, + { + "$ref": "#/definitions/Try" + }, + { + "$ref": "#/definitions/Error" + }, + { + "$ref": "#/definitions/Return" + } + ] + } + } + }, + "required": [ + "name", + "body" + ] + }, + "Resource": { + "title": "Resource", + "type": "object", + "properties": { + "source": { + "title": "Source", + "type": "string", + "format": "path" + }, + "doc": { + "title": "Doc", + "type": "string" + }, + "imports": { + "title": "Imports", + "type": "array", + "items": { + "$ref": "#/definitions/Import" + } + }, + "variables": { + "title": "Variables", + "type": "array", + "items": { + "$ref": "#/definitions/Variable" + } + }, + "keywords": { + "title": "Keywords", + "type": "array", + "items": { + "$ref": "#/definitions/UserKeyword" + } + } + } + }, + "TestSuite": { + "title": "TestSuite", + "description": "JSON schema for `robot.running.TestSuite`.\n\nCompatible with JSON Schema Draft 2020-12.", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "doc": { + "title": "Doc", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "source": { + "title": "Source", + "type": "string", + "format": "path" + }, + "rpa": { + "title": "Rpa", + "type": "boolean" + }, + "setup": { + "$ref": "#/definitions/Keyword" + }, + "teardown": { + "$ref": "#/definitions/Keyword" + }, + "tests": { + "title": "Tests", + "type": "array", + "items": { + "$ref": "#/definitions/TestCase" + } + }, + "suites": { + "title": "Suites", + "type": "array", + "items": { + "$ref": "#/definitions/TestSuite" + } + }, + "resource": { + "$ref": "#/definitions/Resource" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" + } + } +} \ No newline at end of file diff --git a/doc/schema/running_json_schema.py b/doc/schema/running_json_schema.py new file mode 100755 index 00000000000..c4930d73ab4 --- /dev/null +++ b/doc/schema/running_json_schema.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 + +"""JSON schema for ``robot.running.TestSuite`` model structure. + +The schema is modeled using pydantic in this file. After updating the model, +execute this file to regenerate the actual schema file in ``running.json``. + +https://pydantic-docs.helpmanual.io/usage/schema/ +""" + +from collections.abc import Sequence +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Extra, Field + + +class BodyItem(BaseModel): + lineno: int | None + error: str | None + + +class Return(BodyItem): + type = Field('RETURN', const=True) + values: Sequence[str] + + +class Continue(BodyItem): + type = Field('CONTINUE', const=True) + + +class Break(BodyItem): + type = Field('BREAK', const=True) + + +class Error(BodyItem): + type = Field('ERROR', const=True) + values: Sequence[str] + error: str + + +class Keyword(BodyItem): + name: str + args: Sequence[str] | None + assign: Sequence[str] | None + + +class For(BodyItem): + type = Field('FOR', const=True) + variables: Sequence[str] + flavor: str + values: Sequence[str] + start: str | None + mode: str | None + fill: str | None + body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] + + +class While(BodyItem): + type = Field('WHILE', const=True) + condition: str | None + limit: str | None + on_limit: str | None + on_limit_message: str | None + body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] + + +class IfBranch(BodyItem): + type: Literal['IF', 'ELSE IF', 'ELSE'] + condition: str | None + body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] + + +class If(BodyItem): + type = Field('IF/ELSE ROOT', const=True) + body: list[IfBranch] + + +class TryBranch(BodyItem): + type: Literal['TRY', 'EXCEPT', 'ELSE', 'FINALLY'] + patterns: Sequence[str] | None + pattern_type: str | None + variable: str | None + body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] + + +class Try(BodyItem): + type = Field('TRY/EXCEPT ROOT', const=True) + body: list[TryBranch] + + +class TestCase(BaseModel): + name: str + doc: str | None + tags: Sequence[str] | None + template: str | None + timeout: str | None + lineno: int | None + error: str | None + setup: Keyword | None + teardown: Keyword | None + body: list[Keyword | For | While | If | Try | Error] + + +class TestSuite(BaseModel): + """JSON schema for `robot.running.TestSuite`. + + Compatible with JSON Schema Draft 2020-12. + """ + name: str + doc: str | None + metadata: dict[str, str] | None + source: Path | None + rpa: bool | None + setup: Keyword | None + teardown: Keyword | None + tests: list[TestCase] | None + suites: list['TestSuite'] | None + resource: 'Resource | None' + + class Config: + # Do not allow extra attributes. + extra = Extra.forbid + # pydantic doesn't add schema version automatically. + # https://github.com/samuelcolvin/pydantic/issues/1478 + schema_extra = { + '$schema': 'https://json-schema.org/draft/2020-12/schema' + } + + +class Import(BaseModel): + type: Literal['LIBRARY', 'RESOURCE', 'VARIABLES'] + name: str + args: Sequence[str] | None + alias: str | None + lineno: int | None + + +class Variable(BaseModel): + name: str + value: Sequence[str] + lineno: int | None + error: str | None + + +class UserKeyword(BaseModel): + name: str + args: Sequence[str] | None + doc: str | None + tags: Sequence[str] | None + return_: Sequence[str] | None + timeout: str | None + lineno: int | None + error: str | None + body: list[Keyword | For | While | If | Try | Error | Return] + + +class Resource(BaseModel): + source: Path | None + doc: str | None + imports: list[Import] | None + variables: list[Variable] | None + keywords: list[UserKeyword] | None + + +for cls in [For, While, IfBranch, TryBranch, TestSuite]: + cls.update_forward_refs() + + +if __name__ == '__main__': + path = Path(__file__).parent / 'running.json' + with open(path, 'w') as f: + f.write(TestSuite.schema_json(indent=2)) + print(path.absolute()) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index eda1a5fee70..ad306a4c024 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -152,6 +152,7 @@ def to_dict(self) -> DataDict: data: DataDict = {'type': self.type} for name, value in [('condition', self.condition), ('limit', self.limit), + ('on_limit', self.on_limit), ('on_limit_message', self.on_limit_message)]: if value is not None: data[name] = value @@ -197,11 +198,10 @@ def visit(self, visitor: SuiteVisitor): visitor.visit_if_branch(self) def to_dict(self) -> DataDict: - data = {'type': self.type, - 'condition': self.condition, - 'body': self.body.to_dicts()} - if self.type == self.ELSE: - data.pop('condition') + data = {'type': self.type} + if self.condition: + data['condition'] = self.condition + data['body'] = self.body.to_dicts() return data diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index a2f62a1c9fa..d5fa8b74d8d 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -411,7 +411,7 @@ def to_dict(self) -> 'dict[str, Any]': data['metadata'] = dict(self.metadata) if self.source: data['source'] = str(self.source) - if self.rpa: + if self.rpa is not None: data['rpa'] = self.rpa if self.has_setup: data['setup'] = self.setup.to_dict() diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 1cae0ccef5e..75237a1fd94 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -362,8 +362,7 @@ def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: data['lineno'] = self.lineno - if self.error: - data['error'] = self.error + data['error'] = self.error return data @@ -556,7 +555,7 @@ def run(self, settings=None, **options): If such an option is used only once, it can be given also as a single string like ``variable='VAR:value'``. - Additionally listener option allows passing object directly instead of + Additionally, listener option allows passing object directly instead of listener name, e.g. ``run('tests.robot', listener=Listener())``. To capture stdout and/or stderr streams, pass open file objects in as diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index e45d3f6621b..0ad6bae5c50 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -1,10 +1,13 @@ import copy +import json import os import tempfile import unittest import warnings from pathlib import Path +from jsonschema import Draft202012Validator + from robot import api, model from robot.model.modelobject import ModelObject from robot.running import (Break, Continue, Error, For, If, IfBranch, Keyword, @@ -14,7 +17,9 @@ from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, assert_raises, assert_true) -MISC_DIR = (Path(__file__).parent / '../../atest/testdata/misc').resolve() + +CURDIR = Path(__file__).resolve().parent +MISCDIR = (CURDIR / '../../atest/testdata/misc').resolve() class TestModelTypes(unittest.TestCase): @@ -164,7 +169,7 @@ class TestCopy(unittest.TestCase): @classmethod def setUpClass(cls): - cls.suite = TestSuite.from_file_system(MISC_DIR) + cls.suite = TestSuite.from_file_system(MISCDIR) def test_copy(self): self.assert_copy(self.suite, self.suite.copy()) @@ -223,7 +228,7 @@ def cannot_differ(self, value1, value2): class TestLineNumberAndSource(unittest.TestCase): - source = MISC_DIR / 'pass_and_fail.robot' + source = MISCDIR / 'pass_and_fail.robot' @classmethod def setUpClass(cls): @@ -269,6 +274,12 @@ def _assert_lineno_and_source(self, item, lineno): class TestToFromDictAndJson(unittest.TestCase): + @classmethod + def setUpClass(cls): + with open(CURDIR / '../../doc/schema/running.json') as file: + schema = json.load(file) + cls.validator = Draft202012Validator(schema=schema) + def test_keyword(self): self._verify(Keyword(), name='') self._verify(Keyword('Name'), name='Name') @@ -290,16 +301,31 @@ def test_while(self): self._verify(While(), type='WHILE', body=[]) self._verify(While('1 > 0', '1 min'), type='WHILE', condition='1 > 0', limit='1 min', body=[]) + self._verify(While(limit='1', on_limit='PASS'), + type='WHILE', limit='1', on_limit='PASS', body=[]) + self._verify(While(limit='1', on_limit_message='Ooops!'), + type='WHILE', limit='1', on_limit_message='Ooops!', body=[]) self._verify(While('True', lineno=3, error='x'), type='WHILE', condition='True', body=[], lineno=3, error='x') + def test_while_structure(self): + root = While('True') + root.body.create_keyword('K', 'a') + root.body.create_while('False').body.create_keyword('W') + root.body.create_break() + self._verify(root, type='WHILE', condition='True', + body=[{'name': 'K', 'args': ('a',)}, + {'type': 'WHILE', 'condition': 'False', + 'body': [{'name': 'W'}]}, + {'type': 'BREAK'}]) + def test_if(self): self._verify(If(), type='IF/ELSE ROOT', body=[]) self._verify(If(lineno=4, error='E'), type='IF/ELSE ROOT', body=[], lineno=4, error='E') def test_if_branch(self): - self._verify(IfBranch(), type='IF', condition=None, body=[]) + self._verify(IfBranch(), type='IF', body=[]) self._verify(IfBranch(If.ELSE_IF, '1 > 0'), type='ELSE IF', condition='1 > 0', body=[]) self._verify(IfBranch(If.ELSE, lineno=5), @@ -352,8 +378,9 @@ def test_return_continue_break(self): type='BREAK', lineno=11, error='E') def test_error(self): - self._verify(Error(), type='ERROR', values=()) - self._verify(Error(('bad', 'things')), type='ERROR', values=('bad', 'things')) + self._verify(Error(), type='ERROR', values=(), error='') + self._verify(Error(('bad', 'things'), error='Bad things!'), + type='ERROR', values=('bad', 'things'), error='Bad things!') def test_test(self): self._verify(TestCase(), name='', body=[]) @@ -447,7 +474,7 @@ def test_resource_file(self): keywords=[{'name': 'UK', 'body': [{'name': 'K'}]}]) def test_bigger_suite_structure(self): - suite = TestSuite.from_file_system(MISC_DIR) + suite = TestSuite.from_file_system(MISCDIR) self._verify(suite, **suite.to_dict()) def _verify(self, obj, **expected): @@ -458,6 +485,39 @@ def _verify(self, obj, **expected): self.assertDictEqual(roundtrip, expected) roundtrip = type(obj).from_json(obj.to_json()).to_dict() self.assertDictEqual(roundtrip, expected) + self._validate(obj) + + def _validate(self, obj): + suite = self._create_suite_structure(obj) + self.validator.validate(instance=json.loads(suite.to_json())) + # Validating `suite.to_dict` directly doesn't work due to tuples not + # being accepted as arrays: + # https://github.com/python-jsonschema/jsonschema/issues/148 + #self.validator.validate(instance=suite.to_dict()) + + def _create_suite_structure(self, obj): + suite = TestSuite() + test = suite.tests.create() + if isinstance(obj, TestSuite): + suite = obj + elif isinstance(obj, TestCase): + suite.tests = [obj] + elif isinstance(obj, (Keyword, For, While, If, Try, Error)): + test.body.append(obj) + elif isinstance(obj, (IfBranch, TryBranch)): + item = If() if isinstance(obj, IfBranch) else Try() + item.body.append(obj) + test.body.append(item) + elif isinstance(obj, (Break, Continue, Return)): + branch = test.body.create_if().body.create_branch() + branch.body.append(obj) + elif isinstance(obj, UserKeyword): + suite.resource.keywords.append(obj) + elif isinstance(obj, ResourceFile): + suite.resource = obj + else: + raise ValueError(obj) + return suite if __name__ == '__main__': From 25b321963e0606424b2000a17338bdc0aab73134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 9 Jun 2023 22:30:33 +0300 Subject: [PATCH 0618/1592] regen --- doc/userguide/src/Appendices/Translations.rst | 127 ++++++++++++++++++ .../src/CreatingTestData/TestDataSyntax.rst | 1 + 2 files changed, 128 insertions(+) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index 0eb3d91ad3a..18120ca1f0f 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -2434,6 +2434,133 @@ Boolean strings * - False - +Vietnamese (vi) +--------------- + +Section headers +~~~~~~~~~~~~~~~ + +.. list-table:: + :class: tabular + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Header + - Translation + * - Settings + - Cài Đặt + * - Variables + - Các biến số + * - Test Cases + - Các kịch bản kiểm thử + * - Tasks + - Các nghiệm vụ + * - Keywords + - Các từ khóa + * - Comments + - Các chú thích + +Settings +~~~~~~~~ + +.. list-table:: + :class: tabular + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Setting + - Translation + * - Library + - Thư viện + * - Resource + - Tài nguyên + * - Variables + - Biến số + * - Name + - Tên + * - Documentation + - Tài liệu hướng dẫn + * - Metadata + - Dữ liệu tham chiếu + * - Suite Setup + - Tiền thiết lập bộ kịch bản kiểm thử + * - Suite Teardown + - Hậu thiết lập bộ kịch bản kiểm thử + * - Test Setup + - Tiền thiết lập kịch bản kiểm thử + * - Task Setup + - Tiền thiểt lập nhiệm vụ + * - Test Teardown + - Hậu thiết lập kịch bản kiểm thử + * - Task Teardown + - Hậu thiết lập nhiệm vụ + * - Test Template + - Mẫu kịch bản kiểm thử + * - Task Template + - Mẫu nhiễm vụ + * - Test Timeout + - Thời gian chờ kịch bản kiểm thử + * - Task Timeout + - Thời gian chờ nhiệm vụ + * - Test Tags + - Các nhãn kịch bản kiểm thử + * - Task Tags + - Các nhãn nhiệm vụ + * - Keyword Tags + - Các từ khóa nhãn + * - Tags + - Các thẻ + * - Setup + - Tiền thiết lập + * - Teardown + - Hậu thiết lập + * - Template + - Mẫu + * - Timeout + - Thời gian chờ + * - Arguments + - Các đối số + +BDD prefixes +~~~~~~~~~~~~ + +.. list-table:: + :class: tabular + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - Prefix + - Translation + * - Given + - Đã cho + * - When + - Khi + * - Then + - Thì + * - And + - Và + * - But + - Nhưng + +Boolean strings +~~~~~~~~~~~~~~~ + +.. list-table:: + :class: tabular + :width: 40em + :widths: 2 3 + :header-rows: 1 + + * - True/False + - Values + * - True + - Đúng, Vâng, Mở + * - False + - Sai, Không, Tắt, Không Có Gì + Chinese Simplified (zh-CN) -------------------------- diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 6c9896d5dfd..69c70b7924c 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -657,6 +657,7 @@ to see the actual translations: - `Thai (th)`_ - `Turkish (tr)`_ - `Ukrainian (uk)`_ +- `Vietnamese (vi)`_ - `Chinese Simplified (zh-CN)`_ - `Chinese Traditional (zh-TW)`_ From 5ac5781cc760c49002b15f1464ac1b053c1210fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 10 Jun 2023 01:57:35 +0300 Subject: [PATCH 0619/1592] Remove outdated note about Name and --suite incompatibility. The --suite option doesn't anymore affect what files are parsed (#4688), so there's no incompatibility with it and the new Name setting. --- doc/userguide/src/CreatingTestData/CreatingTestSuites.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst index e42dd55b8c4..ba41dd030d5 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst @@ -159,12 +159,7 @@ to a suite by using the :setting:`Name` setting in the Setting section: The name of the top-level suite `can be overridden`__ from the command line with the :option:`--name` option. -.. note:: The :setting:`Name` setting is not compatible with the :option:`--suite` - option that can be used to select tests `by suite names`_. This `will - fixed`__ in Robot Framework 7.0. - __ `Setting suite name`_ -__ https://github.com/robotframework/robotframework/issues/4688 Suite documentation ------------------- From 375afe5f6d187dcf3b81b356838a47cb5963eef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 11 Jun 2023 13:39:49 +0300 Subject: [PATCH 0620/1592] refactor --- src/robot/model/testsuite.py | 4 +--- utest/model/test_testsuite.py | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index d5fa8b74d8d..5e0fa3e6c31 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -331,9 +331,7 @@ def test_count(self) -> int: @property def has_tests(self) -> bool: - if self.tests: - return True - return any(s.has_tests for s in self.suites) + return bool(self.tests) or any(s.has_tests for s in self.suites) def set_tags(self, add: Sequence[str] = (), remove: Sequence[str] = (), persist: bool = False): diff --git a/utest/model/test_testsuite.py b/utest/model/test_testsuite.py index 9f4d781634a..ab1bba6b7f6 100644 --- a/utest/model/test_testsuite.py +++ b/utest/model/test_testsuite.py @@ -160,15 +160,19 @@ def test_set_tags_also_to_new_child(self): def test_all_tests_and_test_count(self): root = TestSuite() + assert_equal(root.has_tests, False) assert_equal(root.test_count, 0) assert_equal(list(root.all_tests), []) for i in range(10): suite = root.suites.create() for j in range(100): suite.tests.create() + assert_equal(root.has_tests, True) assert_equal(root.test_count, 1000) assert_equal(len(list(root.all_tests)), 1000) - assert_equal(list(root.suites[0].all_tests), list(root.suites[0].tests)) + for suite in root.suites: + assert_equal(suite.has_tests, True) + assert_equal(list(suite.all_tests), list(suite.tests)) def test_configure_only_works_with_root_suite(self): for Suite in TestSuite, RunningTestSuite, ResultTestSuite: From cbe0a9b8b195fb430d8dafc67f98e57581b96fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 11 Jun 2023 13:41:54 +0300 Subject: [PATCH 0621/1592] Enhance JSON format docs in release notes --- doc/releasenotes/rf-6.1rc1.rst | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/doc/releasenotes/rf-6.1rc1.rst b/doc/releasenotes/rf-6.1rc1.rst index c35e2be20ef..86fcd82cef0 100644 --- a/doc/releasenotes/rf-6.1rc1.rst +++ b/doc/releasenotes/rf-6.1rc1.rst @@ -60,10 +60,10 @@ The biggest new feature in Robot Framework 6.1 is the possibility to convert test/task data to JSON and back (`#3902`_). This functionality has three main use cases: -- Transferring suites between processes and machines. A suite can be converted +- Transferring data between processes and machines. A suite can be converted to JSON in one machine and recreated somewhere else. -- Possibility to save a suite, possible a nested suite, constructed from data - on the file system into a single file that is faster to parse. +- Saving a suite constructed from normal Robot Framework data into a single + JSON file that is faster to parse. - Alternative data format for external tools generating tests or tasks. This feature is designed more for tool developers than for regular Robot Framework @@ -77,10 +77,14 @@ functionalities are explained below: .. sourcecode:: python - from robot.api import TestSuite + from robot.running import TestSuite + # Construct suite based on data on the file system. suite = TestSuite.from_file_system('/path/to/data') - suite.to_json('data.rbt') + # Get JSON data as a string. + data = suite.to_json() + # Save JSON data to a file with custom indentation. + suite.to_json('data.rbt', indent=2) If you would rather work with Python data and then convert that to JSON or some other format yourself, you can use `TestSuite.to_dict`__ instead. @@ -90,11 +94,14 @@ functionalities are explained below: .. sourcecode:: python - from robot.api import TestSuite + from robot.running import TestSuite + # Create suite from JSON data in a file. suite = TestSuite.from_json('data.rbt') + # Create suite from a JSON string. + suite = TestSuite.from_json('{"name": "Suite", "tests": [{"name": "Test"}]}') - If you hava data as a Python dictionary, you can use `TestSuite.from_dict`__ + If you have data as a Python dictionary, you can use `TestSuite.from_dict`__ instead. 3. When using the `robot` command normally, JSON files with the `.rbt` extension @@ -111,7 +118,7 @@ recreated: .. sourcecode:: python - from robot.api import TestSuite + from robot.running import TestSuite # Create a suite, adjust source and convert to JSON. suite = TestSuite.from_file_system('/path/to/data') @@ -123,16 +130,18 @@ recreated: suite.adjust_source(root='/new/path/to') Ths JSON serialization support can be enhanced in future Robot Framework versions. -We try to keep the data format stable, but it is possible that some changes are -needed. If you have an enhancement idea or believe you have encountered a bug, -please submit an issue issue start a discussion thread on the `#devel` channel +If you have an enhancement idea or believe you have encountered a bug, +please submit an issue or start a discussion thread on the `#devel` channel on our Slack_. +The JSON data format is documented using the `running.json` `schema file`__. + __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_json __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_dict __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_json __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_dict __ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.adjust_source +__ https://github.com/robotframework/robotframework/tree/master/doc/schema#readme External parser API ------------------- From cf3ec9bd6ec262e973eb5e138b280ceff624f373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 11 Jun 2023 17:44:57 +0300 Subject: [PATCH 0622/1592] Avoid outdated resource extension --- utest/resources/{test_resource.txt => test.resource} | 0 utest/running/test_imports.py | 5 ++--- 2 files changed, 2 insertions(+), 3 deletions(-) rename utest/resources/{test_resource.txt => test.resource} (100%) diff --git a/utest/resources/test_resource.txt b/utest/resources/test.resource similarity index 100% rename from utest/resources/test_resource.txt rename to utest/resources/test.resource diff --git a/utest/running/test_imports.py b/utest/running/test_imports.py index 4dd695d6889..3c6ef7e5c61 100644 --- a/utest/running/test_imports.py +++ b/utest/running/test_imports.py @@ -42,7 +42,7 @@ def run_and_check_pass(self, suite): def test_create(self): suite = TestSuite(name='Suite') suite.resource.imports.create('Library', 'OperatingSystem') - suite.resource.imports.create('RESOURCE', 'test_resource.txt') + suite.resource.imports.create('RESOURCE', 'test.resource') suite.resource.imports.create(type='LibRary', name='String') test = suite.tests.create(name='Test') test.body.create_keyword('Directory Should Exist', args=['.']) @@ -50,7 +50,6 @@ def test_create(self): test.body.create_keyword('Convert To Lower Case', args=['ROBOT']) self.run_and_check_pass(suite) - def test_library(self): suite = TestSuite(name='Suite') suite.resource.imports.library('OperatingSystem') @@ -60,7 +59,7 @@ def test_library(self): def test_resource(self): suite = TestSuite(name='Suite') - suite.resource.imports.resource('test_resource.txt') + suite.resource.imports.resource('test.resource') suite.tests.create(name='Test').body.create_keyword('My Test Keyword') assert_equal(suite.tests[0].body[0].name, 'My Test Keyword') self.run_and_check_pass(suite) From 4f9e8d2e9f9322f6558c31a9a577c80e0d0ad83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 11 Jun 2023 17:58:21 +0300 Subject: [PATCH 0623/1592] Enhance programmatic API to create resource files Enhancements to `ResourceFile`: - Expose via `robot.running`. - Add `from_file_system`, `from_string` and `from_model`. Fixes #4793. --- src/robot/running/__init__.py | 2 +- src/robot/running/builder/parsers.py | 7 +++- src/robot/running/model.py | 44 ++++++++++++++++++++ utest/resources/test.resource | 4 +- utest/running/test_run_model.py | 62 ++++++++++++++++++++++++++-- 5 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index 551a54d9027..34594d3152f 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -111,7 +111,7 @@ from .builder import ResourceFileBuilder, TestDefaults, TestSuiteBuilder from .context import EXECUTION_CONTEXTS from .model import (Break, Continue, Error, For, If, IfBranch, Keyword, Return, - TestCase, TestSuite, Try, TryBranch, While) + ResourceFile, TestCase, TestSuite, Try, TryBranch, While) from .runkwregister import RUN_KW_REGISTER from .testlibraries import TestLibrary from .usererrorhandler import UserErrorHandler diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index b21c9ee8573..df018b9bbf5 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -81,7 +81,12 @@ def _get_source(self, source: Path) -> 'Path|str': def parse_resource_file(self, source: Path) -> ResourceFile: model = get_resource_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) - resource = ResourceFile(source=source) + resource = self.parse_resource_model(model) + resource.source = source + return resource + + def parse_resource_model(self, model: File) -> ResourceFile: + resource = ResourceFile(source=model.source) ResourceBuilder(resource).build(model) return resource diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 75237a1fd94..1965288c6b6 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -686,6 +686,50 @@ def variables(self, variables: Sequence['Variable']) -> 'Variables': def keywords(self, keywords: Sequence['UserKeyword']) -> 'UserKeywords': return UserKeywords(self, keywords) + @classmethod + def from_file_system(cls, path: 'Path|str', **config) -> 'ResourceFile': + """Create a :class:`ResourceFile` object based on the give ``path``. + + :param path: File path where to read the data from. + :param config: Configuration parameters for :class:`~.builders.ResourceFileBuilder` + class that is used internally for building the suite. + + New in Robot Framework 6.1. See also :meth:`from_string` and :meth:`from_model`. + """ + from .builder import ResourceFileBuilder + return ResourceFileBuilder(**config).build(path) + + @classmethod + def from_string(cls, string: str, **config) -> 'ResourceFile': + """Create a :class:`ResourceFile` object based on the given ``string``. + + :param string: String to create the resource file from. + :param config: Configuration parameters for + :func:`~robot.parsing.parser.parser.get_resource_model` used internally. + + New in Robot Framework 6.1. See also :meth:`from_file_system` and + :meth:`from_model`. + """ + from robot.parsing import get_resource_model + model = get_resource_model(string, data_only=True, **config) + return cls.from_model(model) + + @classmethod + def from_model(cls, model: 'File') -> 'ResourceFile': + """Create a :class:`ResourceFile` object based on the given ``model``. + + :param model: Model to create the suite from. + + The model can be created by using the + :func:`~robot.parsing.parser.parser.get_resource_model` function and possibly + modified by other tooling in the :mod:`robot.parsing` module. + + New in Robot Framework 6.1. See also :meth:`from_file_system` and + :meth:`from_string`. + """ + from .builder import RobotParser + return RobotParser().parse_resource_model(model) + def to_dict(self) -> DataDict: data = {} if self._source: diff --git a/utest/resources/test.resource b/utest/resources/test.resource index 731b6e3f5e0..9856783d809 100644 --- a/utest/resources/test.resource +++ b/utest/resources/test.resource @@ -1,4 +1,6 @@ -*** Keywords *** +*** Variables *** +${PATH} ${CURDIR} +*** Keywords *** My Test Keyword No Operation diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 0ad6bae5c50..33a1bd02033 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -10,10 +10,11 @@ from robot import api, model from robot.model.modelobject import ModelObject +from robot.parsing import get_resource_model from robot.running import (Break, Continue, Error, For, If, IfBranch, Keyword, - Return, TestCase, TestDefaults, TestSuite, Try, TryBranch, - While) -from robot.running.model import ResourceFile, UserKeyword + Return, ResourceFile, TestCase, TestDefaults, TestSuite, + Try, TryBranch, While) +from robot.running.model import UserKeyword from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, assert_raises, assert_true) @@ -520,5 +521,60 @@ def _create_suite_structure(self, obj): return suite +class TestResourceFile(unittest.TestCase): + path = CURDIR.parent / 'resources/test.resource' + data = ''' +*** Settings *** +Library Example +Keyword Tags common + +*** Variables *** +${NAME} Value + +*** Keywords *** +Example + [Tags] own + Log Hello! +''' + + def test_from_file_system(self): + res = ResourceFile.from_file_system(self.path) + assert_equal(res.variables[0].name, '${PATH}') + assert_equal(res.variables[0].value, (str(self.path.parent).replace('\\', '\\\\'),)) + assert_equal(res.keywords[0].name, 'My Test Keyword') + + def test_from_file_system_with_config(self): + res = ResourceFile.from_file_system(self.path, process_curdir=False) + assert_equal(res.variables[0].name, '${PATH}') + assert_equal(res.variables[0].value, ('${CURDIR}',)) + assert_equal(res.keywords[0].name, 'My Test Keyword') + + def test_from_string(self): + res = ResourceFile.from_string(self.data) + assert_equal(res.imports[0].name, 'Example') + assert_equal(res.variables[0].name, '${NAME}') + assert_equal(res.variables[0].value, ('Value',)) + assert_equal(res.keywords[0].name, 'Example') + assert_equal(res.keywords[0].tags, ['common', 'own']) + assert_equal(res.keywords[0].body[0].name, 'Log') + assert_equal(res.keywords[0].body[0].args, ('Hello!',)) + + def test_from_string_with_config(self): + res = ResourceFile.from_string('*** Muuttujat ***\n${NIMI}\tarvo', lang='fi') + assert_equal(res.variables[0].name, '${NIMI}') + assert_equal(res.variables[0].value, ('arvo',)) + + def test_from_model(self): + model = get_resource_model(self.data) + res = ResourceFile.from_model(model) + assert_equal(res.imports[0].name, 'Example') + assert_equal(res.variables[0].name, '${NAME}') + assert_equal(res.variables[0].value, ('Value',)) + assert_equal(res.keywords[0].name, 'Example') + assert_equal(res.keywords[0].tags, ['common', 'own']) + assert_equal(res.keywords[0].body[0].name, 'Log') + assert_equal(res.keywords[0].body[0].args, ('Hello!',)) + + if __name__ == '__main__': unittest.main() From 2ca377fd6cd7c2009d07564c001dc3a48e02a56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 11 Jun 2023 19:17:54 +0300 Subject: [PATCH 0624/1592] UG: Document JSON data format (#3902) and enhance related docs. Includes: - Using JSON suite files. - Using JSON resource files. - Using reST resource files. - Recommend `.resource` with normal resource files more strongly. - List all supported extensions under Registrations. Also document reST resource files. --- .../src/Appendices/Registrations.rst | 41 +++++- .../ResourceAndVariableFiles.rst | 107 ++++++++++++-- .../src/CreatingTestData/TestDataSyntax.rst | 135 ++++++++++++++++-- 3 files changed, 259 insertions(+), 24 deletions(-) diff --git a/doc/userguide/src/Appendices/Registrations.rst b/doc/userguide/src/Appendices/Registrations.rst index 84414c8182c..ea83a0817f1 100644 --- a/doc/userguide/src/Appendices/Registrations.rst +++ b/doc/userguide/src/Appendices/Registrations.rst @@ -4,11 +4,44 @@ Registrations This appendix lists file extensions, media types, and so on, that are associated with Robot Framework. -File extensions ---------------- +Suite file extensions +--------------------- -- Robot Framework `suite files`_ use the :file:`.robot` extension. -- Robot Framework `resource files`_ use the :file:`.resource` extension. +`Suite files`_ with the following extensions are parsed automatically: + +:file:`.robot` + Suite file using the `plain text format`_. + +:file:`.robot.rst` + Suite file using the `reStructuredText format`_. + +:file:`.rbt` + Suite file using the `JSON format`_. + +Using other extensions is possible, but it requires `separate configuration`__. + +__ `Selecting files to parse`_ + +Resource file extensions +------------------------ + +`Resource files`_ can use the following extensions: + +:file:`.resource` + Recommended when using the plain text format. + +:file:`.robot`, :file:`.txt` and :file:`.tsv` + Supported with the plain text format for backwards compatibility reasons. + :file:`.resource` is recommended and may be mandated in the future. + +:file:`.rst` and :file:`.rest` + Resource file using the `reStructuredText format`__. + +:file:`.rsrc` and :file:`.json` + Resource file using the `JSON format`__. + +__ `Resource files using reStructured text format`_ +__ `Resource files using JSON format`_ Media type ---------- diff --git a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst index 78144798939..18509c69898 100644 --- a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst +++ b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst @@ -5,7 +5,7 @@ User keywords and variables in `suite files`_ and `suite initialization files`_ can only be used in files where they are created, but *resource files* provide a mechanism for sharing them. The high level syntax for creating resource files is exactly the same -as when creating test case files and `supported file formats`_ are the same +as when creating suite files and `supported file formats`_ are the same as well. The main difference is that resource files cannot have tests. *Variable files* provide a powerful mechanism for creating and sharing @@ -21,13 +21,20 @@ also makes them somewhat more complicated than `Variable sections`_. Resource files -------------- +Resource files are typically created using the plain text format, but also +`reStructuredText format`__ and `JSON format`__ are supported. + +__ `Resource files using reStructured text format`_ +__ `Resource files using JSON format`_ + Taking resource files into use ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Resource files are imported using the :setting:`Resource` setting in the Settings section so that the path to the resource file is given as an argument -to the setting. The recommended extension for resource files is -:file:`.resource`, but also the normal :file:`.robot` extension works. +to the setting. The recommended extension for resource files is :file:`.resource`. +For backwards compatibility reasons also :file:`.robot`, :file:`.txt` and +:file:`.tsv` work, but using :file:`.resource` may be mandated in the future. If the resource file path is absolute, it is used directly. Otherwise, the resource file is first searched relatively to the directory @@ -47,7 +54,7 @@ are automatically changed to backslashes (:codesc:`\\`) on Windows. *** Settings *** Resource example.resource - Resource ../data/resources.robot + Resource ../resources/login.resource Resource package/example.resource Resource ${RESOURCES}/common.resource @@ -65,11 +72,12 @@ Resource file structure ~~~~~~~~~~~~~~~~~~~~~~~ The higher-level structure of resource files is the same as that of -test case files otherwise, but, of course, they cannot contain Test -Case sections. Additionally, the Setting section in resource files can -contain only import settings (:setting:`Library`, :setting:`Resource`, -:setting:`Variables`) and :setting:`Documentation`. The Variable section and -Keyword section are used exactly the same way as in test case files. +suite files otherwise, but they cannot contain tests or tasks. +Additionally, the Setting section in resource files can contain only imports +(:setting:`Library`, :setting:`Resource`, :setting:`Variables`), +:setting:`Documentation` and :setting:`Keyword Tags`. +The Variable section and Keyword section are used exactly the same way +as in suite files. If several resource files have a user keyword with the same name, they must be used so that the `keyword name is prefixed with the resource @@ -126,6 +134,87 @@ Example resource file [Arguments] ${password} Input Text password_field ${password} +Resource files using reStructured text format +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The `reStructuredText format`_ that can be used with `suite files`_ works +also with resource files. Such resource files can use either :file:`.rst` +or :file:`.rest` extension and they are otherwise imported exactly as +normal resource files: + +.. sourcecode:: robotframework + + *** Settings *** + Resource example.rst + +When parsing resource files using the reStructuredText format, Robot Framework +ignores all data outside code blocks containing Robot Framework data exactly +the same way as when parsing `reStructuredText suite files`__. +For example, the following resource file imports :name:`OperatingSystem` library, +defines `${MESSAGE}` variable and creates :name:`My Keyword` keyword: + +.. sourcecode:: rest + + Resource file using reStructuredText + ------------------------------------ + + This text is outside code blocks and thus ignored. + + .. code:: robotframework + + *** Settings *** + Library OperatingSystem + + *** Variables *** + ${MESSAGE} Hello, world! + + Also this text is outside code blocks and ignored. Code blocks not + containing Robot Framework data are ignored as well. + + .. code:: robotframework + + # Both space and pipe separated formats are supported. + + | *** Keywords *** | | | + | My Keyword | [Arguments] | ${path} | + | | Directory Should Exist | ${path} | + +__ `reStructuredText format`_ + +Resource files using JSON format +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Resource files can be created using JSON_ the `same way as suite files`__. +Such JSON resource files must use either the standard :file:`.json` extension +or the custom :file:`.rsrc` extension. They are otherwise imported exactly as +normal resource files: + +.. sourcecode:: robotframework + + *** Settings *** + Resource example.rsrc + +Resource files can be converted to JSON using `ResourceFile.to_json`__ and +recreated using `ResourceFile.from_json`__: + +.. sourcecode:: python + + from robot.running import ResourceFile + + + # Create resource file based on data on the file system. + resource = ResourceFile.from_file_system('example.resource') + + # Save JSON data to a file. + resource.to_json('example.rsrc') + + # Recreate resource from JSON data. + resource = ResourceFile.from_json('example.rsrc') + +__ `JSON format`_ +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.ResourceFile.to_json +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.ResourceFile.from_json + Variable files -------------- diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 69c70b7924c..c8965d68efb 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -106,17 +106,21 @@ and their arguments, are separated from each others with two or more spaces. An alternative is using the `pipe separated format`_ where the separator is the pipe character surrounded with spaces (:codesc:`\ |\ `). -Executed files typically use the :file:`.robot` extension, but that `can be -configured`__ with the :option:`--extension` option. `Resource files`_ -can use the :file:`.robot` extension as well, but using the dedicated -:file:`.resource` extension is recommended. Files containing non-ASCII +Suite files typically use the :file:`.robot` extension, but what files are +parsed `can be configured`__. `Resource files`_ can use the :file:`.robot` +extension as well, but using the dedicated :file:`.resource` extension is +recommended and may be mandated in the future. Files containing non-ASCII characters must be saved using the UTF-8 encoding. -Robot Framework also supports reStructuredText_ files so that normal -Robot Framework data is `embedded into code blocks`__. It is possible to -use either :file:`.rst` or :file:`.rest` extension with reStructuredText -files, but the aforementioned :option:`--extension` option `must be used`__ -to enable parsing them when executing a directory. +Robot Framework supports also reStructuredText_ files so that normal +Robot Framework data is `embedded into code blocks`__. Only files with +the :file:`.robot.rst` extension are parsed by default. If you would +rather use just :file:`.rst` or :file:`.rest` extension, that needs to be +configured separately. + +Robot Framework data can also be created in `JSON format`_ that is targeted +more for tool developers than normal Robot Framework users. Only JSON files +with the custom :file:`.rbt` extension are parsed by default. Earlier Robot Framework versions supported data also in HTML and TSV formats. The TSV format still works if the data is compatible with the `space separated @@ -129,9 +133,9 @@ format at all. __ `Selecting files to parse`_ __ `reStructuredText format`_ -__ `Selecting files to parse`_ .. _space separated plain text format: +.. _plain text format: Space separated format ~~~~~~~~~~~~~~~~~~~~~~ @@ -314,7 +318,116 @@ when processing files using reStructuredText tooling normally. JSON format ~~~~~~~~~~~ -FIXME +Robot Framework supports data also in JSON_ format. This format is designed +more for tool developers than for regular Robot Framework users and it is not +meant to be edited manually. Its most important use cases are: + +- Transferring data between processes and machines. A suite can be converted + to JSON in one machine and recreated somewhere else. +- Saving a suite constructed from normal Robot Framework data into a single + JSON file that is faster to parse. +- Alternative data format for external tools generating tests or tasks. + +.. note:: The JSON data support is new in Robot Framework 6.1 and it can be + enhanced in future Robot Framework versions. If you have an enhancement + idea or believe you have encountered a bug, please submit an issue__ + or start a discussion thread on the `#devel` channel on our Slack_. + +__ https://issues.robotframework.org + +Converting suite to JSON +'''''''''''''''''''''''' + +A suite structure can be serialized into JSON by using the `TestSuite.to_json`__ +method. When used without arguments, it returns JSON data as a string, but +it also accepts a path or an open file where to write JSON data along with +configuration options related to JSON formatting: + +.. sourcecode:: python + + from robot.running import TestSuite + + + # Create suite based on data on the file system. + suite = TestSuite.from_file_system('/path/to/data') + # Get JSON data as a string. + data = suite.to_json() + # Save JSON data to a file with custom indentation. + suite.to_json('data.rbt', indent=2) + +If you would rather work with Python data and then convert that to JSON +or some other format yourself, you can use `TestSuite.to_dict`__ instead. + +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.to_json +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.to_dict + +Creating suite from JSON +'''''''''''''''''''''''' + +A suite can be constructed from JSON data using the `TestSuite.from_json`__ +method. It works both with JSON strings and paths to JSON files: + +.. sourcecode:: python + + from robot.running import TestSuite + + + # Create suite from JSON data in a file. + suite = TestSuite.from_json('data.rbt') + # Create suite from a JSON string. + suite = TestSuite.from_json('{"name": "Suite", "tests": [{"name": "Test"}]}') + +If you have data as a Python dictionary, you can use `TestSuite.from_dict`__ +instead. + +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_json +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_dict + +Executing JSON files +'''''''''''''''''''' + +When using the `robot` command normally, JSON files with the :file:`.rbt` +extension are parsed automatically. This includes running individual JSON files +like `robot tests.rbt` and running directories containing :file:`.rbt` files. +If you would rather use the standard :file:`.json` extension, you need to +`configure which files are parsed`__. + +__ `Selecting files to parse`_ + +Adjusting suite source +'''''''''''''''''''''' + +Suite source in the data got from `TestSuite.to_json` and `TestSuite.to_dict` +is in absolute format. If a suite is recreated later on a different machine, +the source may thus not match the directory structure on that machine. To +avoid that, it is possible to use the `TestSuite.adjust_source`__ method to +make the suite source relative before getting the data and add a correct root +directory after the suite is recreated: + +.. sourcecode:: python + + from robot.running import TestSuite + + + # Create a suite, adjust source and convert to JSON. + suite = TestSuite.from_file_system('/path/to/data') + suite.adjust_source(relative_to='/path/to') + suite.to_json('data.rbt') + + # Recreate suite elsewhere and adjust source accordingly. + suite = TestSuite.from_json('data.rbt') + suite.adjust_source(root='/new/path/to') + +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.adjust_source + +JSON structure +'''''''''''''' + +Imports, variables and keywords created in suite files are included in the +generated JSON along with tests and tasks. The exact JSON structure is documented +in the :file:`running.json` `schema file`__. + +__ https://github.com/robotframework/robotframework/tree/master/doc/schema#readme Rules for parsing the data -------------------------- From a11ff03ffa32bf7d3c0d5583cd6e77b2f9d944d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 11 Jun 2023 19:53:35 +0300 Subject: [PATCH 0625/1592] Require new translations to have "New in" note. Also add it to Vietnamese added in RF 6.1 (#4792). --- doc/userguide/src/Appendices/Translations.rst | 2 ++ doc/userguide/translations.py | 10 +++++-- src/robot/conf/languages.py | 7 +++-- utest/api/test_languages.py | 29 +++++++++++++++++-- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index 18120ca1f0f..80aef24ade2 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -2437,6 +2437,8 @@ Boolean strings Vietnamese (vi) --------------- +New in Robot Framework 6.1. + Section headers ~~~~~~~~~~~~~~~ diff --git a/doc/userguide/translations.py b/doc/userguide/translations.py index 8125276f219..928cf555f9e 100644 --- a/doc/userguide/translations.py +++ b/doc/userguide/translations.py @@ -1,5 +1,6 @@ -from pathlib import Path +import re import sys +from pathlib import Path CURDIR = Path(__file__).absolute().parent @@ -21,6 +22,11 @@ def __getattr__(self, name): value = getattr(self.lang, name) return value if value is not None else '' + @property + def new_in(self): + new_in = re.search(r'(New in Robot Framework [\d.]+\.)', self.lang.__doc__) + return ('\n' + new_in.group(1) + '\n') if new_in else '' + @property def underline(self): width = len(self.lang.name + self.lang.code) + 3 @@ -58,7 +64,7 @@ def false_strings(self): TEMPLATE = ''' {lang.name} ({lang.code}) {lang.underline} - +{lang.new_in} Section headers ~~~~~~~~~~~~~~~ diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index 6c9adbf4fba..c3c883bd160 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -1220,9 +1220,12 @@ class Hi(Language): true_strings = ['यथार्थ', 'निश्चित', 'हां', 'पर'] false_strings = ['गलत', 'नहीं', 'हालाँकि', 'यद्यपि', 'नहीं', 'हैं'] - + class Vi(Language): - """Vietnamese""" + """Vietnamese + + New in Robot Framework 6.1. + """ settings_header = 'Cài Đặt' variables_header = 'Các biến số' test_cases_header = 'Các kịch bản kiểm thử' diff --git a/utest/api/test_languages.py b/utest/api/test_languages.py index 35293fbd942..8c7ad81bda8 100644 --- a/utest/api/test_languages.py +++ b/utest/api/test_languages.py @@ -1,5 +1,6 @@ +import inspect import unittest - +import re from pathlib import Path from robot.api import Language, Languages @@ -9,6 +10,9 @@ assert_raises_with_msg) +STANDARD_LANGUAGES = Language.__subclasses__() + + class TestLanguage(unittest.TestCase): def test_one_part_code(self): @@ -41,13 +45,32 @@ class X(Language): assert_equal(X().name, '') assert_equal(X.name, '') - def test_all_standard_languages_have_code_and_name(self): - for cls in Language.__subclasses__(): + def test_standard_languages_have_code_and_name(self): + for cls in STANDARD_LANGUAGES: assert cls().code assert cls.code assert cls().name assert cls.name + def test_standard_language_doc_formatting(self): + added_in_rf60 = {'bg', 'bs', 'cs', 'de', 'en', 'es', 'fi', 'fr', 'hi', + 'it', 'nl', 'pl', 'pt', 'pt-BR', 'ro', 'ru', 'sv', + 'th', 'tr', 'uk', 'zh-CN', 'zh-TW'} + for cls in STANDARD_LANGUAGES: + doc = inspect.getdoc(cls) + if cls.code in added_in_rf60: + if doc != cls.name: + raise AssertionError( + f'Invalid docstring for {cls.name}. ' + f'Expected only language name, got:\n{doc}' + ) + else: + if not re.match(rf'{cls.name}\n\nNew in Robot Framework [\d.]+\.', doc): + raise AssertionError( + f'Invalid docstring for {cls.name}. ' + f'Expected language name and "New in" note, got:\n{doc}' + ) + def test_code_and_name_of_Language_base_class_are_propertys(self): assert isinstance(Language.code, property) assert isinstance(Language.name, property) From 2782221a52226a3fc8cc7ee3ddc2ac9bc24111dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 12 Jun 2023 01:44:10 +0300 Subject: [PATCH 0626/1592] Add WHILE's `on_limit` and `on_limit_message` to output.xml schema. No need to raise schema version because it has already been raised from 3 to 4 during RF 6.1 development. These are optional arguments, so old output.xml files will be compatible with the new schema. --- doc/schema/robot.xsd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index 4418b2d1b9d..2ff14a970e1 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -240,6 +240,8 @@ </xs:choice> <xs:attribute name="condition" type="xs:string" /> <xs:attribute name="limit" type="xs:string" /> + <xs:attribute name="on_limit" type="xs:string" /> + <xs:attribute name="on_limit_message" type="xs:string" /> </xs:complexType> <xs:complexType name="WhileIteration"> <xs:choice maxOccurs="unbounded"> From 4c30d2c8b8e574050c09eb3ee60e150231e5d840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 12 Jun 2023 02:20:23 +0300 Subject: [PATCH 0627/1592] Small UG tuning --- .../src/CreatingTestData/TestDataSyntax.rst | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index c8965d68efb..9c39d629449 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -324,8 +324,8 @@ meant to be edited manually. Its most important use cases are: - Transferring data between processes and machines. A suite can be converted to JSON in one machine and recreated somewhere else. -- Saving a suite constructed from normal Robot Framework data into a single - JSON file that is faster to parse. +- Saving a suite, possibly a nested suite, constructed from normal Robot Framework + data into a single JSON file that is faster to parse. - Alternative data format for external tools generating tests or tasks. .. note:: The JSON data support is new in Robot Framework 6.1 and it can be @@ -350,8 +350,10 @@ configuration options related to JSON formatting: # Create suite based on data on the file system. suite = TestSuite.from_file_system('/path/to/data') + # Get JSON data as a string. data = suite.to_json() + # Save JSON data to a file with custom indentation. suite.to_json('data.rbt', indent=2) @@ -374,6 +376,7 @@ method. It works both with JSON strings and paths to JSON files: # Create suite from JSON data in a file. suite = TestSuite.from_json('data.rbt') + # Create suite from a JSON string. suite = TestSuite.from_json('{"name": "Suite", "tests": [{"name": "Test"}]}') @@ -386,11 +389,11 @@ __ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#r Executing JSON files '''''''''''''''''''' -When using the `robot` command normally, JSON files with the :file:`.rbt` -extension are parsed automatically. This includes running individual JSON files -like `robot tests.rbt` and running directories containing :file:`.rbt` files. -If you would rather use the standard :file:`.json` extension, you need to -`configure which files are parsed`__. +When executing tests or tasks using the `robot` command, JSON files with +the custom :file:`.rbt` extension are parsed automatically. This includes +running individual JSON files like `robot tests.rbt` and running directories +containing :file:`.rbt` files. If you would rather use the standard +:file:`.json` extension, you need to `configure which files are parsed`__. __ `Selecting files to parse`_ From 0eef984a9a15e4271ea734ce8646d71465c0dd8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 12 Jun 2023 02:21:02 +0300 Subject: [PATCH 0628/1592] Release notes for 6.1 --- doc/releasenotes/rf-6.1.rst | 1324 +++++++++++++++++++++++++++++++++++ 1 file changed, 1324 insertions(+) create mode 100644 doc/releasenotes/rf-6.1.rst diff --git a/doc/releasenotes/rf-6.1.rst b/doc/releasenotes/rf-6.1.rst new file mode 100644 index 00000000000..d3798820e63 --- /dev/null +++ b/doc/releasenotes/rf-6.1.rst @@ -0,0 +1,1324 @@ +=================== +Robot Framework 6.1 +=================== + +.. default-role:: code + +`Robot Framework`_ 6.1 is a new feature release with support for converting +Robot Framework data to JSON and back, a new external parser API, possibility +to mix embedded and normal arguments, and various other interesting new features +both for normal users and for external tool developers. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.1 was released on Monday June 12, 2023. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +JSON data format +---------------- + +The biggest new feature in Robot Framework 6.1 is the possibility to convert +test/task data to JSON and back (`#3902`_). This functionality has three main +use cases: + +- Transferring data between processes and machines. A suite can be converted + to JSON in one machine and recreated somewhere else. +- Saving a suite, possibly a nested suite, constructed from normal Robot Framework + data into a single JSON file that is faster to parse. +- Alternative data format for external tools generating tests or tasks. + +This feature is designed more for tool developers than for regular Robot Framework +users and we expect new interesting tools to emerge in the future. The main +functionalities are explained below: + +1. You can serialize a suite structure into JSON by using `TestSuite.to_json`__ + method. When used without arguments, it returns JSON data as a string, but + it also accepts a path or an open file where to write JSON data along with + configuration options related to JSON formatting: + + .. sourcecode:: python + + from robot.running import TestSuite + + # Construct suite based on data on the file system. + suite = TestSuite.from_file_system('/path/to/data') + + # Get JSON data as a string. + data = suite.to_json() + + # Save JSON data to a file with custom indentation. + suite.to_json('data.rbt', indent=2) + + If you would rather work with Python data and then convert that to JSON + or some other format yourself, you can use `TestSuite.to_dict`__ instead. + +2. You can create a suite based on JSON data using `TestSuite.from_json`__. + It works both with JSON strings and paths to JSON files: + + .. sourcecode:: python + + from robot.running import TestSuite + + # Create suite from JSON data in a file. + suite = TestSuite.from_json('data.rbt') + + # Create suite from a JSON string. + suite = TestSuite.from_json('{"name": "Suite", "tests": [{"name": "Test"}]}') + + If you have data as a Python dictionary, you can use `TestSuite.from_dict`__ + instead. + +3. When executing tests or tasks using the `robot` command, JSON files with + the custom `.rbt` extension are parsed automatically. This includes running + individual JSON files like `robot tests.rbt` and running directories + containing `.rbt` files. + +Suite source information in the data got from `TestSuite.to_json` and +`TestSuite.to_dict` is in absolute format. If a suite is recreated later on +a different machine, the source may thus not match the directory structure on +that machine. To avoid such problems, it is possible to use the new +`TestSuite.adjust_source`__ method to make the suite source relative +before getting the data and add a correct root directory after the suite is +recreated: + +.. sourcecode:: python + + from robot.running import TestSuite + + # Create a suite, adjust source and convert to JSON. + suite = TestSuite.from_file_system('/path/to/data') + suite.adjust_source(relative_to='/path/to') + suite.to_json('data.rbt') + + # Recreate suite elsewhere and adjust source accordingly. + suite = TestSuite.from_json('data.rbt') + suite.adjust_source(root='/new/path/to') + +Ths JSON serialization support can be enhanced in future Robot Framework versions. +If you have an enhancement idea or believe you have encountered a bug, +please submit an issue or start a discussion thread on the `#devel` channel +on our Slack_. + +The JSON data format is documented using the `running.json` `schema file`__. + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_json +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.to_dict +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_json +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_dict +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.adjust_source +__ https://github.com/robotframework/robotframework/tree/master/doc/schema#readme + +External parser API +------------------- + +The parser API is another important new interface targeted for tool developers +(`#1283`_). It makes it possible to create custom parsers that can handle their +own data formats or even override Robot Framework's own parser. + +Parsers are taken into use from the command line using the new `--parser` option +the same way as, for example, listeners. This includes specifying parsers as +names or paths, giving arguments to parser classes, and so on:: + + robot --parser MyParser tests.custom + robot --parser path/to/MyParser.py tests.custom + robot --parser Parser1:arg --parser Parser2:a1:a2 path/to/tests + +In simple cases parsers can be implemented as modules. They only thing they +need is an `EXTENSION` or `extension` attribute that specifies the extension +or extensions they support, and a `parse` method that gets the path of the +source file to parse as an argument: + +.. sourcecode:: python + + from robot.api import TestSuite + + EXTENSION = '.example' + + def parse(source): + suite = TestSuite(name='Example', source=source) + test = suite.tests.create(name='Test') + test.body.create_keyword(name='Log', args=['Hello!']) + return suite + +As the example demonstrates, the `parse` method must return a TestSuite__ +instance. In the above example the suite contains only some dummy data and +the source file is not actually parsed. + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite + +Parsers can also be implemented as classes which makes it possible for them to +preserve state and allows passing arguments from the command like. The following +example illustrates that and, unlike the previous example, actually processes the +source file: + +.. sourcecode:: python + + from pathlib import Path + from robot.api import TestSuite + + + class ExampleParser: + + def __init__(self, extension: str): + self.extension = extension + + def parse(self, source: Path) -> TestSuite: + suite = TestSuite(TestSuite.name_from_source(source), source=source) + for line in source.read_text().splitlines(): + test = suite.tests.create(name=line) + test.body.create_keyword(name='Log', args=['Hello!']) + return suite + +As the earlier examples have demonstrated, parsers do not need to extend any +explicit base class or interface. There is, however, an optional Parser__ +base class that can be extended. The following example +does that and has also two other differences compared to earlier examples: + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.api.html#robot.api.interfaces.Parser + +- The parser has optional `parse_init` file for parsing suite initialization files. +- Both `parse` and `parse_init` accept optional `defaults` argument. When this + second argument is present, the `parse` method gets a TestDefaults__ instance + that contains possible test related default values (setup, teardown, tags and + timeout) from initialization files. Also `parse_init` can get it and possible + changes are seen by subsequently called `parse` methods. + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.builder.html#robot.running.builder.settings.TestDefaults + +.. sourcecode:: python + + from pathlib import Path + from robot.api import TestSuite + from robot.api.interfaces import Parser, TestDefaults + + + class ExampleParser(Parser): + extension = ('example', 'another') + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + """Create a suite and set possible defaults from init files to tests.""" + suite = TestSuite(TestSuite.name_from_source(source), source=source) + for line in source.read_text().splitlines(): + test = suite.tests.create(name=line, doc='Example') + test.body.create_keyword(name='Log', args=['Hello!']) + defaults.set_to(test) + return suite + + def parse_init(self, source: Path, defaults: TestDefaults) -> TestSuite: + """Create a dummy suite and set some defaults. + + This method is called only if there is an initialization file with + a supported extension. + """ + defaults.tags = ('tags', 'from init') + defaults.setup = {'name': 'Log', 'args': ['Hello from init!']} + return TestSuite(TestSuite.name_from_source(source.parent), doc='Example', + source=source, metadata={'Example': 'Value'}) + +The final parser acts as a preprocessor for Robot Framework data files that +supports headers in format `=== Test Cases ===` in addition to +`*** Test Cases ***`. In this kind of usage it is convenient to use +`TestSuite.from_string`__, `TestSuite.from_model`__ or +`TestSuite.from_file_system`__ factory methods for constructing the returned suite. + +.. sourcecode:: python + + from pathlib import Path + from robot.running import TestDefaults, TestSuite + + class RobotPreprocessor: + extension = '.robot' + + def parse(self, source: Path, defaults: TestDefaults) -> TestSuite: + data = source.read_text() + for header in 'Settings', 'Variables', 'Test Cases', 'Keywords': + data = data.replace(f'=== {header} ===', f'*** {header} ***') + suite = TestSuite.from_string(data, defaults=defaults) + return suite.config(name=TestSuite.name_from_source(source), source=source) + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_string +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_model +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_file_system + +Python 3.12 compatibility +------------------------- + +Python 3.12 will be released in `October 2023`__. It contains a `subtle change +to tokenization`__ that affects Robot Framework's Python evaluation when the +special `$var` syntax is used. This issue has been fixed and Robot Framework 6.1 +is also otherwise Python 3.12 compatible (`#4771`_). + +__ https://peps.python.org/pep-0693/ +__ https://github.com/python/cpython/issues/104802 + +User keywords with both embedded and normal arguments +----------------------------------------------------- + +User keywords can nowadays mix embedded arguments and normal arguments (`#4234`_). +For example, this kind of usage is possible: + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Number of horses is 2 + Number of dogs is 3 + + *** Keywords *** + Number of ${animals} is + [Arguments] ${count} + Log to console There are ${count} ${animals}. + +This only works with user keywords at least for now. If there is interest, +the support can be extended to library keywords in future releases. + +Support item assignment with lists and dictionaries +--------------------------------------------------- + +Robot Framework 6.1 makes it possible to assign return values from keywords +to list and dictionary items (`#4546`_):: + + ${list}[0] = Keyword + ${dict}[key] = Keyword + ${result}[users][0] = Keyword + +Possibility to flatten keyword structures during execution +---------------------------------------------------------- + +With nested keyword structures, especially with recursive keyword calls and with +WHILE and FOR loops, the log file can get hard to understand with many different +nesting levels. Such nested structures also increase the size of the output.xml +file. For example, even a simple keyword like: + +.. sourcecode:: robotframework + + *** Keywords *** + Example + Log Robot + Log Framework + +creates this much content in output.xml: + +.. sourcecode:: xml + + <kw name="Example"> + <kw name="Log" library="BuiltIn"> + <arg>Robot</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.663"/> + </kw> + <kw name="Log" library="BuiltIn"> + <arg>Framework</arg> + <doc>Logs the given message with the given level.</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + +We already have the `--flattenkeywords` option for "flattening" such structures +and it works great. When a keyword is flattened, its child keywords and control +structures are removed otherwise, but all their messages (`<msg>` elements) are +preserved. Using `--flattenkeywords` does not affect output.xml generated during +execution, but flattening happens when output.xml files are parsed and can save +huge amounts of memory. When `--flattenkeywords` is used with Rebot, it is +possible to create a new flattened output.xml. For example, the above structure +is converted into this if the `Example` keyword is flattened using `--flattenkeywords`: + +.. sourcecode:: xml + + <kw name="Keyword"> + <doc>_*Content flattened.*_</doc> + <msg timestamp="20230103 20:06:36.663" level="INFO">Robot</msg> + <msg timestamp="20230103 20:06:36.663" level="INFO">Framework</msg> + <status status="PASS" starttime="20230103 20:06:36.663" endtime="20230103 20:06:36.664"/> + </kw> + +Starting from Robot Framework 6.1, this kind of flattening can be done also +during execution and without using command line options. The only thing needed +is using the new keyword tag `robot:flatten` (`#4584`_) and flattening is done +automatically. For example, if the earlier `Keyword` is changed to: + +.. sourcecode:: robotframework + + *** Keywords *** + Example + [Tags] robot:flatten + Log Robot + Log Framework + +the result in output.xml will be this: + +.. sourcecode:: xml + + <kw name="Example"> + <tag>robot:flatten</tag> + <msg timestamp="20230317 00:54:34.772" level="INFO">Robot</msg> + <msg timestamp="20230317 00:54:34.772" level="INFO">Framework</msg> + <status status="PASS" starttime="20230317 00:54:34.771" endtime="20230317 00:54:34.772"/> + </kw> + +The main benefit of using `robot:flatten` instead of `--flattenkeywords` is that +it is used already during execution making the resulting output.xml file +smaller. `--flattenkeywords` has more configuration options than `robot:flatten`, +though, but `robot:flatten` can be enhanced in that regard later if there are +needs. + +Type information added to public APIs +------------------------------------- + +Robot Framework has several public APIs that library and tool developers can +use. These APIs nowadays have type hints making their usage easier: + +- The `TestSuite` structure used by listeners, model modifiers, external parsers, + and various other tools (`#4570`_) +- Listener API (`#4568`_) +- Dynamic and hybrid library APIs (`#4567`_) +- Parsing API (`#4740`_) +- Visitor API (`#4569`_) + +Custom argument converters can access library +--------------------------------------------- + +Support for custom argument converters was added in Robot Framework 5.0 +(`#4088`__) and they have turned out to be really useful. This functionality +is now enhanced so that converters can easily get an access to the +library containing the keyword that is used and can thus do conversion +based on the library state (`#4510`_). This can be done simply by creating +a converter that accepts two values. The first value is the value used in +the data, exactly as earlier, and the second is the library instance or module: + +.. sourcecode:: python + + def converter(value, library): + ... + +Converters accepting only one argument keep working as earlier. There are no +plans to require changing them to accept two values. + +__ https://github.com/robotframework/robotframework/issues/4088 + +JSON variable file support +-------------------------- + +It has been possible to create variable files using YAML in addition to Python +for long time, and nowadays also JSON variable files are supported (`#4532`_). +For example, a JSON file containing: + +.. sourcecode:: json + + { + "STRING": "Hello, world!", + "INTEGER": 42 + } + +could be used like this: + +.. sourcecode:: robotframework + + *** Settings *** + Variables example.json + + *** Test Cases *** + Example + Should Be Equal ${STRING} Hello, world! + Should Be Equal ${INTEGER} ${42} + + +`WHILE` loop enhancements +------------------------- + +Robot Framework's WHILE__ loop has been enhanced in several different ways: + +- The biggest enhancement is that `WHILE` loops got an optional + `on_limit` configuration option that controls what to do if the configured + loop `limit` is reached (`#4562`_). By default execution fails, but setting + the option to `PASS` changes that. For example, the following loop runs ten + times and continues execution afterwards: + + .. sourcecode:: robotframework + + *** Test Cases *** + WHILE with 'limit' and 'on_limit' + WHILE True limit=10 on_limit=PASS + Log to console Hello! + END + Log to console Hello once more! + +- The loop condition is nowadays optional (`#4576`_). For example, the above + loop header could be simplified to this:: + + WHILE limit=10 on_limit=PASS + +- New `on_limit_message` configuration option can be used to set the message + that is used if the loop limit exceeds and the loop fails (`#4575`_). + +- A bug with the loop limit in teardowns has been fixed (`#4744`_). + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#while-loops + +`FOR IN ZIP` loop behavior if lists lengths differ can be configured +-------------------------------------------------------------------- + +Robot Framework's `FOR IN ZIP`__ loop behaves like Python's zip__ function so +that if lists lengths are not the same, items from longer ones are ignored. +For example, the following loop is executed only twice: + +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#for-in-zip-loop +__ https://docs.python.org/3/library/functions.html#zip + +.. sourcecode:: robotframework + + *** Variables *** + @{ANIMALS} dog cat horse cow elephant + @{ELÄIMET} koira kissa + + *** Test Cases *** + Example + FOR ${en} ${fi} IN ZIP ${ANIMALS} ${ELÄIMET} + Log ${en} is ${fi} in Finnish + END + +This behavior can cause problems when iterating over items received from +the automated system. For example, the following test would pass regardless +how many things `Get something` returns as long as the returned items match +the expected values. The example succeeds if `Get something` returns ten items +if three first ones match. What's even worse, it succeeds also if `Get something` +returns nothing. + +.. sourcecode:: robotframework + + *** Test Cases *** + Example + Validate something expected 1 expected 2 expected 3 + + *** Keywords **** + Validate something + [Arguments] @{expected} + @{actual} = Get something + FOR ${act} ${exp} IN ZIP ${actual} ${expected} + Validate one thing ${act} ${exp} + END + +This situation is pretty bad because it can cause false positives where +automation succeeds but nothing is actually done. Python itself has this +same issue, and Python 3.10 added new optional `strict` argument to `zip` +(`PEP 681`__). In addition to that, Python has for long time had a separate +`zip_longest`__ function that loops over all values possibly filling-in +values to shorter lists. + +__ https://peps.python.org/pep-0618/ +__ https://docs.python.org/3/library/itertools.html#itertools.zip_longest + +To support the same features as Python, Robot Framework's `FOR IN ZIP` +loops now have an optional `mode` configuration option that accepts three +values (`#4682`_): + +- `STRICT`: Lists must have equal lengths. If not, execution fails. This is + the same as using `strict=True` with Python's `zip` function. +- `SHORTEST`: Items in longer lists are ignored. Infinitely long lists are supported + in this mode as long as one of the lists is exhausted. This is the current + default behavior. +- `LONGEST`: The longest list defines how many iterations there are. Missing + values in shorter lists are filled-in with value specified using the `fill` + option or `None` if it is not used. This is the same as using Python's + `zip_longest` function except that it has `fillvalue` argument instead of + `fill`. + +All these modes are illustrated by the following examples: + +.. sourcecode:: robotframework + + *** Variables *** + @{CHARACTERS} a b c d f + @{NUMBERS} 1 2 3 + + *** Test Cases *** + STRICT mode + [Documentation] This loop fails due to lists lengths being different. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=STRICT + Log ${c}: ${n} + END + + SHORTEST mode + [Documentation] This loop executes three times. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=SHORTEST + Log ${c}: ${n} + END + + LONGEST mode + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `None`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST + Log ${c}: ${n} + END + + LONGEST mode with custom fill value + [Documentation] This loop executes five times. + ... On last two rounds `${n}` has value `-`. + FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST fill=- + Log ${c}: ${n} + END + +This enhancement makes it easy to activate strict validation and avoid +false positives. The default behavior is still problematic, though, and +the plan is to change it to `STRICT` `in the future`__. +Those who want to keep using the `SHORTEST` mode need to enable it explicitly. + +__ https://github.com/robotframework/robotframework/issues/4686 + +New pseudo log level `CONSOLE` +------------------------------ + +There are often needs to log something to the console while tests or tasks +are running. Some keywords support it out-of-the-box and there is also +separate `Log To Console` keyword for that purpose. + +The new `CONSOLE` pseudo log level (`#4536`_) adds this support to *any* +keyword that accepts a log level such as `Log List` in Collections and +`Page Should Contain` in SeleniumLibrary. When this level is used, the message +is logged both to the console and on `INFO` level to the log file. + +Configuring virtual root suite when running multiple suites +----------------------------------------------------------- + +When execution multiple suites like `robot first.robot second.robot`, +Robot Framework creates a virtual root suite containing the executed +suites as child suites. Earlier this virtual suite could be +configured only by using command line options like `--name`, but now +it is possible to use normal suite initialization files (`__init__.robot`) +for that purpose (`#4015`_). If an initialization file is included +in the call as in the following example, the root suite is configured +based on data it contains:: + + robot __init__.robot first.robot second.robot + +The most important feature this enhancement allows is specifying suite +setup and teardown to the virtual root suite. Earlier that was not possible +at all. + +Support for asynchronous functions and methods as keywords +---------------------------------------------------------- + +It is nowadays possible to use asynchronous functions (created using +`async def`) as keywords just like normal functions (`#4089`_). For example, +the following async functions could be used as keyword `Gather Something` and +`Async Sleep`: + +.. sourcecode:: python + + from asyncio import gather, sleep + + async def gather_something(): + print('start') + await gather(something(1), something(2), something(3)) + print('done') + + async def async_sleep(time: int): + await sleep(time) + +`zipapp` compatibility +---------------------- + +Robot Framework 6.1 is compatible with zipapp__ (`#4613`_). This makes it possible +to create standalone distributions using either only the `zipapp` module or +with a help from an external packaging tool like PDM__. + +__ https://docs.python.org/3/library/zipapp.html +__ https://pdm.fming.dev + +New translations +---------------- + +Robot Framework 6.0 started our `localization efforts`__ and added built-in support +for various languages. Robot Framework 6.1 adds support for Vietnamese (`#4792`_) +and we hope to add more languages in the future. + +The new `Name` setting (`#4583`_) has also been translated to various languages +but not yet for all. All supported languages and exact translations used by +them are listed in the `User Guide`__. + +__ https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-6.0.rst#localization +__ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#translations + + +Backwards incompatible changes +============================== + +We try to avoid backwards incompatible changes in general and especially in +non-major version. They cannot always be avoided, though, and there are some +features and fixes in this release that are not fully backwards compatible. +These changes *should not* cause problems in normal usage, but especially +tools using Robot Framework may nevertheless be affected. + +Changes to output.xml +--------------------- + +Syntax errors such as invalid settings like `[Setpu]` or `END` in a wrong place +are nowadays reported better (`#4683`_). Part of that change was storing +invalid constructs in output.xml as `<error>` elements. Tools processing +output.xml files so that they go through all elements need to take `<error>` +elements into account, but tools just querying information using xpath +expression or otherwise should not be affected. + +Another change is that `<for>` and `<while>` elements may have new attributes. +With `FOR IN ENUMERATE` loops the `<for>` element may get `start` attribute +(`#4684`_), with `FOR IN ZIP` loops the `<for>` element may get `mode` and `fill` +attributes (`#4682`_), and with `WHILE` loops the `<while>` element may get +`on_limit` (`#4562`_) and `on_limit_message` (`#4575`_) attributes. This +affects tools processing all possible attributes, but such tools ought to +be very rare. + +Changes to `TestSuite` model structure +-------------------------------------- + +The aforementioned enhancements for handling invalid syntax better (`#4683`_) +required changes also to the TestSuite__ model structure. Syntax errors are +nowadays represented as Error__ objects and they can appear in the `body` of +TestCase__, Keyword__, and other such model objects. Tools interacting with +the `TestSuite` structure should take `Error` objects into account, but tools +using the `visitor API`__ should in general not be affected. + +Another related change is that `doc`, `tags`, `timeout` and `teardown` attributes +were removed from the `robot.running.Keyword`__ object (`#4589`_). They were +left there accidentally and were not used for anything by Robot Framework. +Tools accessing them need to be updated. + +Finally, the `TestSuite.source`__ attribute is nowadays a `pathlib.Path`__ +instance instead of a string (`#4596`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testsuite.TestSuite +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.control.Error +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testcase.TestCase +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.keyword.Keyword +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#module-robot.model.visitor +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.Keyword +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.model.html#robot.model.testsuite.TestSuite.source +__ https://docs.python.org/3/library/pathlib.html + +Changes to parsing model +------------------------ + +Invalid section headers like `*** Bad ***` are nowadays represented in the +parsing model as InvalidSection__ objects when they earlier were generic +Error__ objects (`#4689`_). + +New ReturnSetting__ object has been introduced as an alias for Return__. +This does not yet change anything, but in the future `Return` will be used +for other purposes and tools using it should be updated to use `ReturnSetting` +instead (`#4656`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.blocks.InvalidSection +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Error +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Return +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.ReturnSetting + +Files are not excluded from parsing when using `--suite` option +--------------------------------------------------------------- + +Earlier when the `--suite` option was used, files not matching the specified +suite name were excluded from parsing altogether. This performance enhancement +was convenient especially with bigger suite structures, but it needed to be removed +(`#4688`_) because the new `Name` setting (`#4583`_) made it impossible to +get the suite name solely based on the file name. +Users who are affected by this change can use the new `--parseinclude` option +that explicitly specifies which files should be parsed (`#4687`_). + +Changes to Libdoc spec files +---------------------------- + +Libdoc did not handle parameterized types like `list[int]` properly earlier. +Fixing that problem required storing information about nested types into +the spec files along with the top level type. In addition to the parameterized +types, also unions are now handled differently than earlier, but with normal +types there are no changes. With JSON spec files changes were pretty small, +but XML spec files required a bit bigger changes. See issue `#4538`_ for more +details about what exactly has changed and how. + +Argument conversion changes +--------------------------- + +If an argument has multiple types, Robot Framework tries to do argument +conversion with all of them, from left to right, until one of them succeeds. +Earlier if a type was not recognized at all, the used value was returned +as-is without trying conversion with the remaining types. For example, if +a keyword like: + +.. sourcecode:: python + + def example(arg: Union[UnknownType, int]): + ... + +would be called like:: + + Example 42 + +the integer conversion would not be attempted and the keyword would get +string `42`. This was changed so that unrecognized types are just skipped +and in the above case integer conversion is nowadays done (`#4648`_). That +obviously changes the value the keyword gets to an integer. + +Another argument conversion change is that the `Any` type is now recognized +so that any value is accepted without conversion (`#4647`_). This change is +mostly backwards compatible, but in a special case where such an argument has +a default value like `arg: Any = 1` the behavior changes. Earlier when `Any` +was not recognized at all, conversion was attempted based on the default value +type. Nowadays when `Any` is recognized and explicitly not converted, +no conversion based on the default value is done either. The behavior change +can be avoided by using `arg: Union[int, Any] = 1` which is much better +typing in general. + +Changes affecting execution +--------------------------- + +Invalid settings in tests and keywords like `[Tasg]` are nowadays considered +syntax errors that cause failures at execution time (`#4683`_). They were +reported also earlier, but they did not affect execution. + +All invalid sections in resource files are considered to be syntax errors that +prevent importing the resource file (`#4689`_). Earlier having a `*** Test Cases ***` +header in a resource file caused such an error, but other invalid headers were +just reported as errors but imports succeeded. + +Deprecated features +=================== + +Python 3.7 support +------------------ + +Python 3.7 will reach its end-of-life in `June 2023`__. We have decided to +support it with Robot Framework 6.1 and its bug fix releases, but +Robot Framework 7.0 will not support it anymore (`#4637`_). + +We have already earlier deprecated Python 3.6 that reached its end-of-life +already in `December 2021`__ the same way. The reason we still support it +is that it is the default Python version in Red Hat Enterprise Linux 8 +that is still `actively supported`__. + +__ https://peps.python.org/pep-0537/ +__ https://peps.python.org/pep-0494/ +__ https://endoflife.date/rhel + +Old elements in Libdoc spec files +--------------------------------- + +Libdoc spec files have been enhanced in latest releases. For backwards +compatibility reasons old information has been preserved, but all such data +will be removed in Robot Framework 7.0. For more details about what will be +removed see issue `#4667`__. + +__ https://github.com/robotframework/robotframework/issues/4667 + +Other deprecated features +------------------------- + +- Return__ node in the parsing model has been deprecated and ReturnSetting__ + should be used instead (`#4656`_). +- `name` argument of `TestSuite.from_model`__ has been deprecated and will be + removed in the future (`#4598`_). +- `accept_plain_values` argument of `robot.utils.timestr_to_secs` has been + deprecated and will be removed in the future (`#4522`_). + +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.running.html#robot.running.model.TestSuite.from_model +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.Return +__ https://robot-framework.readthedocs.io/en/latest/autodoc/robot.parsing.model.html#robot.parsing.model.statements.ReturnSetting + + +Acknowledgements +================ + +Robot Framework development is sponsored by the `Robot Framework Foundation`_ +and its over 60 member organizations. If your organization is using Robot Framework +and benefiting from it, consider joining the foundation to support its +development as well. + +Robot Framework 6.1 team funded by the foundation consists of +`Pekka Klärck <https://github.com/pekkaklarck>`_ and +`Janne Härkönen <https://github.com/yanne>`_ (part time). +In addition to that, the community has provided several great contributions: + +- `@Serhiy1 <https://github.com/Serhiy1>`__ helped massively with adding type + information to the `TestSuite` structure (`#4570`_). + +- `@Vincema <https://github.com/Vincema>`__ added support for long command line + options with hyphens like `--pre-run-modifier` (`#4547`_) and implemented + possibility to assign keyword return values directly to list and dictionary items + (`#4546`_). + +- `@sunday2 <https://github.com/sunday2>`__ implemented JSON variable file support + (`#4532`_) and fixed User Guide generation on Windows (`#4680`_). + +- `Tatu Aalto <https://github.com/aaltat>`__ added positional-only argument + support to the dynamic library API (`#4660`_). + +- `@otemek <https://github.com/otemek>`__ implemented possibility to give + a custom name to a suite using a new `Name` setting (`#4583`_). + +- `@franzhaas <https://github.com/franzhaas>`__ made Robot Framework + `zipapp <https://docs.python.org/3/library/zipapp.html>`__ compatible (`#4613`_). + +- `Ygor Pontelo <https://github.com/ygorpontelo>`__ added support for using + asynchronous functions and methods as keywords (`#4089`_). + +- `@ursa-h <https://github.com/ursa-h>`__ enhanced keyword conflict resolution + so that library search order has higher precedence (`#4609`_). + +- `Jonathan Arns <https://github.com/JonathanArns>`__ and + `Fabian Zeiher <https://github.com/cetceeve>`__ made the initial implementation + to limit which files are parsed (`#4687`_). + +- `@asaout <https://github.com/asaout>`__ added `on_limit_message` option to WHILE + loops to control the failure message used if the loop limit is exceeded (`#4575`_). + +- `@turunenm <https://github.com/turunenm>`__ implemented `CONSOLE` pseudo log level + (`#4536`_). + +- `Yuri Verweij <https://github.com/yuriverweij>`__ enhanced `Dictionaries Should Be Equal` + so that it supports ignoring keys (`#2717`_). + +- Hưng Trịnh provided Vietnamese translation (`#4792`_) and + `Elout van Leeuwen <https://github.com/leeuwe>`__ helped otherwise with localization. + +Big thanks to Robot Framework Foundation for the continued support, to community +members listed above for their valuable contributions, and to everyone else who +has submitted bug reports, proposed enhancements, debugged problems, or otherwise +helped to make Robot Framework 6.1 such a great release! + +| `Pekka Klärck <https://github.com/pekkaklarck>`__ +| Robot Framework Creator + + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#1283`_ + - enhancement + - critical + - External parser API for custom parsers + * - `#3902`_ + - enhancement + - critical + - Support serializing executable suite into JSON + * - `#4234`_ + - enhancement + - critical + - Support user keywords with both embedded and normal arguments + * - `#4771`_ + - enhancement + - critical + - Python 3.12 compatibility + * - `#4705`_ + - bug + - high + - Items are not converted when using generics like `list[int]` and passing object, not string + * - `#4744`_ + - bug + - high + - WHILE limit doesn't work in teardown + * - `#4015`_ + - enhancement + - high + - Support configuring virtual suite created when running multiple suites with `__init__.robot` + * - `#4089`_ + - enhancement + - high + - Support asynchronous functions and methods as keywords + * - `#4510`_ + - enhancement + - high + - Make it possible for custom converters to get access to the library + * - `#4532`_ + - enhancement + - high + - JSON variable file support + * - `#4536`_ + - enhancement + - high + - Add new pseudo log level `CONSOLE` that logs to console and to log file + * - `#4546`_ + - enhancement + - high + - Support item assignment with lists and dicts like `${x}[key] = Keyword` + * - `#4562`_ + - enhancement + - high + - Possibility to continue execution after WHILE limit is reached + * - `#4570`_ + - enhancement + - high + - Add type information to `TestSuite` structure + * - `#4584`_ + - enhancement + - high + - New `robot:flatten` tag for "flattening" keyword structures + * - `#4613`_ + - enhancement + - high + - Make Robot Framework compatible with `zipapp` + * - `#4637`_ + - enhancement + - high + - Deprecate Python 3.7 + * - `#4682`_ + - enhancement + - high + - Make `FOR IN ZIP` loop behavior if lists have different lengths configurable + * - `#4746`_ + - enhancement + - high + - Decide and document XDG media type + * - `#4792`_ + - enhancement + - high + - Add Vietnamese translation + * - `#4538`_ + - bug + - medium + - Libdoc doesn't handle parameterized types like `list[int]` properly + * - `#4571`_ + - bug + - medium + - Suite setup and teardown are executed even if all tests are skipped + * - `#4589`_ + - bug + - medium + - Remove unused attributes from `robot.running.Keyword` model object + * - `#4604`_ + - bug + - medium + - Listeners do not get source information for keywords executed with `Run Keyword` + * - `#4626`_ + - bug + - medium + - Inconsistent argument conversion when using `None` as default value with Python 3.11 and earlier + * - `#4635`_ + - bug + - medium + - Dialogs created by `Dialogs` on Windows don't have focus + * - `#4648`_ + - bug + - medium + - Argument conversion should be attempted with all possible types even if some type wouldn't be recognized + * - `#4670`_ + - bug + - medium + - Parsing model: `Documentation.from_params(...).value` doesn't work + * - `#4680`_ + - bug + - medium + - User Guide generation broken on Windows + * - `#4689`_ + - bug + - medium + - Invalid sections are not represented properly in parsing model + * - `#4692`_ + - bug + - medium + - `ELSE IF` condition not passed to listeners + * - `#4695`_ + - bug + - medium + - Accessing `id` property of model objects may cause `ValueError` + * - `#4716`_ + - bug + - medium + - Variable nodes with nested variables report a parsing error, but work properly in the runtime + * - `#4754`_ + - bug + - medium + - Back navigation does not work properly in HTML outputs (log, report, Libdoc) + * - `#4756`_ + - bug + - medium + - Failed keywords inside skipped tests are not expanded + * - `#2717`_ + - enhancement + - medium + - `Dictionaries Should Be Equal` should support ignoring keys + * - `#3579`_ + - enhancement + - medium + - Enhance performance of selecting tests using `--include` and `--exclude` + * - `#4210`_ + - enhancement + - medium + - Enhance error detection at parsing time + * - `#4547`_ + - enhancement + - medium + - Support long command line options with hyphens like `--pre-run-modifier` + * - `#4567`_ + - enhancement + - medium + - Add optional typed base class for dynamic library API + * - `#4568`_ + - enhancement + - medium + - Add optional typed base classes for listener API + * - `#4569`_ + - enhancement + - medium + - Add type information to the visitor API + * - `#4575`_ + - enhancement + - medium + - Add `on_limit_message` option to WHILE loops to control message used if loop limit is exceeded + * - `#4576`_ + - enhancement + - medium + - Make the WHILE loop condition optional + * - `#4583`_ + - enhancement + - medium + - Possibility to give a custom name to a suite using `Name` setting + * - `#4601`_ + - enhancement + - medium + - Add `robot.running.TestSuite.from_string` method + * - `#4609`_ + - enhancement + - medium + - If multiple keywords match, resolve conflict first using search order + * - `#4627`_ + - enhancement + - medium + - Support custom converters that accept only `*varargs` + * - `#4647`_ + - enhancement + - medium + - Add explicit argument converter for `Any` that does no conversion + * - `#4660`_ + - enhancement + - medium + - Dynamic API: Support positional-only arguments + * - `#4666`_ + - enhancement + - medium + - Add public API to query is Robot running and is dry-run active + * - `#4676`_ + - enhancement + - medium + - Propose using `$var` syntax if evaluation IF or WHILE condition using `${var}` fails + * - `#4683`_ + - enhancement + - medium + - Report syntax errors better in log file + * - `#4684`_ + - enhancement + - medium + - Handle start index with `FOR IN ENUMERATE` loops already in parser + * - `#4687`_ + - enhancement + - medium + - Add explicit command line option to limit which files are parsed + * - `#4688`_ + - enhancement + - medium + - Do not exclude files during parsing if using `--suite` option + * - `#4729`_ + - enhancement + - medium + - Leading and internal spaces should be preserved in documentation + * - `#4740`_ + - enhancement + - medium + - Add type hints to parsing API + * - `#4765`_ + - enhancement + - medium + - Add forward compatible `start_time`, `end_time` and `elapsed_time` propertys to result objects + * - `#4777`_ + - enhancement + - medium + - Parse files with `.robot.rst` extension automatically + * - `#4793`_ + - enhancement + - medium + - Enhance programmatic API to create resource files + * - `#4611`_ + - bug + - low + - Some unit tests cannot be run independently + * - `#4634`_ + - bug + - low + - Dialogs created by `Dialogs` are not centered and their minimum size is too small + * - `#4638`_ + - bug + - low + - Using bare `Union` as annotation is not handled properly + * - `#4646`_ + - bug + - low + - Bad error message when function is annotated with an empty tuple `()` + * - `#4663`_ + - bug + - low + - `BuiltIn.Log` documentation contains a defect + * - `#4736`_ + - bug + - low + - Backslash preventing newline in documentation can form escape sequence like `\n` + * - `#4749`_ + - bug + - low + - Process: `Split/Join Command Line` do not work properly with `pathlib.Path` objects + * - `#4780`_ + - bug + - low + - Libdoc crashes if it does not detect documentation format + * - `#4781`_ + - bug + - low + - Libdoc: Type info for `TypedDict` doesn't list `Mapping` in converted types + * - `#4522`_ + - enhancement + - low + - Deprecate `accept_plain_values` argument used by `timestr_to_secs` + * - `#4596`_ + - enhancement + - low + - Make `TestSuite.source` attribute `pathlib.Path` instance + * - `#4598`_ + - enhancement + - low + - Deprecate `name` argument of `TestSuite.from_model` + * - `#4619`_ + - enhancement + - low + - Dialogs created by `Dialogs` should bind `Enter` key to `OK` button + * - `#4636`_ + - enhancement + - low + - Buttons in dialogs created by `Dialogs` should get keyboard shortcuts + * - `#4656`_ + - enhancement + - low + - Deprecate `Return` node in parsing model + * - `#4709`_ + - enhancement + - low + - Add `__repr__()` method to NormalizedDict + +Altogether 77 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1>`__. + +.. _#1283: https://github.com/robotframework/robotframework/issues/1283 +.. _#3902: https://github.com/robotframework/robotframework/issues/3902 +.. _#4234: https://github.com/robotframework/robotframework/issues/4234 +.. _#4771: https://github.com/robotframework/robotframework/issues/4771 +.. _#4705: https://github.com/robotframework/robotframework/issues/4705 +.. _#4744: https://github.com/robotframework/robotframework/issues/4744 +.. _#4015: https://github.com/robotframework/robotframework/issues/4015 +.. _#4089: https://github.com/robotframework/robotframework/issues/4089 +.. _#4510: https://github.com/robotframework/robotframework/issues/4510 +.. _#4532: https://github.com/robotframework/robotframework/issues/4532 +.. _#4536: https://github.com/robotframework/robotframework/issues/4536 +.. _#4546: https://github.com/robotframework/robotframework/issues/4546 +.. _#4562: https://github.com/robotframework/robotframework/issues/4562 +.. _#4570: https://github.com/robotframework/robotframework/issues/4570 +.. _#4584: https://github.com/robotframework/robotframework/issues/4584 +.. _#4613: https://github.com/robotframework/robotframework/issues/4613 +.. _#4637: https://github.com/robotframework/robotframework/issues/4637 +.. _#4682: https://github.com/robotframework/robotframework/issues/4682 +.. _#4746: https://github.com/robotframework/robotframework/issues/4746 +.. _#4792: https://github.com/robotframework/robotframework/issues/4792 +.. _#4538: https://github.com/robotframework/robotframework/issues/4538 +.. _#4571: https://github.com/robotframework/robotframework/issues/4571 +.. _#4589: https://github.com/robotframework/robotframework/issues/4589 +.. _#4604: https://github.com/robotframework/robotframework/issues/4604 +.. _#4626: https://github.com/robotframework/robotframework/issues/4626 +.. _#4635: https://github.com/robotframework/robotframework/issues/4635 +.. _#4648: https://github.com/robotframework/robotframework/issues/4648 +.. _#4670: https://github.com/robotframework/robotframework/issues/4670 +.. _#4680: https://github.com/robotframework/robotframework/issues/4680 +.. _#4689: https://github.com/robotframework/robotframework/issues/4689 +.. _#4692: https://github.com/robotframework/robotframework/issues/4692 +.. _#4695: https://github.com/robotframework/robotframework/issues/4695 +.. _#4716: https://github.com/robotframework/robotframework/issues/4716 +.. _#4754: https://github.com/robotframework/robotframework/issues/4754 +.. _#4756: https://github.com/robotframework/robotframework/issues/4756 +.. _#2717: https://github.com/robotframework/robotframework/issues/2717 +.. _#3579: https://github.com/robotframework/robotframework/issues/3579 +.. _#4210: https://github.com/robotframework/robotframework/issues/4210 +.. _#4547: https://github.com/robotframework/robotframework/issues/4547 +.. _#4567: https://github.com/robotframework/robotframework/issues/4567 +.. _#4568: https://github.com/robotframework/robotframework/issues/4568 +.. _#4569: https://github.com/robotframework/robotframework/issues/4569 +.. _#4575: https://github.com/robotframework/robotframework/issues/4575 +.. _#4576: https://github.com/robotframework/robotframework/issues/4576 +.. _#4583: https://github.com/robotframework/robotframework/issues/4583 +.. _#4601: https://github.com/robotframework/robotframework/issues/4601 +.. _#4609: https://github.com/robotframework/robotframework/issues/4609 +.. _#4627: https://github.com/robotframework/robotframework/issues/4627 +.. _#4647: https://github.com/robotframework/robotframework/issues/4647 +.. _#4660: https://github.com/robotframework/robotframework/issues/4660 +.. _#4666: https://github.com/robotframework/robotframework/issues/4666 +.. _#4676: https://github.com/robotframework/robotframework/issues/4676 +.. _#4683: https://github.com/robotframework/robotframework/issues/4683 +.. _#4684: https://github.com/robotframework/robotframework/issues/4684 +.. _#4687: https://github.com/robotframework/robotframework/issues/4687 +.. _#4688: https://github.com/robotframework/robotframework/issues/4688 +.. _#4729: https://github.com/robotframework/robotframework/issues/4729 +.. _#4740: https://github.com/robotframework/robotframework/issues/4740 +.. _#4765: https://github.com/robotframework/robotframework/issues/4765 +.. _#4777: https://github.com/robotframework/robotframework/issues/4777 +.. _#4793: https://github.com/robotframework/robotframework/issues/4793 +.. _#4611: https://github.com/robotframework/robotframework/issues/4611 +.. _#4634: https://github.com/robotframework/robotframework/issues/4634 +.. _#4638: https://github.com/robotframework/robotframework/issues/4638 +.. _#4646: https://github.com/robotframework/robotframework/issues/4646 +.. _#4663: https://github.com/robotframework/robotframework/issues/4663 +.. _#4736: https://github.com/robotframework/robotframework/issues/4736 +.. _#4749: https://github.com/robotframework/robotframework/issues/4749 +.. _#4780: https://github.com/robotframework/robotframework/issues/4780 +.. _#4781: https://github.com/robotframework/robotframework/issues/4781 +.. _#4522: https://github.com/robotframework/robotframework/issues/4522 +.. _#4596: https://github.com/robotframework/robotframework/issues/4596 +.. _#4598: https://github.com/robotframework/robotframework/issues/4598 +.. _#4619: https://github.com/robotframework/robotframework/issues/4619 +.. _#4636: https://github.com/robotframework/robotframework/issues/4636 +.. _#4656: https://github.com/robotframework/robotframework/issues/4656 +.. _#4709: https://github.com/robotframework/robotframework/issues/4709 From e3bb783c825313dbfa02e9d7a6e70486474a47f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 12 Jun 2023 12:36:20 +0300 Subject: [PATCH 0629/1592] Updated version to 6.1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index dec0a213064..ea9ebe8756d 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1rc2.dev1' +VERSION = '6.1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index dc2b1d2b41f..7f34864b5e7 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1rc2.dev1' +VERSION = '6.1' def get_version(naked=False): From cef8be60d1d25934ddeee1534164e9287d9a53aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 12 Jun 2023 12:39:40 +0300 Subject: [PATCH 0630/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ea9ebe8756d..23cd1365e70 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1' +VERSION = '6.1.1.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 7f34864b5e7..d3e56480240 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1' +VERSION = '6.1.1.dev1' def get_version(naked=False): From 6a266bf852ad0525c350ea7412f7b33f81455e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 14 Jun 2023 01:23:17 +0300 Subject: [PATCH 0631/1592] Add link to contributor's GitHub profile --- doc/releasenotes/rf-6.1.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/releasenotes/rf-6.1.rst b/doc/releasenotes/rf-6.1.rst index d3798820e63..c068f01a7f6 100644 --- a/doc/releasenotes/rf-6.1.rst +++ b/doc/releasenotes/rf-6.1.rst @@ -912,8 +912,8 @@ In addition to that, the community has provided several great contributions: - `Yuri Verweij <https://github.com/yuriverweij>`__ enhanced `Dictionaries Should Be Equal` so that it supports ignoring keys (`#2717`_). -- Hưng Trịnh provided Vietnamese translation (`#4792`_) and - `Elout van Leeuwen <https://github.com/leeuwe>`__ helped otherwise with localization. +- `Hưng Trịnh <https://github.com/hungtrinh>`__ provided Vietnamese translation (`#4792`_) + and `Elout van Leeuwen <https://github.com/leeuwe>`__ helped with localization otherwise. Big thanks to Robot Framework Foundation for the continued support, to community members listed above for their valuable contributions, and to everyone else who From 7f7e80e2f371242c670b7ead4d22a04961ac0aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 14 Jun 2023 01:24:02 +0300 Subject: [PATCH 0632/1592] Fix docstring formatting --- src/robot/api/interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 199fbbd23d9..451ee223ccc 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -179,7 +179,7 @@ def get_keyword_arguments(self, name: Name) -> Optional[Arguments]: - If keyword does not accept varargs, a lone ``*`` can be used a separator between normal and named-only arguments like ``['normal', '*', 'named']``. - - Kwargs must have a ``**`` prefix like [``**config``]. There can + - Kwargs must have a ``**`` prefix like ``['**config']``. There can be only one kwargs, and it must be last. Both normal arguments and named-only arguments can have default values: From bd96ef80cb89982075f8ce4ff090ae3f77589b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sun, 9 Jul 2023 23:31:06 +0300 Subject: [PATCH 0633/1592] Faster way to check does suite have tests --- src/robot/model/modifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/model/modifier.py b/src/robot/model/modifier.py index edc164b22a8..6047f1014f9 100644 --- a/src/robot/model/modifier.py +++ b/src/robot/model/modifier.py @@ -35,7 +35,7 @@ def visit_suite(self, suite): message, details = get_error_details() self._log_error(f"Executing model modifier '{type_name(visitor)}' " f"failed: {message}\n{details}") - if not (suite.test_count or self._empty_suite_ok): + if not (suite.has_tests or self._empty_suite_ok): raise DataError(f"Suite '{suite.name}' contains no tests after " f"model modifiers.") From 1ff5225bdbaaf1a28ae565cee1425d4ab3657229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 10 Jul 2023 01:18:26 +0300 Subject: [PATCH 0634/1592] Avoid passing `rpa` to `TestSuiteBuilder` in tests. The `rpa` argument may be deprecated in the future. Use `process_curdir` when testing `TestSuite.from_file_system` configuration instead. --- utest/running/test_run_model.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 33a1bd02033..b5a34dc4573 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -76,7 +76,7 @@ class TestSuiteFromSources(unittest.TestCase): *** Keywords *** Keyword - Log Hello! + Log ${CURDIR} ''' @classmethod @@ -90,22 +90,23 @@ def tearDownClass(cls): def test_from_file_system(self): suite = TestSuite.from_file_system(self.path) - self._verify_suite(suite) + self._verify_suite(suite, curdir=str(self.path.parent)) def test_from_file_system_with_multiple_paths(self): suite = TestSuite.from_file_system(self.path, self.path) assert_equal(suite.name, 'Test Run Model & Test Run Model') - self._verify_suite(suite.suites[0]) - self._verify_suite(suite.suites[1]) + self._verify_suite(suite.suites[0], curdir=str(self.path.parent)) + self._verify_suite(suite.suites[1], curdir=str(self.path.parent)) def test_from_file_system_with_config(self): - suite = TestSuite.from_file_system(self.path, rpa=True) - self._verify_suite(suite, rpa=True) + suite = TestSuite.from_file_system(self.path, process_curdir=False) + self._verify_suite(suite) def test_from_file_system_with_defaults(self): defaults = TestDefaults(tags=('from defaults',), timeout='10s') suite = TestSuite.from_file_system(self.path, defaults=defaults) - self._verify_suite(suite, tags=('from defaults', 'tag'), timeout='10s') + self._verify_suite(suite, tags=('from defaults', 'tag'), timeout='10s', + curdir=str(self.path.parent)) def test_from_model(self): model = api.get_model(self.data) @@ -140,7 +141,7 @@ def test_from_string(self): def test_from_string_with_config(self): suite = TestSuite.from_string(self.data.replace('Test Cases', 'Testit'), lang='Finnish', curdir='.') - self._verify_suite(suite, name='') + self._verify_suite(suite, name='', curdir='.') def test_from_string_with_defaults(self): defaults = TestDefaults(tags=('from defaults',), timeout='10s') @@ -148,17 +149,17 @@ def test_from_string_with_defaults(self): self._verify_suite(suite, name='', tags=('from defaults', 'tag'), timeout='10s') def _verify_suite(self, suite, name='Test Run Model', tags=('tag',), - timeout=None, rpa=False): + timeout=None, curdir='${CURDIR}'): assert_equal(suite.name, name) assert_equal(suite.doc, 'Some text.') - assert_equal(suite.rpa, rpa) + assert_equal(suite.rpa, False) assert_equal(suite.resource.imports[0].type, 'LIBRARY') assert_equal(suite.resource.imports[0].name, 'ExampleLibrary') assert_equal(suite.resource.variables[0].name, '${VAR}') assert_equal(suite.resource.variables[0].value, ('Value',)) assert_equal(suite.resource.keywords[0].name, 'Keyword') assert_equal(suite.resource.keywords[0].body[0].name, 'Log') - assert_equal(suite.resource.keywords[0].body[0].args, ('Hello!',)) + assert_equal(suite.resource.keywords[0].body[0].args, (curdir,)) assert_equal(suite.tests[0].name, 'Example') assert_equal(suite.tests[0].tags, tags) assert_equal(suite.tests[0].timeout, timeout) From b6abbc5d878b953f92af6126ff6e5476569ef602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 12 Jul 2023 16:10:56 +0300 Subject: [PATCH 0635/1592] UG: Update version when new `-tag` syntax will be added. It wasn't added in RF 6.1 but hopefully will be RF 7.0. Fixes #4816. Also some enhancements to related docs. --- .../src/CreatingTestData/CreatingTestCases.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index 6e2a914bc1f..b725c301ad3 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -671,18 +671,18 @@ using two different settings: it will not get these tags. Both of these settings still work, but they are considered deprecated. -A visible deprecation warning will be added in the future, most likely -in Robot Framework 7.0, and eventually these settings will be removed. +A visible deprecation warning will be added in the future, possibly +already in Robot Framework 7.0, and eventually these settings will be removed. Tools like Tidy__ can be used to ease transition. -Robot Framework 6.1 will introduce a new way for tests to indicate they +Robot Framework 7.0 will introduce a new way for tests to indicate they `should not get certain globally specified tags`__. Instead of using a separate -setting that tests can override, tests can use syntax `-tag` with their +setting that tests can override, tests can use the `-tag` syntax with their :setting:`[Tags]` setting to tell they should not get a tag named `tag`. -This syntax *does not* yet work in Robot Framework 6.0, but using -:setting:`[Tags]` with a literal value like `-tag` `is now deprecated`__. -If such tags are needed, they can be set using :setting:`Test Tags` or -escaped__ syntax `\-tag` can be used with :setting:`[Tags]`. +This syntax *does not* yet work in Robot Framework 6.x series, but using +:setting:`[Tags]` with a literal value like `-tag` `is already deprecated`__. +If such tags are needed, it is possible to use the :setting:`Test Tags` +setting or escape__ the hyphen like `\-tag`. __ https://robotidy.readthedocs.io __ https://github.com/robotframework/robotframework/issues/4374 From 9c30106fdb912b75826ccb64624edbbbb89f5655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 14 Jul 2023 00:43:37 +0300 Subject: [PATCH 0636/1592] Validate execution mode only after filtering tests/tasks. This allows having both tests and tasks in same suite structure as long as they aren't actually run together. Fixes #4807. --- atest/robot/core/filter_by_names.robot | 8 +++-- atest/robot/rpa/run_rpa_tasks.robot | 29 +++++++++------- atest/robot/tags/include_and_exclude.robot | 8 +++-- atest/testdata/rpa/tests.robot | 1 + src/robot/model/configurer.py | 10 +++--- src/robot/model/testsuite.py | 31 +++++++++++++++-- src/robot/run.py | 2 +- src/robot/running/builder/builders.py | 40 ++++++++-------------- src/robot/running/builder/parsers.py | 6 ++-- src/robot/running/model.py | 2 +- utest/running/test_builder.py | 15 ++++---- 11 files changed, 93 insertions(+), 59 deletions(-) diff --git a/atest/robot/core/filter_by_names.robot b/atest/robot/core/filter_by_names.robot index 563e1798ae0..b90cb13816f 100644 --- a/atest/robot/core/filter_by_names.robot +++ b/atest/robot/core/filter_by_names.robot @@ -138,10 +138,14 @@ Parent suite init files are processed Should Contain Suites ${SUITE} Sub.Suite.1 Suite5 Suite6 Should Contain Suites ${SUITE.suites[0]} .Sui.te.2. Suite4 +Suite containing tasks is ok if only tests are selected + Run And Check Tests --test test Test sources=rpa/tasks rpa/tests.robot + Run And Check Tests --suite tests Test sources=rpa/tasks rpa/tests.robot + *** Keywords *** Run And Check Tests - [Arguments] ${params} @{tests} - Run Tests ${params} ${SUITE FILE} + [Arguments] ${params} @{tests} ${sources}=${SUITE FILE} + Run Tests ${params} ${sources} Stderr Should Be Empty Should Contain Tests ${suite} @{tests} diff --git a/atest/robot/rpa/run_rpa_tasks.robot b/atest/robot/rpa/run_rpa_tasks.robot index e8504351d21..33de99babfa 100644 --- a/atest/robot/rpa/run_rpa_tasks.robot +++ b/atest/robot/rpa/run_rpa_tasks.robot @@ -27,8 +27,8 @@ Task header with --norpa Conflicting headers cause error [Template] Run and validate conflict - rpa/tests.robot rpa/tasks rpa/tasks/aliases.robot tasks tests - rpa/ rpa/tests.robot tests tasks + rpa/tests.robot rpa/tasks Rpa.Tests Rpa.Tasks tests tasks + rpa/ Rpa.Task Aliases Rpa.Tests tasks tests ... [[] ERROR ] Error in file '*[/\\]task_aliases.robot' on line 7: ... Non-existing setting 'Tesk Setup'. Did you mean:\n ... ${SPACE*3}Test Setup\n @@ -66,11 +66,15 @@ Conflicting headers in same file cause error when executing directory --task task rpa/tasks Task --rpa --task Test --test "An* T???" rpa/ Another task Test +Suite containing tests is ok if only tasks are selected + --task task rpa/tasks rpa/tests.robot Task + --suite stuff rpa/tasks rpa/tests.robot Task Another task + Error message is correct if no task match --task or other options [Template] Run and validate no task found - --task nonex matching name 'nonex' - --include xxx --exclude yyy matching tag 'xxx' and not matching tag 'yyy' - --suite nonex --task task matching name 'task' in suite 'nonex' + --rpa --task nonex no tasks matching name 'nonex' + --norpa --include xxx --exclude yyy no tests matching tag 'xxx' and not matching tag 'yyy' + --suite nonex --task task no tests or tasks matching name 'task' in suite 'nonex' Error message is correct if task name is empty or task contains no keywords [Template] NONE @@ -92,20 +96,19 @@ Run and validate test cases Should contain tests ${SUITE} @{tasks} Run and validate conflict - [Arguments] ${paths} ${conflicting} ${this} ${that} @{extra errors} - Run tests without processing output ${EMPTY} ${paths} - ${conflicting} = Normalize path ${DATADIR}/${conflicting} + [Arguments] ${paths} ${suite1} ${suite2} ${mode1} ${mode2} @{extra errors} + Run tests without processing output --name Rpa ${paths} ${extra} = Catenate @{extra errors} ${error} = Catenate - ... [[] ERROR ] Parsing '${conflicting}' failed: Conflicting execution modes. - ... File has ${this} but files parsed earlier have ${that}. - ... Fix headers or use '--rpa' or '--norpa' options to set the execution mode explicitly. + ... [[] ERROR ] Conflicting execution modes: + ... Suite '${suite1}' has ${mode1} but suite '${suite2}' has ${mode2}. + ... Resolve the conflict or use '--rpa' or '--norpa' options to set the execution mode explicitly. Stderr Should Match ${extra}${error}${USAGE TIP}\n Run and validate no task found [Arguments] ${options} ${message} - Run tests without processing output --rpa ${options} rpa/tasks - Stderr Should Be Equal To [ ERROR ] Suite 'Tasks' contains no tasks ${message}.${USAGE TIP}\n + Run tests without processing output ${options} rpa/tasks rpa/tests.robot + Stderr Should Be Equal To [ ERROR ] Suite 'Tasks & Tests' contains ${message}.${USAGE TIP}\n Outputs should contain correct mode information [Arguments] ${rpa} diff --git a/atest/robot/tags/include_and_exclude.robot b/atest/robot/tags/include_and_exclude.robot index 7bc686ddedd..80e66b70779 100644 --- a/atest/robot/tags/include_and_exclude.robot +++ b/atest/robot/tags/include_and_exclude.robot @@ -112,10 +112,14 @@ Non Matching When Running Multiple Suites --include nonex tag 'nonex' Pass And Fail & Normal --include nonex --name MyName tag 'nonex' MyName +Suite containing tasks is ok if only tests are selected + --include test Test sources=rpa/tasks rpa/tests.robot + --exclude task Test sources=rpa/tasks rpa/tests.robot + *** Keywords *** Run And Check Include And Exclude - [Arguments] ${params} @{tests} - Run Tests ${params} ${DATA SOURCES} + [Arguments] ${params} @{tests} ${sources}=${DATA SOURCES} + Run Tests ${params} ${sources} Stderr Should Be Empty Should Contain Tests ${SUITE} @{tests} diff --git a/atest/testdata/rpa/tests.robot b/atest/testdata/rpa/tests.robot index 8d4333a0fd6..d2da2e13c4e 100644 --- a/atest/testdata/rpa/tests.robot +++ b/atest/testdata/rpa/tests.robot @@ -1,3 +1,4 @@ *** Test Cases *** Test + [Tags] test Log Can be executed as a task with --rpa option diff --git a/src/robot/model/configurer.py b/src/robot/model/configurer.py index 23c1c2869db..7c78ec48f7d 100644 --- a/src/robot/model/configurer.py +++ b/src/robot/model/configurer.py @@ -59,15 +59,15 @@ def _filter(self, suite): name = suite.name suite.filter(self.include_suites, self.include_tests, self.include_tags, self.exclude_tags) - if not (suite.test_count or self.empty_suite_ok): - self._raise_no_tests_error(name, suite.rpa) + if not (suite.has_tests or self.empty_suite_ok): + self._raise_no_tests_or_tasks_error(name, suite.rpa) - def _raise_no_tests_error(self, suite, rpa=False): - parts = ['tests' if not rpa else 'tasks', + def _raise_no_tests_or_tasks_error(self, name, rpa): + parts = [{False: 'tests', True: 'tasks', None: 'tests or tasks'}[rpa], self._get_test_selector_msgs(), self._get_suite_selector_msg()] raise DataError("Suite '%s' contains no %s." - % (suite, ' '.join(p for p in parts if p))) + % (name, ' '.join(p for p in parts if p))) def _get_test_selector_msgs(self): parts = [] diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 5e0fa3e6c31..60711773957 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Any, Generic, Iterator, Sequence, Type, TypeVar +from robot.errors import DataError from robot.utils import seq2str, setter from .configurer import SuiteConfigurer @@ -56,7 +57,7 @@ def __init__(self, name: str = '', doc: str = '', metadata: 'Mapping[str, str]|None' = None, source: 'Path|str|None' = None, - rpa: 'bool|None' = None, + rpa: 'bool|None' = False, parent: 'TestSuite|None' = None): self._name = name self.doc = doc @@ -202,6 +203,32 @@ def metadata(self, metadata: 'Mapping[str, str]|None') -> Metadata: """Free suite metadata as a :class:`~.metadata.Metadata` object.""" return Metadata(metadata) + def validate_execution_mode(self) -> 'bool|None': + """Validate that suite execution mode is set consistently. + + Raise an exception if the execution mode is not set (i.e. the :attr:`rpa` + attribute is ``None``) and child suites have conflicting execution modes. + + The execution mode is returned. New in RF 6.1.1. + """ + if self.rpa is None: + rpa = name = None + for suite in self.suites: + suite.validate_execution_mode() + if rpa is None: + rpa = suite.rpa + name = suite.longname + elif rpa is not suite.rpa: + mode1, mode2 = ('tasks', 'tests') if rpa else ('tests', 'tasks') + raise DataError( + f"Conflicting execution modes: Suite '{name}' has {mode1} but " + f"suite '{suite.longname}' has {mode2}. Resolve the conflict " + f"or use '--rpa' or '--norpa' options to set the execution " + f"mode explicitly." + ) + self.rpa = rpa + return self.rpa + @setter def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> 'TestSuites[TestSuite[KW, TC]]': return TestSuites['TestSuite'](self.__class__, self, suites) @@ -409,7 +436,7 @@ def to_dict(self) -> 'dict[str, Any]': data['metadata'] = dict(self.metadata) if self.source: data['source'] = str(self.source) - if self.rpa is not None: + if self.rpa: data['rpa'] = self.rpa if self.has_setup: data['setup'] = self.setup.to_dict() diff --git a/src/robot/run.py b/src/robot/run.py index 869615e4850..8f33e8a3f61 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -444,11 +444,11 @@ def main(self, datasources, **options): lang=settings.languages, allow_empty_suite=settings.run_empty_suite) suite = builder.build(*datasources) - settings.rpa = suite.rpa if settings.pre_run_modifiers: suite.visit(ModelModifier(settings.pre_run_modifiers, settings.run_empty_suite, LOGGER)) suite.configure(**settings.suite_config) + settings.rpa = suite.validate_execution_mode() with pyloggingconf.robot_handler_enabled(settings.log_level): old_max_error_lines = text.MAX_ERROR_LINES old_max_assign_length = text.MAX_ASSIGN_LENGTH diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py index 2ece0ff2210..6e5a18b5052 100644 --- a/src/robot/running/builder/builders.py +++ b/src/robot/running/builder/builders.py @@ -84,8 +84,8 @@ def __init__(self, included_suites: str = 'DEPRECATED', New in RF 6.1. :param rpa: Explicit execution mode. ``True`` for RPA and ``False`` for test - automation. By default, mode is got from data file headers and possible - conflicting headers cause an error. Same as ``--rpa`` or ``--norpa``. + automation. By default, mode is got from data file headers. + Same as ``--rpa`` or ``--norpa``. :param lang: Additional languages to be supported during parsing. Can be a string matching any of the supported language codes or names, @@ -111,7 +111,7 @@ def __init__(self, included_suites: str = 'DEPRECATED', if included_suites != 'DEPRECATED': warnings.warn("'TestSuiteBuilder' argument 'included_suites' is deprecated " "and has no effect. Use the new 'included_files' argument " - "or filter the parsed suite instead.") + "or filter the created suite instead.") def _get_standard_parsers(self, lang: LanguagesLike, process_curdir: bool) -> 'dict[str, Parser]': @@ -201,11 +201,11 @@ def _validate_not_empty(self, suite: TestSuite, multi_source: bool = False): class SuiteStructureParser(SuiteStructureVisitor): def __init__(self, parsers: 'dict[str|None, Parser]', - defaults: 'TestDefaults|None' = None, rpa: 'bool|None' = None): + defaults: 'TestDefaults|None' = None, + rpa: 'bool|None' = None): self.parsers = parsers self.rpa = rpa self.defaults = defaults - self._rpa_given = rpa is not None self.suite: 'TestSuite|None' = None self._stack: 'list[tuple[TestSuite, TestDefaults]]' = [] @@ -215,13 +215,13 @@ def parent_defaults(self) -> 'TestDefaults|None': def parse(self, structure: SuiteStructure) -> TestSuite: structure.visit(self) - suite = cast(TestSuite, self.suite) - suite.rpa = self.rpa - return suite + return cast(TestSuite, self.suite) def visit_file(self, structure: SuiteFile): LOGGER.info(f"Parsing file '{structure.source}'.") suite = self._build_suite_file(structure) + if self.rpa is not None: + suite.rpa = self.rpa if self.suite is None: self.suite = suite else: @@ -239,8 +239,13 @@ def start_directory(self, structure: SuiteDirectory): def end_directory(self, structure: SuiteDirectory): suite, _ = self._stack.pop() - if suite.rpa is None and suite.suites: - suite.rpa = suite.suites[0].rpa + if self.rpa is not None: + suite.rpa = self.rpa + elif suite.rpa is None and suite.suites: + if all(s.rpa is False for s in suite.suites): + suite.rpa = False + elif all(s.rpa is True for s in suite.suites): + suite.rpa = True def _build_suite_file(self, structure: SuiteFile): source = cast(Path, structure.source) @@ -250,7 +255,6 @@ def _build_suite_file(self, structure: SuiteFile): suite = parser.parse_suite_file(source, defaults) if not suite.tests: LOGGER.info(f"Data source '{source}' has no tests or tasks.") - self._validate_execution_mode(suite) except DataError as err: raise DataError(f"Parsing '{source}' failed: {err.message}") return suite @@ -267,20 +271,6 @@ def _build_suite_directory(self, structure: SuiteDirectory): raise DataError(f"Parsing '{source}' failed: {err.message}") return suite, defaults - def _validate_execution_mode(self, suite: TestSuite): - if self._rpa_given: - suite.rpa = self.rpa - elif suite.rpa is None: - pass - elif self.rpa is None: - self.rpa = suite.rpa - elif self.rpa is not suite.rpa: - this, that = ('tasks', 'tests') if suite.rpa else ('tests', 'tasks') - raise DataError(f"Conflicting execution modes. File has {this} " - f"but files parsed earlier have {that}. Fix headers " - f"or use '--rpa' or '--norpa' options to set the " - f"execution mode explicitly.") - class ResourceFileBuilder: diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index df018b9bbf5..8e0bb5e3f8a 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -62,7 +62,8 @@ def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: model = get_init_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) directory = source.parent - suite = TestSuite(name=TestSuite.name_from_source(directory), source=directory) + suite = TestSuite(name=TestSuite.name_from_source(directory), + source=directory, rpa=None) SuiteBuilder(suite, InitFileSettings(defaults)).build(model) return suite @@ -117,7 +118,8 @@ def parse_resource_file(self, source: Path) -> ResourceFile: class NoInitFileDirectoryParser(Parser): def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: - return TestSuite(name=TestSuite.name_from_source(source), source=source) + return TestSuite(name=TestSuite.name_from_source(source), + source=source, rpa=None) class CustomParser(Parser): diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 1965288c6b6..b801048f817 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -416,7 +416,7 @@ def __init__(self, name: str = '', doc: str = '', metadata: 'Mapping[str, str]|None' = None, source: 'Path|str|None' = None, - rpa: 'bool|None' = None, + rpa: 'bool|None' = False, parent: 'TestSuite|None' = None): super().__init__(name, doc, metadata, source, rpa, parent) #: :class:`ResourceFile` instance containing imports, variables and diff --git a/utest/running/test_builder.py b/utest/running/test_builder.py index 907946b0c03..22138d8cd25 100644 --- a/utest/running/test_builder.py +++ b/utest/running/test_builder.py @@ -114,7 +114,15 @@ def test_rpa(self): self._validate_rpa(build(*paths, rpa=True), True) self._validate_rpa(build('../rpa/tasks1.robot'), True) self._validate_rpa(build('../rpa/', rpa=False), False) - assert_raises(DataError, build, '../rpa') + suite = build('../rpa/') + assert_equal(suite.rpa, None) + for child in suite.suites: + self._validate_rpa(child, child.name != 'Tests') + + def _validate_rpa(self, suite, expected): + assert_equal(suite.rpa, expected, suite.name) + for child in suite.suites: + self._validate_rpa(child, expected) def test_custom_parser(self): path = DATADIR / '../parsing/custom/CustomParser.py' @@ -146,11 +154,6 @@ def test_incompatible_parser_object(self): assert_equal(err.message, "Importing parser 'integer' failed: " "'integer' does not have mandatory 'parse' method.") - def _validate_rpa(self, suite, expected): - assert_equal(suite.rpa, expected, suite.name) - for child in suite.suites: - self._validate_rpa(child, expected) - class TestTemplates(unittest.TestCase): From 06b81fba008f6688918c5cfa1cbd1a7e3cb3086e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 14 Jul 2023 01:06:00 +0300 Subject: [PATCH 0637/1592] UG: Enhance docs related to JSON format. Make it explicit that recreating a suite doesn't recreate data files. --- doc/userguide/src/CreatingTestData/TestDataSyntax.rst | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 9c39d629449..490068e596d 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -380,11 +380,20 @@ method. It works both with JSON strings and paths to JSON files: # Create suite from a JSON string. suite = TestSuite.from_json('{"name": "Suite", "tests": [{"name": "Test"}]}') + # Execute suite. Notice that log and report needs to be created separately. + suite.run(output='example.xml') + If you have data as a Python dictionary, you can use `TestSuite.from_dict`__ -instead. +instead. Regardless of how a suite is recreated, it exists only in memory and +original data files on the file system are not recreated. + +As the above example demonstrates, the created suite can be executed using +the `TestSuite.run`__ method. It may, however, be easier to execute a JSON file +directly as explained in the following section. __ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_json __ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.from_dict +__ https://robot-framework.readthedocs.io/en/master/autodoc/robot.running.html#robot.running.model.TestSuite.run Executing JSON files '''''''''''''''''''' From b15a51d54c611966f153cbd6029a35fc8efd5b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 14 Jul 2023 01:59:55 +0300 Subject: [PATCH 0638/1592] Dialogs: Unbind 'o' and 'c' when text field has focus. Fix regression in RF 6.1 that typing 'o' or 'c' to the input dialog isn't possible because they are bind to 'Ok' and 'Cancel' buttons, respectively. Fixes #4812. --- atest/genrunner.py | 2 +- .../standard_libraries/dialogs/dialogs.robot | 5 ++++- .../standard_libraries/dialogs/dialogs.robot | 18 ++++++++++++------ src/robot/libraries/dialogs_py.py | 16 ++++++++++++++-- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/atest/genrunner.py b/atest/genrunner.py index f6e923448a4..cb003ef685a 100755 --- a/atest/genrunner.py +++ b/atest/genrunner.py @@ -50,7 +50,7 @@ def __init__(self, name, tags=None): TESTS.append(TestCase(line.split(' ')[0])) elif parsing_tests and line.strip().startswith('[Tags]'): TESTS[-1].tags = line.split('[Tags]', 1)[1].split() - elif parsing_settings and line.startswith(('Force Tags', 'Default Tags')): + elif parsing_settings and line.startswith(('Force Tags', 'Default Tags', 'Test Tags')): name, value = line.split(' ', 1) SETTINGS.append((name, value.strip())) diff --git a/atest/robot/standard_libraries/dialogs/dialogs.robot b/atest/robot/standard_libraries/dialogs/dialogs.robot index edbdfdb4e36..4ecc2e49f5c 100644 --- a/atest/robot/standard_libraries/dialogs/dialogs.robot +++ b/atest/robot/standard_libraries/dialogs/dialogs.robot @@ -1,6 +1,6 @@ *** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/dialogs/dialogs.robot -Force Tags manual no-ci +Test Tags manual no-ci Resource atest_resource.robot *** Test Cases *** @@ -40,6 +40,9 @@ Get Value From User Cancelled Get Value From User Exited Check Test Case ${TESTNAME} +Get Value From User Shortcuts + Check Test Case ${TESTNAME} + Get Selection From User Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/dialogs/dialogs.robot b/atest/testdata/standard_libraries/dialogs/dialogs.robot index 8635e5529b0..d667fa5ef0e 100644 --- a/atest/testdata/standard_libraries/dialogs/dialogs.robot +++ b/atest/testdata/standard_libraries/dialogs/dialogs.robot @@ -1,6 +1,7 @@ *** Settings *** -Library Dialogs -Library Collections +Library Dialogs +Library Collections +Test Tags manual no-ci *** Variable *** ${FILLER} = Wräp < & シ${SPACE} @@ -32,8 +33,8 @@ Execute Manual Step Exit Execute Manual Step Press <Esc>. This should not be shown!! Get Value From User - ${value} = Get Value From User Type 'value' and press OK. Overwrite me - Should Be Equal ${value} value + ${value} = Get Value From User Type 'robot' and press OK. Overwrite me + Should Be Equal ${value} robot Get Non-ASCII Value From User ${value} = Get Value From User Press <Enter>. ʕ•ᴥ•ʔ @@ -44,8 +45,8 @@ Get Empty Value From User Should Be Equal ${value} ${EMPTY} Get Hidden Value From User - ${value} = Get Value From User Type 'value' and press OK or <Enter>. hidden=yes - Should Be Equal ${value} value + ${value} = Get Value From User Type 'c' and press OK or <Enter>. hidden=yes + Should Be Equal ${value} c ${value} = Get Value From User Press OK or <Enter>. initial value hide Should Be Equal ${value} initial value @@ -60,6 +61,11 @@ Get Value From User Exited Get Value From User ... Press <Esc>.\n\nAlso verify that the long text below is wrapped nicely.\n\n${FILLER*200} +Get Value From User Shortcuts + ${value} = Get Value From User + ... 1. Type 'oc'.\n2. Press <tab> to move focus.\n3. Press <o> to close the dialog. + Should Be Equal ${value} oc + Get Selection From User ${value} = Get Selection From User ... Select 'valuë' and press OK.\n\nAlso verify that the dialog is resized properly. diff --git a/src/robot/libraries/dialogs_py.py b/src/robot/libraries/dialogs_py.py index 316a7e3681c..15dbe974abf 100644 --- a/src/robot/libraries/dialogs_py.py +++ b/src/robot/libraries/dialogs_py.py @@ -27,6 +27,7 @@ class TkDialog(Toplevel): def __init__(self, message, value=None, **config): self._prevent_execution_with_timeouts() self.root = self._get_root() + self._button_bindings = {} super().__init__(self.root) self._initialize_dialog() self.widget = self._create_body(message, value, **config) @@ -92,8 +93,9 @@ def _create_button(self, parent, label, callback): if label: button = Button(parent, text=label, width=10, command=callback, underline=0) button.pack(side=LEFT, padx=5, pady=5) - self.bind(label[0], callback) - self.bind(label[0].lower(), callback) + for char in label[0].upper(), label[0].lower(): + self.bind(char, callback) + self._button_bindings[char] = callback def _left_button_clicked(self, event=None): if self._validate_value(): @@ -135,8 +137,18 @@ def _create_widget(self, parent, default, hidden=False) -> Entry: widget = Entry(parent, show='*' if hidden else '') widget.insert(0, default) widget.select_range(0, END) + widget.bind('<FocusIn>', self._unbind_buttons) + widget.bind('<FocusOut>', self._rebind_buttons) return widget + def _unbind_buttons(self, event): + for char in self._button_bindings: + self.unbind(char) + + def _rebind_buttons(self, event): + for char, callback in self._button_bindings.items(): + self.bind(char, callback) + def _get_value(self) -> str: return self.widget.get() From 1ccac9fc9e53249d498f2b84bc871e2382b8530b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 15 Jul 2023 02:20:31 +0300 Subject: [PATCH 0639/1592] Fix Libdoc init handling if init only has named-only args. Fixes #4820. --- atest/robot/libdoc/python_library.robot | 9 +++++++-- atest/testdata/libdoc/InitWithOnlyNamedOnlyArgs.py | 7 +++++++ src/robot/libdocpkg/robotbuilder.py | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 atest/testdata/libdoc/InitWithOnlyNamedOnlyArgs.py diff --git a/atest/robot/libdoc/python_library.robot b/atest/robot/libdoc/python_library.robot index 1d6d64d34ea..e0d09b4c784 100644 --- a/atest/robot/libdoc/python_library.robot +++ b/atest/robot/libdoc/python_library.robot @@ -53,10 +53,10 @@ Keyword Names Keyword Arguments Keyword Arguments Should Be 0 - Keyword Arguments Should Be 1 loglevel=None + Keyword Arguments Should Be 1 loglevel=None Keyword Documentation - Keyword Doc Should Start With 0 Closes all open connections + Keyword Doc Should Start With 0 Closes all open connections Keyword Doc Should Start With 2 ... Executes the given ``command`` and reads, logs, and returns everything until the prompt. ... @@ -114,6 +114,11 @@ Documentation set in __init__ Run Libdoc And Parse Output ${TESTDATADIR}/DocSetInInit.py Doc Should Be Doc set in __init__!! +__init__ with only named-only arguments + Run Libdoc And Parse Output ${TESTDATADIR}/InitWithOnlyNamedOnlyArgs.py::b=2 + Init Arguments Should Be 0 * a=1 b + Init Doc Should Be 0 xxx + Deprecation Run Libdoc And Parse Output ${TESTDATADIR}/Deprecation.py Keyword Name Should Be 0 Deprecated diff --git a/atest/testdata/libdoc/InitWithOnlyNamedOnlyArgs.py b/atest/testdata/libdoc/InitWithOnlyNamedOnlyArgs.py new file mode 100644 index 00000000000..a26dc8b9249 --- /dev/null +++ b/atest/testdata/libdoc/InitWithOnlyNamedOnlyArgs.py @@ -0,0 +1,7 @@ +class InitWithOnlyNamedOnlyArgs: + + def __init__(self, *, a=1, b): + """xxx""" + + def kw(self): + pass diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index 5cd4f4e34f5..25ee01d7422 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -61,7 +61,7 @@ def _get_doc(self, lib): return lib.doc or f"Documentation for library ``{lib.name}``." def _get_initializers(self, lib): - if lib.init.arguments.maxargs: + if lib.init.arguments: return [KeywordDocBuilder().build_keyword(lib.init)] return [] From c13749346d7dbfc051ae6678391f8fba2085f7af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 15 Jul 2023 22:16:57 +0300 Subject: [PATCH 0640/1592] Windows unit test fix --- utest/running/test_run_model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index b5a34dc4573..4b229e1de9f 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -150,6 +150,7 @@ def test_from_string_with_defaults(self): def _verify_suite(self, suite, name='Test Run Model', tags=('tag',), timeout=None, curdir='${CURDIR}'): + curdir = curdir.replace('\\', '\\\\') assert_equal(suite.name, name) assert_equal(suite.doc, 'Some text.') assert_equal(suite.rpa, False) From 49c8126028f3c6481fb48a625431adec01842f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 24 Jul 2023 00:21:29 +0300 Subject: [PATCH 0641/1592] Handle descriptors wrapped with classmethod propertly. Fixes #4802. --- ...d_properties_when_creating_libraries.robot | 54 ++++++++++- .../test_libraries/AvoidProperties.py | 89 +++++++++++++++++++ ...d_properties_when_creating_libraries.robot | 31 ++++++- src/robot/running/arguments/argumentparser.py | 4 +- src/robot/running/testlibraries.py | 23 ++--- 5 files changed, 183 insertions(+), 18 deletions(-) create mode 100644 atest/testdata/test_libraries/AvoidProperties.py diff --git a/atest/robot/test_libraries/avoid_properties_when_creating_libraries.robot b/atest/robot/test_libraries/avoid_properties_when_creating_libraries.robot index bae1e2bfc41..7098575bee2 100644 --- a/atest/robot/test_libraries/avoid_properties_when_creating_libraries.robot +++ b/atest/robot/test_libraries/avoid_properties_when_creating_libraries.robot @@ -1,7 +1,53 @@ -*** Setting *** +*** Settings *** +Documentation Tests for avoiding properties and handling descriptors. Suite Setup Run Tests ${EMPTY} test_libraries/avoid_properties_when_creating_libraries.robot Resource atest_resource.robot -*** Test Case *** -Python Property - Check Test Case ${TEST NAME} +*** Test Cases *** +Property + Check Test Case ${TESTNAME} + Adding keyword failed normal_property + +Classmethod property + Check Test Case ${TESTNAME} + Adding keyword failed classmethod_property classmethod=True + +Non-data descriptor + Check Test Case ${TESTNAME} + Adding keyword failed non_data_descriptor + +Classmethod non-data descriptor + Check Test Case ${TESTNAME} + Adding keyword failed classmethod_non_data_descriptor classmethod=True + +Data descriptor + Check Test Case ${TESTNAME} + Adding keyword failed data_descriptor + +Classmethod data descriptor + Check Test Case ${TESTNAME} + Adding keyword failed classmethod_data_descriptor classmethod=True + +Failing non-data descriptor + Adding keyword failed failing_non_data_descriptor Getting handler method failed: ZeroDivisionError: + +Failing classmethod non-data descriptor + Adding keyword failed failing_classmethod_non_data_descriptor Getting handler method failed: ZeroDivisionError: classmethod=True + +Failing data descriptor + Adding keyword failed failing_data_descriptor + +Failing classmethod data descriptor + Adding keyword failed failing_classmethod_data_descriptor Getting handler method failed: ZeroDivisionError: classmethod=True + +*** Keywords *** +Adding keyword failed + [Arguments] ${name} ${error}=Not a method or function. ${classmethod}=False + # With Python < 3.9, descriptors wrapped with @classmethod are considered callable by + # inspect.isroutine, but inspect.signature doesn't like them. This results in error + # being reported on different places depending on Python version. + IF ${INTERPRETER.version_info} >= (3, 9) or not ${classmethod} + Syslog Should Contain | INFO \ | In library 'AvoidProperties': Adding keyword '${name}' failed: ${error} + ELSE + Syslog Should Contain | ERROR | Error in library 'AvoidProperties': Adding keyword '${name}' failed: + END diff --git a/atest/testdata/test_libraries/AvoidProperties.py b/atest/testdata/test_libraries/AvoidProperties.py new file mode 100644 index 00000000000..4f82cf23792 --- /dev/null +++ b/atest/testdata/test_libraries/AvoidProperties.py @@ -0,0 +1,89 @@ +class NonDataDescriptor: + + def __init__(self, func): + self.func = func + + def __get__(self, instance, owner): + return self.func(instance) + + +class DataDescriptor(NonDataDescriptor): + + def __set__(self, instance, value): + pass + + +class FailingNonDataDescriptor(NonDataDescriptor): + + def __get__(self, instance, owner): + return 1/0 + + +class FailingDataDescriptor(DataDescriptor): + + def __get__(self, instance, owner): + return 1/0 + + +class AvoidProperties: + normal_property_called = 0 + classmethod_property_called = 0 + non_data_descriptor_called = 0 + classmethod_non_data_descriptor_called = 0 + data_descriptor_called = 0 + classmethod_data_descriptor_called = 0 + + def keyword(self): + pass + + @property + def normal_property(self): + type(self).normal_property_called += 1 + return self.normal_property_called + + @classmethod + @property + def classmethod_property(cls): + cls.classmethod_property_called += 1 + return cls.classmethod_property_called + + @NonDataDescriptor + def non_data_descriptor(self): + type(self).non_data_descriptor_called += 1 + return self.non_data_descriptor_called + + @classmethod + @NonDataDescriptor + def classmethod_non_data_descriptor(cls): + cls.classmethod_non_data_descriptor_called += 1 + return cls.classmethod_non_data_descriptor_called + + @DataDescriptor + def data_descriptor(self): + type(self).data_descriptor_called += 1 + return self.data_descriptor_called + + @classmethod + @DataDescriptor + def classmethod_data_descriptor(cls): + cls.classmethod_data_descriptor_called += 1 + return cls.classmethod_data_descriptor_called + + @FailingNonDataDescriptor + def failing_non_data_descriptor(self): + pass + + @classmethod + @FailingNonDataDescriptor + def failing_classmethod_non_data_descriptor(self): + pass + + @FailingDataDescriptor + def failing_data_descriptor(self): + pass + + @classmethod + @FailingDataDescriptor + def failing_classmethod_data_descriptor(self): + pass + diff --git a/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot b/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot index 0be6d29dfeb..c27ecfc2c3b 100644 --- a/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot +++ b/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot @@ -1,6 +1,31 @@ *** Setting *** -Library newstyleclasses.NewStyleClassLibrary +Suite Setup Keyword +Library AvoidProperties.py +Test Template Attribute value should be *** Test Case *** -Python Property - mirror whatever +Property + normal_property 1 + +Classmethod property + classmethod_property 2 classmethod=True + +Non-data descriptor + non_data_descriptor 2 + +Classmethod non-data descriptor + classmethod_non_data_descriptor 2 classmethod=True + +Data descriptor + data_descriptor 1 + +Classmethod data descriptor + classmethod_data_descriptor 2 classmethod=True + +*** Keywords *** +Attribute value should be + [Arguments] ${attr} ${expected} ${classmethod}=False + ${lib} = Get Library Instance AvoidProperties + IF sys.version_info >= (3, 9) or not ${classmethod} + Should Be Equal As Integers ${lib.${attr}} ${expected} + END diff --git a/src/robot/running/arguments/argumentparser.py b/src/robot/running/arguments/argumentparser.py index 9d7a0ae8245..99fb1588967 100644 --- a/src/robot/running/arguments/argumentparser.py +++ b/src/robot/running/arguments/argumentparser.py @@ -52,9 +52,11 @@ def parse(self, handler, name=None): def _set_args(self, spec, handler): try: sig = signature(handler) - except ValueError: # Can occur w/ C functions (incl. many builtins). + except ValueError: # Can occur with C functions (incl. many builtins). spec.var_positional = 'args' return + except TypeError as err: # Occurs if handler isn't actually callable. + raise DataError(str(err)) parameters = list(sig.parameters.values()) # `inspect.signature` drops `self` with bound methods and that's the case when # inspecting keywords. `__init__` is got directly from class (i.e. isn't bound) diff --git a/src/robot/running/testlibraries.py b/src/robot/running/testlibraries.py index ef0ad84e47f..2115483b4b2 100644 --- a/src/robot/running/testlibraries.py +++ b/src/robot/running/testlibraries.py @@ -289,11 +289,10 @@ def _get_handler_method(self, libcode, name): except Exception: message, details = get_error_details() raise DataError(f'Getting handler method failed: {message}', details) - self._validate_handler_method(method) - return method + return self._validate_handler_method(method) def _validate_handler_method(self, method): - # isroutine returns false for partial objects. This may change in Python 3.11. + # isroutine returns false for partial objects. This may change in the future. if not (inspect.isroutine(method) or isinstance(method, partial)): raise DataError('Not a method or function.') if getattr(method, 'robot_not_keyword', False): @@ -341,14 +340,18 @@ def _raise_creating_instance_failed(self): class _ClassLibrary(_BaseTestLibrary): def _get_handler_method(self, libinst, name): - # Type is checked before using getattr to avoid calling properties. for item in (libinst,) + inspect.getmro(libinst.__class__): - if item is object: - continue - if hasattr(item, '__dict__') and name in item.__dict__: - self._validate_handler_method(item.__dict__[name]) - return getattr(libinst, name) - raise DataError('No non-implicit implementation found.') + # `isroutine` is used before `getattr` to avoid calling properties. + if (name in getattr(item, '__dict__', ()) + and inspect.isroutine(item.__dict__[name])): + try: + method = getattr(libinst, name) + except Exception: + message, traceback = get_error_details() + raise DataError(f'Getting handler method failed: {message}', + traceback) + return self._validate_handler_method(method) + raise DataError('Not a method or function.') class _ModuleLibrary(_BaseTestLibrary): From 2fa708768949fdf7f388f291a8f747c088c4a9a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 24 Jul 2023 11:41:59 +0300 Subject: [PATCH 0642/1592] Fix handling suites with nothing to run if parent suite is skipped. Earlier tests in these suites incorrectly got FAIL status. Fixes #4829. --- atest/robot/running/skip.robot | 4 ++++ .../running/skip/nested/nothing_to_run.robot | 14 ++++++++++++++ src/robot/running/suiterunner.py | 7 +++---- 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 atest/testdata/running/skip/nested/nothing_to_run.robot diff --git a/atest/robot/running/skip.robot b/atest/robot/running/skip.robot index e8a9d2e2f06..886a5f26b03 100644 --- a/atest/robot/running/skip.robot +++ b/atest/robot/running/skip.robot @@ -91,6 +91,10 @@ Skip In Suite Teardown After Fail In Setup Skip In Directory Suite Teardown Check Test Case ${TEST NAME} +Tests have correct status if suite has nothing to run and directory suite setup uses skip + Check Test Case `robot:skip` with skip in directory suite setup + Check Test Case `--skip` with skip in directory suite setup + Skip with Run Keyword and Ignore Error Check Test Case ${TEST NAME} diff --git a/atest/testdata/running/skip/nested/nothing_to_run.robot b/atest/testdata/running/skip/nested/nothing_to_run.robot new file mode 100644 index 00000000000..a58f1757edd --- /dev/null +++ b/atest/testdata/running/skip/nested/nothing_to_run.robot @@ -0,0 +1,14 @@ +*** Settings *** +Documentation These tests are not run due to skip in parent suite setup. +... They should get correct status nevertheless. + +*** Test Cases *** +`robot:skip` with skip in directory suite setup + [Documentation] SKIP Skipped in parent suite setup:\nAll children must be skipped + [Tags] robot:skip + Fail Should not be executed! + +`--skip` with skip in directory suite setup + [Documentation] SKIP Skipped in parent suite setup:\nAll children must be skipped + [Tags] skip-this + Fail Should not be executed! diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index e055c00b613..038bc5016fe 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -85,8 +85,7 @@ def start_suite(self, suite): suites=suite.suites, test_count=suite.test_count)) self._output.register_error_listener(self._suite_status.error_occurred) - if self._any_test_run(suite): - self._run_setup(suite, self._suite_status) + self._run_setup(suite, self._suite_status, run=self._any_test_run(suite)) def _any_test_run(self, suite): skipped_tags = self._skipped_tags @@ -205,8 +204,8 @@ def _get_timeout(self, test): return None return TestTimeout(test.timeout, self._variables, rpa=test.parent.rpa) - def _run_setup(self, item, status, result=None): - if status.passed: + def _run_setup(self, item, status, result=None, run=True): + if run and status.passed: if item.has_setup: exception = self._run_setup_or_teardown(item.setup) else: From 3910f0ca030961bc51b7a04f67e9d38a432e6ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 26 Jul 2023 15:37:17 +0300 Subject: [PATCH 0643/1592] Argument conversion fixes when types have parameters. - Don't convert parameterized types if nested items have correct types. Fixes #4809. - Fix `tuple` with unrecognized parameter type. Fixes #4831. --- .../type_conversion/standard_generics.robot | 38 +++- .../type_conversion/AnnotationsWithTyping.py | 25 +-- .../type_conversion/StandardGenerics.py | 84 ++++++++- .../annotations_with_typing.robot | 10 ++ .../type_conversion/standard_generics.robot | 170 ++++++++++++++---- src/robot/running/arguments/typeconverters.py | 54 ++++-- 6 files changed, 317 insertions(+), 64 deletions(-) diff --git a/atest/robot/keywords/type_conversion/standard_generics.robot b/atest/robot/keywords/type_conversion/standard_generics.robot index aa005a4b91d..f71318c83e7 100644 --- a/atest/robot/keywords/type_conversion/standard_generics.robot +++ b/atest/robot/keywords/type_conversion/standard_generics.robot @@ -1,36 +1,72 @@ *** Settings *** Suite Setup Run Tests ${EMPTY} keywords/type_conversion/standard_generics.robot -Force Tags require-py3.9 +Test Tags require-py3.9 Resource atest_resource.robot *** Test Cases *** List Check Test Case ${TESTNAME} +List with unknown + Check Test Case ${TESTNAME} + +List in union + Check Test Case ${TESTNAME} + Incompatible list Check Test Case ${TESTNAME} Tuple Check Test Case ${TESTNAME} +Tuple with unknown + Check Test Case ${TESTNAME} + +Tuple in union + Check Test Case ${TESTNAME} + Homogenous tuple Check Test Case ${TESTNAME} +Homogenous tuple with unknown + Check Test Case ${TESTNAME} + +Homogenous tuple in union + Check Test Case ${TESTNAME} + Incompatible tuple Check Test Case ${TESTNAME} Dict Check Test Case ${TESTNAME} +Dict with unknown + Check Test Case ${TESTNAME} + +Dict in union + Check Test Case ${TESTNAME} + Incompatible dict Check Test Case ${TESTNAME} Set Check Test Case ${TESTNAME} +Set with unknown + Check Test Case ${TESTNAME} + +Set in union + Check Test Case ${TESTNAME} + Incompatible set Check Test Case ${TESTNAME} +Nested generics + Check Test Case ${TESTNAME} + +Incompatible nested generics + Check Test Case ${TESTNAME} + Invalid list Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py index f0a05af595e..df7cb65ad68 100644 --- a/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py +++ b/atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py @@ -33,20 +33,20 @@ def list_(argument: List, expected=None): _validate_type(argument, expected) -def list_with_types(argument: List[int], expected=None): - _validate_type(argument, expected) +def list_with_types(argument: List[int], expected=None, same=False): + _validate_type(argument, expected, same) def tuple_(argument: Tuple, expected=None): _validate_type(argument, expected) -def tuple_with_types(argument: Tuple[bool, int], expected=None): - _validate_type(argument, expected) +def tuple_with_types(argument: Tuple[bool, int], expected=None, same=False): + _validate_type(argument, expected, same) -def homogenous_tuple(argument: Tuple[int, ...], expected=None): - _validate_type(argument, expected) +def homogenous_tuple(argument: Tuple[int, ...], expected=None, same=False): + _validate_type(argument, expected, same) def sequence(argument: Sequence, expected=None): @@ -69,8 +69,8 @@ def dict_(argument: Dict, expected=None): _validate_type(argument, expected) -def dict_with_types(argument: Dict[int, float], expected=None): - _validate_type(argument, expected) +def dict_with_types(argument: Dict[int, float], expected=None, same=False): + _validate_type(argument, expected, same) def mapping(argument: Mapping, expected=None): @@ -101,8 +101,8 @@ def set_(argument: Set, expected=None): _validate_type(argument, expected) -def set_with_types(argument: Set[int], expected=None): - _validate_type(argument, expected) +def set_with_types(argument: Set[int], expected=None, same=False): + _validate_type(argument, expected, same) def mutable_set(argument: MutableSet, expected=None): @@ -137,10 +137,13 @@ def not_liking_isinstance(argument: BadInt, expected=None): _validate_type(argument, expected) -def _validate_type(argument, expected): +def _validate_type(argument, expected, same=False): if isinstance(expected, str): expected = eval(expected) if argument != expected or type(argument) != type(expected): atype = type(argument).__name__ etype = type(expected).__name__ raise AssertionError(f'{argument!r} ({atype}) != {expected!r} ({etype})') + if same and argument is not expected: + raise AssertionError(f'{argument} (id: {id(argument)}) is not same ' + f'as {expected} (id: {id(expected)})') diff --git a/atest/testdata/keywords/type_conversion/StandardGenerics.py b/atest/testdata/keywords/type_conversion/StandardGenerics.py index 1636167cb6a..36c7efde5f5 100644 --- a/atest/testdata/keywords/type_conversion/StandardGenerics.py +++ b/atest/testdata/keywords/type_conversion/StandardGenerics.py @@ -1,23 +1,98 @@ -def list_(argument: list[int], expected=None): +from typing import Union + + +class Unknown: + pass + + +def list_(argument: list[int], expected=None, same=False): + _validate_type(argument, expected, same) + + +def list_with_unknown(argument: list[Unknown], expected=None): _validate_type(argument, expected) +def list_in_union_1(argument: Union[str, list[str]], expected=None, same=False): + _validate_type(argument, expected, same) + + +def list_in_union_2(argument: Union[list[str], str], expected=None, same=False): + _validate_type(argument, expected, same) + + def tuple_(argument: tuple[int, bool, float], expected=None): _validate_type(argument, expected) +def tuple_with_unknown(argument: tuple[Unknown, int], expected=None): + _validate_type(argument, expected) + + +def tuple_in_union_1(argument: Union[str, tuple[str, str, str]], expected=None): + _validate_type(argument, expected) + + +def tuple_in_union_2(argument: Union[tuple[str, str, str], str], expected=None): + _validate_type(argument, expected) + + def homogenous_tuple(argument: tuple[int, ...], expected=None): _validate_type(argument, expected) -def dict_(argument: dict[int, float], expected=None): +def homogenous_tuple_with_unknown(argument: tuple[Unknown, ...], expected=None): + _validate_type(argument, expected) + + +def homogenous_tuple_in_union_1(argument: Union[str, tuple[str, ...]], expected=None): _validate_type(argument, expected) +def homogenous_tuple_in_union_2(argument: Union[tuple[str, ...], str], expected=None): + _validate_type(argument, expected) + + +def dict_(argument: dict[int, float], expected=None, same=False): + _validate_type(argument, expected, same) + + +def dict_with_unknown_key(argument: dict[Unknown, int], expected=None): + _validate_type(argument, expected) + + +def dict_with_unknown_value(argument: dict[int, Unknown], expected=None): + _validate_type(argument, expected) + + +def dict_in_union_1(argument: Union[str, dict[str, str]], expected=None, same=False): + _validate_type(argument, expected, same) + + +def dict_in_union_2(argument: Union[dict[str, str], str], expected=None, same=False): + _validate_type(argument, expected, same) + + def set_(argument: set[bool], expected=None): _validate_type(argument, expected) +def set_with_unknown(argument: set[Unknown], expected=None): + _validate_type(argument, expected) + + +def set_in_union_1(argument: Union[str, set[str]], expected=None): + _validate_type(argument, expected) + + +def set_in_union_2(argument: Union[set[str], str], expected=None): + _validate_type(argument, expected) + + +def nested_generics(argument: list[tuple[int, int]], expected=None, same=False): + _validate_type(argument, expected, same) + + def invalid_list(a: list[int, float]): pass @@ -34,10 +109,13 @@ def invalid_set(a: set[int, float]): pass -def _validate_type(argument, expected): +def _validate_type(argument, expected, same=False): if isinstance(expected, str): expected = eval(expected) if argument != expected or type(argument) != type(expected): atype = type(argument).__name__ etype = type(expected).__name__ raise AssertionError(f'{argument!r} ({atype}) != {expected!r} ({etype})') + if same and argument is not expected: + raise AssertionError(f'{argument} (id: {id(argument)}) is not same ' + f'as {expected} (id: {id(expected)})') diff --git a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot index 66935233eb7..34e23ffc5d9 100644 --- a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot @@ -15,6 +15,8 @@ List with types List with types [1, 2, 3, -42] [1, 2, 3, -42] List with types [1, '2', 3.0] [1, 2, 3] List with types ${{[1, '2', 3.0]}} [1, 2, 3] + ${obj} = Evaluate list(range(100)) + List with types ${obj} ${obj} same=True List with incompatible types [Template] Conversion Should Fail @@ -38,6 +40,8 @@ Tuple with types Tuple with types ('true', 1) (True, 1) Tuple with types ('ei', '2') (False, 2) # 'ei' -> False is due to language config Tuple with types ${{('no', '3')}} (False, 3) + ${obj} = Evaluate (True, 42) + Tuple with types ${obj} ${obj} same=True Tuple with homogenous types Homogenous tuple () () @@ -46,6 +50,8 @@ Tuple with homogenous types Homogenous tuple (1, '2') (1, 2) Homogenous tuple (1, '2', 3.0, 4, 5) (1, 2, 3, 4, 5) Homogenous tuple ${{(1, '2', 3.0)}} (1, 2, 3) + ${obj} = Evaluate tuple(range(100)) + Homogenous tuple ${obj} ${obj} same=True Tuple with incompatible types [Template] Conversion Should Fail @@ -98,6 +104,8 @@ Dict with types Dict with types {1: 1.1, 2: 2.2} {1: 1.1, 2: 2.2} Dict with types {'1': '2', 3.0: 4} {1: 2, 3: 4} Dict with types ${{{'1': '2', 3.0: 4}}} {1: 2, 3: 4} + ${obj} = Evaluate {i: float(i) for i in range(100)} + Dict with types ${obj} ${obj} same=True Dict with incompatible types [Template] Conversion Should Fail @@ -179,6 +187,8 @@ Set with types Set with types ${{{1, 2.0, '3'}}} {1, 2, 3} Mutable set with types {1, 2, 3.14, -42} {1, 2, 3.14, -42} Mutable set with types ${{{1, 2, 3.14, -42}}} {1, 2, 3.14, -42} + ${obj} = Evaluate set(range(100)) + Set with types ${obj} ${obj} same=True Set with incompatible types [Template] Conversion Should Fail diff --git a/atest/testdata/keywords/type_conversion/standard_generics.robot b/atest/testdata/keywords/type_conversion/standard_generics.robot index 97848d27aed..c9ca5f0279a 100644 --- a/atest/testdata/keywords/type_conversion/standard_generics.robot +++ b/atest/testdata/keywords/type_conversion/standard_generics.robot @@ -3,66 +3,164 @@ language: fi *** Settings *** Library StandardGenerics.py Resource conversion.resource -Force Tags require-py3.9 +Test Tags require-py3.9 + +*** Variables *** +@{INTS} ${1} ${2} ${3} +@{STRINGS} one 2 kolme +@{MIXED} one ${2} kolme +&{INT TO FLOAT} ${1}=${2.3} +&{STR TO STR} a=1 b=2 +&{STR TO INT} a=${1} b=${2} *** Test Cases *** List - List [] [] - List [1, 2, 3] [1, 2, 3] - List ['1', 2.0] [1, 2] + List [] [] + List [1, 2, 3] [1, 2, 3] + List ['1', 2.0] [1, 2] + List ${INTS} ${INTS} same=True + +List with unknown + List with unknown [] [] + List with unknown [1, 2, 3] [1, 2, 3] + List with unknown ${{['1', 2.0]}} ['1', 2.0] + +List in union + List in union 1 ['1', '2'] "['1', '2']" + List in union 1 ${STRINGS} ${STRINGS} same=True + List in union 1 ${MIXED} "${MIXED}" + List in union 2 ['1', '2'] "['1', '2']" + List in union 2 ${STRINGS} ${STRINGS} + List in union 2 ${MIXED} ${STRINGS} Incompatible list - [Template] Conversion should fail - List [1, 'bad'] type=list[int] error=Item '1' got value 'bad' that cannot be converted to integer. - List [1, 2, 3.4] type=list[int] error=Item '2' got value '3.4' (float) that cannot be converted to integer: Conversion would lose precision. + [Template] Conversion should fail + List [1, 'bad'] type=list[int] error=Item '1' got value 'bad' that cannot be converted to integer. + List [1, 2, 3.4] type=list[int] error=Item '2' got value '3.4' (float) that cannot be converted to integer: Conversion would lose precision. Tuple - Tuple (1, 'true', 3.14) (1, True, 3.14) - Tuple ('1', 'ei', '3.14') (1, False, 3.14) # 'ei' -> False conversion is due to language config. + Tuple (1, 'true', 3.14) (1, True, 3.14) + Tuple ('1', 'ei', '3.14') (1, False, 3.14) # 'ei' -> False conversion is due to language config. + +Tuple with unknown + Tuple with unknown (1, '2') (1, 2) + Tuple with unknown ${{('1', '2')}} ('1', 2) + Tuple with unknown ${{['1', 2]}} ('1', 2) + +Tuple in union + Tuple in union 1 ('1', '2', '3') "('1', '2', '3')" + Tuple in union 1 ${{tuple($STRINGS)}} ${{tuple($STRINGS)}} + Tuple in union 1 ${STRINGS} "${STRINGS}" + Tuple in union 1 ${MIXED} "${MIXED}" + Tuple in union 2 ('1', '2', '3') "('1', '2', '3')" + Tuple in union 2 ${{tuple($STRINGS)}} ${{tuple($STRINGS)}} + Tuple in union 2 ${STRINGS} ${{tuple($STRINGS)}} + Tuple in union 2 ${MIXED} ${{tuple($STRINGS)}} Homogenous tuple - Homogenous Tuple () () - Homogenous Tuple (1,) (1,) - Homogenous Tuple (1, 2, '3', 4.0, 5) (1, 2, 3, 4, 5) + Homogenous Tuple () () + Homogenous Tuple (1,) (1,) + Homogenous Tuple (1, 2, '3', 4.0, 5) (1, 2, 3, 4, 5) + +Homogenous tuple with unknown + Homogenous tuple with unknown (1, '2') (1, '2') + Homogenous tuple with unknown ${{('1', '2')}} ('1', '2') + Homogenous tuple with unknown ${{['1', 2]}} ('1', 2) + +Homogenous tuple in union + Homogenous tuple in union 1 ('1', '2', '3') "('1', '2', '3')" + Homogenous tuple in union 1 ${{tuple($STRINGS)}} ${{tuple($STRINGS)}} + Homogenous tuple in union 1 ${STRINGS} "${STRINGS}" + Homogenous tuple in union 1 ${MIXED} "${MIXED}" + Homogenous tuple in union 2 ('1', '2', '3') "('1', '2', '3')" + Homogenous tuple in union 2 ${{tuple($STRINGS)}} ${{tuple($STRINGS)}} + Homogenous tuple in union 2 ${STRINGS} ${{tuple($STRINGS)}} + Homogenous tuple in union 2 ${MIXED} ${{tuple($STRINGS)}} Incompatible tuple - [Template] Conversion should fail - Tuple (1, 2, 'bad') type=tuple[int, bool, float] error=Item '2' got value 'bad' that cannot be converted to float. - Homogenous Tuple (1, '2', 3.0, 'four') type=tuple[int, ...] error=Item '3' got value 'four' that cannot be converted to integer. - Tuple ('too', 'few') type=tuple[int, bool, float] error=Expected 3 items, got 2. - Tuple ('too', 'many', '!', '!') type=tuple[int, bool, float] error=Expected 3 items, got 4. + [Template] Conversion should fail + Tuple (1, 2, 'bad') type=tuple[int, bool, float] error=Item '2' got value 'bad' that cannot be converted to float. + Homogenous Tuple (1, '2', 3.0, 'four') type=tuple[int, ...] error=Item '3' got value 'four' that cannot be converted to integer. + Tuple ('too', 'few') type=tuple[int, bool, float] error=Expected 3 items, got 2. + Tuple (1, True, 3.0, 4) type=tuple[int, bool, float] error=Expected 3 items, got 4. Dict - Dict {} {} - Dict {1: 2} {1: 2} - Dict {1: 2, '3': 4.0} {1: 2, 3: 4} + Dict {} {} + Dict {1: 2} {1: 2} + Dict {1: 2, '3': 4.0} {1: 2, 3: 4} + Dict ${INT TO FLOAT} ${INT TO FLOAT} same=True + +Dict with unknown + Dict with unknown key {} {} + Dict with unknown key {1: 2} {1: 2} + Dict with unknown key ${{{1: 2, '3': '4'}}} {1: 2, '3': 4} + Dict with unknown value {} {} + Dict with unknown value {1: 2} {1: 2} + Dict with unknown value ${{{1: 2, '3': '4'}}} {1: 2, 3: '4'} + +Dict in union + Dict in union 1 {'a': '1'} "{'a': '1'}" + Dict in union 1 ${STR TO STR} ${STR TO STR} same=True + Dict in union 1 ${STR TO INT} "${STR TO INT}" + Dict in union 2 {'a': '1'} "{'a': '1'}" + Dict in union 2 ${STR TO STR} ${STR TO STR} same=True + Dict in union 2 ${STR TO INT} ${{dict($STR_TO_STR)}} Incompatible dict - [Template] Conversion should fail - Dict {1: 2, 'bad': 'item'} type=dict[int, float] error=Key 'bad' cannot be converted to integer. - Dict {1: 'bad'} type=dict[int, float] error=Item '1' got value 'bad' that cannot be converted to float. + [Template] Conversion should fail + Dict {1: 2, 'bad': 'item'} type=dict[int, float] error=Key 'bad' cannot be converted to integer. + Dict {1: 'bad'} type=dict[int, float] error=Item '1' got value 'bad' that cannot be converted to float. Set - Set set() set() - Set {True} {True} - Set {'kyllä', 'ei'} {True, False} # 'kyllä' and 'ei' conversions are due to language config. + Set set() set() + Set {True} {True} + Set {'kyllä', 'ei'} {True, False} # 'kyllä' and 'ei' conversions are due to language config. + +Set with unknown + Set with unknown set() set() + Set with unknown {1, 2, 3} {1, 2, 3} + Set with unknown ${{['1', 2.0]}} {'1', 2.0} + +Set in union + Set in union 1 {'1', '2', '3'} "{'1', '2', '3'}" + Set in union 1 ${{{'1', '2', '3'}}} ${{{'1', '2', '3'}}} + Set in union 1 ${{{1, 2, 3}}} "{1, 2, 3}" + Set in union 2 {'1', '2', '3'} "{'1', '2', '3'}" + Set in union 2 ${{{'1', '2', '3'}}} ${{{'1', '2', '3'}}} + Set in union 2 ${{{1, 2, 3}}} ${{{'1', '2', '3'}}} Incompatible set - [Template] Conversion should fail - Set {()} type=set[bool] error=Item '()' (tuple) cannot be converted to boolean. + [Template] Conversion should fail + Set {()} type=set[bool] error=Item '()' (tuple) cannot be converted to boolean. + +Nested generics + Nested generics [] [] + Nested generics [(1, 2)] [(1, 2)] + Nested generics [('1', '2'), (3, 4)] [(1, 2), (3, 4)] + ${obj} = Evaluate [(1, 2), (3, 4), (5, -1)] + Nested generics ${obj} ${obj} same=True + +Incompatible nested generics + [Template] Conversion should fail + Nested generics 1 type=list[tuple[int, int]] + ... error=Value is integer, not list. + Nested generics [1] type=list[tuple[int, int]] + ... error=Item '0' got value '1' (integer) that cannot be converted to tuple[int, int]. + Nested generics [(1, 'x')] type=list[tuple[int, int]] + ... error=Item '0' got value '(1, 'x')' (tuple) that cannot be converted to tuple[int, int]: Item '1' got value 'x' that cannot be converted to integer. Invalid list - [Documentation] FAIL TypeError: list[] construct used as a type hint requires exactly 1 nested type, got 2. - Invalid List whatever + [Documentation] FAIL TypeError: list[] construct used as a type hint requires exactly 1 nested type, got 2. + Invalid List whatever Invalid tuple - [Documentation] FAIL TypeError: Homogenous tuple used as a type hint requires exactly one nested type, got 2. - Invalid Tuple whatever + [Documentation] FAIL TypeError: Homogenous tuple used as a type hint requires exactly one nested type, got 2. + Invalid Tuple whatever Invalid dict - [Documentation] FAIL TypeError: dict[] construct used as a type hint requires exactly 2 nested types, got 1. - Invalid Dict whatever + [Documentation] FAIL TypeError: dict[] construct used as a type hint requires exactly 2 nested types, got 1. + Invalid Dict whatever Invalid set - [Documentation] FAIL TypeError: set[] construct used as a type hint requires exactly 1 nested type, got 2. - Invalid set whatever + [Documentation] FAIL TypeError: set[] construct used as a type hint requires exactly 1 nested type, got 2. + Invalid set whatever diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index d310c1348be..3ab51987128 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -460,9 +460,11 @@ def handles(cls, type_): return super().handles(type_) and type_ is not Tuple def no_conversion_needed(self, value): - if isinstance(value, str) or self.converter: + if isinstance(value, str) or not super().no_conversion_needed(value): return False - return super().no_conversion_needed(value) + if not self.converter: + return True + return all(self.converter.no_conversion_needed(v) for v in value) def _non_string_convert(self, value, explicit_type=True): return self._convert_items(list(value), explicit_type) @@ -498,10 +500,18 @@ def __init__(self, used_type, custom_converters=None, languages=None): self.homogenous = True self.type_name = type_repr(used_type) self.converters = tuple(self.converter_for(t, custom_converters, languages) - for t in types) + or NullConverter() for t in types) def no_conversion_needed(self, value): - return super().no_conversion_needed(value) and not self.converters + if isinstance(value, str) or not super().no_conversion_needed(value): + return False + if not self.converters: + return True + if self.homogenous: + return all(self.converters[0].no_conversion_needed(v) for v in value) + if len(value) != len(self.converters): + return False + return all(c.no_conversion_needed(v) for c, v in zip(self.converters, value)) def _non_string_convert(self, value, explicit_type=True): return self._convert_items(tuple(value), explicit_type) @@ -588,10 +598,17 @@ def __init__(self, used_type, custom_converters=None, languages=None): else: self.type_name = type_repr(used_type) self.converters = tuple(self.converter_for(t, custom_converters, languages) - for t in types) + or NullConverter() for t in types) def no_conversion_needed(self, value): - return super().no_conversion_needed(value) and not self.converters + if isinstance(value, str) or not super().no_conversion_needed(value): + return False + if not self.converters: + return True + no_key_conversion_needed = self.converters[0].no_conversion_needed + no_value_conversion_needed = self.converters[1].no_conversion_needed + return all(no_key_conversion_needed(k) and no_value_conversion_needed(v) + for k, v in value.items()) def _non_string_convert(self, value, explicit_type=True): if self._used_type_is_dict() and not isinstance(value, dict): @@ -608,13 +625,11 @@ def _convert(self, value, explicit_type=True): def _convert_items(self, value, explicit_type): if not self.converters: return value - convert_key = self.__get_converter(self.converters[0], explicit_type, 'Key') - convert_value = self.__get_converter(self.converters[1], explicit_type, 'Item') - return {convert_key(None, k): convert_value(str(k), v) for k, v in value.items()} + convert_key = self._get_converter(self.converters[0], explicit_type, 'Key') + convert_value = self._get_converter(self.converters[1], explicit_type, 'Item') + return {convert_key(None, k): convert_value(k, v) for k, v in value.items()} - def __get_converter(self, converter, explicit_type, kind): - if not converter: - return lambda name, value: value + def _get_converter(self, converter, explicit_type, kind): return lambda name, value: converter.convert(name, value, explicit_type, kind=kind) @@ -636,7 +651,11 @@ def __init__(self, used_type, custom_converters=None, languages=None): self.converter = self.converter_for(types[0], custom_converters, languages) def no_conversion_needed(self, value): - return super().no_conversion_needed(value) and not self.converter + if isinstance(value, str) or not super().no_conversion_needed(value): + return False + if not self.converter: + return True + return all(self.converter.no_conversion_needed(v) for v in value) def _non_string_convert(self, value, explicit_type=True): return self._convert_items(set(value), explicit_type) @@ -755,3 +774,12 @@ def _convert(self, value, explicit_type=True): raise except Exception: raise ValueError(get_error_message()) + + +class NullConverter: + + def convert(self, name, value, explicit_type=True, strict=True, kind='Argument'): + return value + + def no_conversion_needed(self, value): + return True From cb96cbd89434ba65c66068f6bf50247486d0ef3d Mon Sep 17 00:00:00 2001 From: Mateusz Nojek <matnojek@gmail.com> Date: Fri, 28 Jul 2023 15:22:38 +0200 Subject: [PATCH 0644/1592] Update Polish translation to title case (#4801) Fixes #4800. --- src/robot/conf/languages.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index c3c883bd160..befcb01f93b 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -732,9 +732,9 @@ class Pl(Language): """Polish""" settings_header = 'Ustawienia' variables_header = 'Zmienne' - test_cases_header = 'Przypadki testowe' + test_cases_header = 'Przypadki Testowe' tasks_header = 'Zadania' - keywords_header = 'Słowa kluczowe' + keywords_header = 'Słowa Kluczowe' comments_header = 'Komentarze' library_setting = 'Biblioteka' resource_setting = 'Zasób' @@ -742,24 +742,24 @@ class Pl(Language): name_setting = 'Nazwa' documentation_setting = 'Dokumentacja' metadata_setting = 'Metadane' - suite_setup_setting = 'Inicjalizacja zestawu' - suite_teardown_setting = 'Ukończenie zestawu' - test_setup_setting = 'Inicjalizacja testu' - test_teardown_setting = 'Ukończenie testu' - test_template_setting = 'Szablon testu' - test_timeout_setting = 'Limit czasowy testu' - test_tags_setting = 'Znaczniki testu' - task_setup_setting = 'Inicjalizacja zadania' - task_teardown_setting = 'Ukończenie zadania' - task_template_setting = 'Szablon zadania' - task_timeout_setting = 'Limit czasowy zadania' - task_tags_setting = 'Znaczniki zadania' - keyword_tags_setting = 'Znaczniki słowa kluczowego' + suite_setup_setting = 'Inicjalizacja Zestawu' + suite_teardown_setting = 'Ukończenie Zestawu' + test_setup_setting = 'Inicjalizacja Testu' + test_teardown_setting = 'Ukończenie Testu' + test_template_setting = 'Szablon Testu' + test_timeout_setting = 'Limit Czasowy Testu' + test_tags_setting = 'Znaczniki Testu' + task_setup_setting = 'Inicjalizacja Zadania' + task_teardown_setting = 'Ukończenie Zadania' + task_template_setting = 'Szablon Zadania' + task_timeout_setting = 'Limit Czasowy Zadania' + task_tags_setting = 'Znaczniki Zadania' + keyword_tags_setting = 'Znaczniki Słowa Kluczowego' tags_setting = 'Znaczniki' setup_setting = 'Inicjalizacja' teardown_setting = 'Ukończenie' template_setting = 'Szablon' - timeout_setting = 'Limit czasowy' + timeout_setting = 'Limit Czasowy' arguments_setting = 'Argumenty' given_prefixes = ['Zakładając', 'Zakładając, że', 'Mając'] when_prefixes = ['Jeżeli', 'Jeśli', 'Gdy', 'Kiedy'] From d7fe62095ba4f07a07c2ff596c960b5bae19c967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Jul 2023 15:42:05 +0300 Subject: [PATCH 0645/1592] Fix PyPy with invalid language config. Fixes #4833. --- src/robot/utils/encodingsniffer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/robot/utils/encodingsniffer.py b/src/robot/utils/encodingsniffer.py index f96cff7716c..14e20faa9ab 100644 --- a/src/robot/utils/encodingsniffer.py +++ b/src/robot/utils/encodingsniffer.py @@ -53,12 +53,17 @@ def _get_encoding(platform_getters, default): def _get_python_system_encoding(): - return locale.getpreferredencoding(False) + # ValueError occurs with PyPy 3.10 if language config is invalid. + # https://foss.heptapod.net/pypy/pypy/-/issues/3975 + try: + return locale.getpreferredencoding(False) + except ValueError: + return None def _get_unixy_encoding(): - # Cannot use `locale.getdefaultlocale()` because it raises ValueError - # if encoding is invalid. Using same environment variables here anyway. + # Cannot use `locale.getdefaultlocale()` because it is deprecated. + # Using same environment variables here anyway. # https://docs.python.org/3/library/locale.html#locale.getdefaultlocale for name in 'LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE': if name in os.environ: From 550ed6d24afc34b50a938ecd2d6897328ecf1489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Jul 2023 16:23:05 +0300 Subject: [PATCH 0646/1592] atest: Don't generate log and report if all tests pass. --- atest/run.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/atest/run.py b/atest/run.py index 8296cea4430..5ac69b68f74 100755 --- a/atest/run.py +++ b/atest/run.py @@ -48,13 +48,15 @@ ARGUMENTS = ''' --doc Robot Framework acceptance tests --metadata interpreter:{interpreter} ---variablefile {variable_file};{interpreter.path};{interpreter.name};{interpreter.version} +--variable-file {variable_file};{interpreter.path};{interpreter.name};{interpreter.version} --pythonpath {pythonpath} ---outputdir {outputdir} +--output-dir {outputdir} --splitlog --console dotted ---consolewidth 100 ---SuiteStatLevel 3 +--console-width 100 +--suite-stat-Level 3 +--log NONE +--report NONE '''.strip() @@ -65,7 +67,8 @@ def atests(interpreter, arguments, schema_validation=False): sys.exit(err) outputdir, tempdir = _get_directories(interpreter) arguments = list(_get_arguments(interpreter, outputdir)) + list(arguments) - return _run(arguments, tempdir, interpreter, schema_validation) + rc = _run(arguments, tempdir, interpreter, schema_validation) + _rebot(rc, outputdir) def _get_directories(interpreter): @@ -107,6 +110,15 @@ def _run(args, tempdir, interpreter, schema_validation): return subprocess.call(command, env=environ) +def _rebot(rc, outputdir): + if rc == 0: + print('All tests passed, not generating log and report.') + elif rc < 251: + command = [sys.executable, str(CURDIR.parent / 'src/robot/rebot.py'), + '--output-dir', str(outputdir), str(outputdir / 'output.xml')] + subprocess.call(command) + + if __name__ == '__main__': parser = argparse.ArgumentParser(add_help=False) parser.add_argument('-I', '--interpreter', default=sys.executable) From 145d25b7c068ef0a7031eeb2c3d7b43807c7cfaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Jul 2023 19:07:29 +0300 Subject: [PATCH 0647/1592] regen --- doc/userguide/src/Appendices/Translations.rst | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/doc/userguide/src/Appendices/Translations.rst b/doc/userguide/src/Appendices/Translations.rst index 80aef24ade2..e39a746f38b 100644 --- a/doc/userguide/src/Appendices/Translations.rst +++ b/doc/userguide/src/Appendices/Translations.rst @@ -1310,11 +1310,11 @@ Section headers * - Variables - Zmienne * - Test Cases - - Przypadki testowe + - Przypadki Testowe * - Tasks - Zadania * - Keywords - - Słowa kluczowe + - Słowa Kluczowe * - Comments - Komentarze @@ -1342,31 +1342,31 @@ Settings * - Metadata - Metadane * - Suite Setup - - Inicjalizacja zestawu + - Inicjalizacja Zestawu * - Suite Teardown - - Ukończenie zestawu + - Ukończenie Zestawu * - Test Setup - - Inicjalizacja testu + - Inicjalizacja Testu * - Task Setup - - Inicjalizacja zadania + - Inicjalizacja Zadania * - Test Teardown - - Ukończenie testu + - Ukończenie Testu * - Task Teardown - - Ukończenie zadania + - Ukończenie Zadania * - Test Template - - Szablon testu + - Szablon Testu * - Task Template - - Szablon zadania + - Szablon Zadania * - Test Timeout - - Limit czasowy testu + - Limit Czasowy Testu * - Task Timeout - - Limit czasowy zadania + - Limit Czasowy Zadania * - Test Tags - - Znaczniki testu + - Znaczniki Testu * - Task Tags - - Znaczniki zadania + - Znaczniki Zadania * - Keyword Tags - - Znaczniki słowa kluczowego + - Znaczniki Słowa Kluczowego * - Tags - Znaczniki * - Setup @@ -1376,7 +1376,7 @@ Settings * - Template - Szablon * - Timeout - - Limit czasowy + - Limit Czasowy * - Arguments - Argumenty From eecbeaf53be9c770c74fc932511106e2f2a431e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Jul 2023 22:19:09 +0300 Subject: [PATCH 0648/1592] Release notes for 6.1.1 --- doc/releasenotes/rf-6.1.1.rst | 133 ++++++++++++++++++++++++++++++++++ doc/releasenotes/rf-6.1.rst | 1 + 2 files changed, 134 insertions(+) create mode 100644 doc/releasenotes/rf-6.1.1.rst diff --git a/doc/releasenotes/rf-6.1.1.rst b/doc/releasenotes/rf-6.1.1.rst new file mode 100644 index 00000000000..d1de38d8e0e --- /dev/null +++ b/doc/releasenotes/rf-6.1.1.rst @@ -0,0 +1,133 @@ +===================== +Robot Framework 6.1.1 +===================== + +.. default-role:: code + +`Robot Framework`_ 6.1.1 is the first bug fix release in the `Robot Framework +6.1 <rf-6.1.rst>`_ series. + +Questions and comments related to the release can be sent to the +`robotframework-users`_ mailing list or to `Robot Framework Slack`_, +and possible bugs submitted to the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade robotframework + +to install the latest available release or use + +:: + + pip install robotframework==6.1.1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. For more details and other +installation approaches, see the `installation instructions`_. + +Robot Framework 6.1.1 was released on Friday July 28, 2023. + +.. _Robot Framework: http://robotframework.org +.. _Robot Framework Foundation: http://robotframework.org/foundation +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework +.. _issue tracker milestone: https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1.1 +.. _issue tracker: https://github.com/robotframework/robotframework/issues +.. _robotframework-users: http://groups.google.com/group/robotframework-users +.. _Slack: http://slack.robotframework.org +.. _Robot Framework Slack: Slack_ +.. _installation instructions: ../../INSTALL.rst + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +- Robot Framework 6.1 added shortcuts for the buttons in the dialogs used by + the Dialogs__ library. Shortcuts work well otherwise, but `o` and `c` were + bound for the `Ok` and `Cancel` buttons also when typing text with the + `Get Value From User` keyword. This is now fixed and it is possible to input + `o` and `c` characters again. (`#4812`_) + +- Execution mode (test execution vs. RPA) is checked only after selecting which + test or tasks are actually run. This fixes a regression when using the `--suite` + option for selecting which suites to run and is an enhancement with `--include`, + `--exclude`, `--test` or `--task`. (`#4807`_) + +- Argument conversion does not anymore unnecessarily convert containers + with nested items when using parameterized types like `list[str]` if items + have correct types. Most importantly, this fixes a bug when using such types + in an union with `str` like `str | list[str]`. (`#4809`_) + +_ A library using `@classmethod` and `@property` together does not anymore + crash the whole execution. (`#4802`_) + +__ https://robotframework.org/robotframework/latest/libraries/Dialogs.html + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#4812`_ + - bug + - critical + - Dialogs: Cannot type `o` or `c` to input dialog due to they being registered as shortcuts + * - `#4802`_ + - bug + - high + - Library using `@classmethod` and `@property` together crashes execution and fails with Libdoc + * - `#4809`_ + - bug + - high + - Parameterized types are not converted when used in union with `str` like `str | list[str]` + * - `#4807`_ + - enhancement + - high + - Execution mode should be checked only after selecting which test/tasks are run + * - `#4820`_ + - bug + - medium + - Libdoc does not show inits having only named-only arguments + * - `#4829`_ + - bug + - medium + - Test cases with tag 'robot:skip' will fail when whole suite is skipped + * - `#4831`_ + - bug + - medium + - Argument conversion fails if `tuple` has unrecognized parameter + * - `#4833`_ + - bug + - medium + - Execution fails with PyPy if language configuration is invalid + * - `#4816`_ + - bug + - low + - User Guide: Forthcoming `-tag` syntax will be added in RF 7.0, not in RF 6.1 + * - `#4800`_ + - enhancement + - low + - Update Polish translations to use title case + +Altogether 10 issues. View on the `issue tracker <https://github.com/robotframework/robotframework/issues?q=milestone%3Av6.1.1>`__. + +.. _#4812: https://github.com/robotframework/robotframework/issues/4812 +.. _#4802: https://github.com/robotframework/robotframework/issues/4802 +.. _#4809: https://github.com/robotframework/robotframework/issues/4809 +.. _#4807: https://github.com/robotframework/robotframework/issues/4807 +.. _#4820: https://github.com/robotframework/robotframework/issues/4820 +.. _#4829: https://github.com/robotframework/robotframework/issues/4829 +.. _#4831: https://github.com/robotframework/robotframework/issues/4831 +.. _#4833: https://github.com/robotframework/robotframework/issues/4833 +.. _#4816: https://github.com/robotframework/robotframework/issues/4816 +.. _#4800: https://github.com/robotframework/robotframework/issues/4800 diff --git a/doc/releasenotes/rf-6.1.rst b/doc/releasenotes/rf-6.1.rst index c068f01a7f6..4c6d37b7be8 100644 --- a/doc/releasenotes/rf-6.1.rst +++ b/doc/releasenotes/rf-6.1.rst @@ -30,6 +30,7 @@ distribution from PyPI_ and install it manually. For more details and other installation approaches, see the `installation instructions`_. Robot Framework 6.1 was released on Monday June 12, 2023. +It was superseded by `Robot Framework 6.1.1 <rf-6.1.1.rst>`_. .. _Robot Framework: http://robotframework.org .. _Robot Framework Foundation: http://robotframework.org/foundation From a0a2e596fdb352ad4970dff64feb04d38279d76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Jul 2023 22:19:21 +0300 Subject: [PATCH 0649/1592] Updated version to 6.1.1 --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 23cd1365e70..1d3a55384dd 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1.1.dev1' +VERSION = '6.1.1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index d3e56480240..81883d307ae 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1.1.dev1' +VERSION = '6.1.1' def get_version(naked=False): From c9c9d414d8aa66959d3df7ef1895516c1d37d6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Jul 2023 22:25:38 +0300 Subject: [PATCH 0650/1592] Fix formatting --- doc/releasenotes/rf-6.1.1.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/releasenotes/rf-6.1.1.rst b/doc/releasenotes/rf-6.1.1.rst index d1de38d8e0e..1a1968695ac 100644 --- a/doc/releasenotes/rf-6.1.1.rst +++ b/doc/releasenotes/rf-6.1.1.rst @@ -59,11 +59,11 @@ Most important enhancements `--exclude`, `--test` or `--task`. (`#4807`_) - Argument conversion does not anymore unnecessarily convert containers - with nested items when using parameterized types like `list[str]` if items + with nested items when using parameterized types like `list[str]` and items have correct types. Most importantly, this fixes a bug when using such types in an union with `str` like `str | list[str]`. (`#4809`_) -_ A library using `@classmethod` and `@property` together does not anymore +- A library using `@classmethod` and `@property` together does not anymore crash the whole execution. (`#4802`_) __ https://robotframework.org/robotframework/latest/libraries/Dialogs.html From e0da54f33fbdb7e3f637d52168274c16c7480840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 28 Jul 2023 22:27:52 +0300 Subject: [PATCH 0651/1592] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 1d3a55384dd..1f3c2151867 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1.1' +VERSION = '6.1.2.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 81883d307ae..85d18edca77 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1.1' +VERSION = '6.1.2.dev1' def get_version(naked=False): From fd50cb6775f5115ddc665eef058bfb09c375459d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 31 Jul 2023 19:33:22 +0300 Subject: [PATCH 0652/1592] UG: Fix example. Avoid deprecated API. --- doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 84bfaca2e1e..abd55093549 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -637,7 +637,7 @@ adds a new test to each executed test suite and a new keyword to each test. suite.tests.create(name='New test') def start_test(test, result): - test.keywords.create(name='Log', args=['Keyword added by listener!']) + test.body.create_keyword(name='Log', args=['Keyword added by listener!']) Trying to modify execution in `end_suite` or `end_test` methods does not work, simply because that suite or test has already been executed. Trying to modify From bfc91ce0dba65905760b2d3906647d05abe90658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 24 Aug 2023 00:50:22 +0300 Subject: [PATCH 0653/1592] atest: Exit test run with correct rc. Also better wording in message about not generating log/report if all tests pass. --- atest/run.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/atest/run.py b/atest/run.py index 5ac69b68f74..5cb6b1eabdc 100755 --- a/atest/run.py +++ b/atest/run.py @@ -69,6 +69,7 @@ def atests(interpreter, arguments, schema_validation=False): arguments = list(_get_arguments(interpreter, outputdir)) + list(arguments) rc = _run(arguments, tempdir, interpreter, schema_validation) _rebot(rc, outputdir) + return rc def _get_directories(interpreter): @@ -112,7 +113,7 @@ def _run(args, tempdir, interpreter, schema_validation): def _rebot(rc, outputdir): if rc == 0: - print('All tests passed, not generating log and report.') + print('All tests passed, not generating log or report.') elif rc < 251: command = [sys.executable, str(CURDIR.parent / 'src/robot/rebot.py'), '--output-dir', str(outputdir), str(outputdir / 'output.xml')] From b092961a4833b5e6b4c75c449f017c5e3a75f186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 28 Aug 2023 12:03:51 +0300 Subject: [PATCH 0654/1592] RF 7 developent can start! --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 1f3c2151867..18234a6ad10 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1.2.dev1' +VERSION = '7.0.dev1' with open(join(dirname(abspath(__file__)), 'README.rst')) as f: LONG_DESCRIPTION = f.read() base_url = 'https://github.com/robotframework/robotframework/blob/master' diff --git a/src/robot/version.py b/src/robot/version.py index 85d18edca77..ed4b7f4787a 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version <version>`. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '6.1.2.dev1' +VERSION = '7.0.dev1' def get_version(naked=False): From 48e65dc44abe17b15a2c5842a75ee4cb2835fd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 28 Aug 2023 12:59:23 +0300 Subject: [PATCH 0655/1592] Bye bye Python 3.6 and 3.7! You served us well. --- .../workflows/acceptance_tests_cpython.yml | 2 +- .../workflows/acceptance_tests_cpython_pr.yml | 2 +- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- setup.py | 4 +-- src/robot/api/interfaces.py | 14 +------- src/robot/htmldata/template.py | 6 ++-- src/robot/model/control.py | 5 +-- src/robot/model/testcase.py | 5 ++- src/robot/model/testsuite.py | 5 ++- src/robot/result/model.py | 8 ++--- src/robot/running/arguments/typeconverters.py | 10 +++--- src/robot/running/builder/settings.py | 26 ++++++--------- src/robot/running/context.py | 3 -- src/robot/running/model.py | 7 ++-- src/robot/utils/encodingsniffer.py | 2 +- src/robot/utils/robottypes.py | 32 ++++++------------- src/robot/utils/setter.py | 5 --- 18 files changed, 42 insertions(+), 98 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 2a79240e9ac..af2acbf9dfa 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index f3c1c490f6c..3144764e5ba 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.7', '3.11' ] + python-version: [ '3.8', '3.11' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 835e2820d66..c6fccb3076f 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', 'pypy-3.8' ] exclude: - os: windows-latest python-version: 'pypy-3.8' diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml index 21553589059..8d864ec3370 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.7', '3.11' ] + python-version: [ '3.8', '3.11' ] runs-on: ${{ matrix.os }} diff --git a/setup.py b/setup.py index 18234a6ad10..40f568dca5b 100755 --- a/setup.py +++ b/setup.py @@ -22,8 +22,6 @@ Operating System :: OS Independent Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only -Programming Language :: Python :: 3.6 -Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 @@ -66,7 +64,7 @@ long_description_content_type = 'text/x-rst', keywords = KEYWORDS, platforms = 'any', - python_requires='>=3.6', + python_requires='>=3.8', classifiers = CLASSIFIERS, package_dir = {'': 'src'}, package_data = {'robot': PACKAGE_DATA}, diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index 451ee223ccc..ce138c9979d 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -34,9 +34,6 @@ .. note:: These classes are not exposed via the top level :mod:`robot.api` package and need to imported via :mod:`robot.api.interfaces`. -.. note:: Using this module requires having the typing_extensions__ module - installed when using Python 3.6 or 3.7. - This module is new in Robot Framework 6.1. __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dynamic-library-api @@ -50,16 +47,7 @@ import sys from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union -# Need to use version check and not try/except to support Mypy's stubgen. -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - try: - from typing_extensions import TypedDict - except ImportError: - raise ImportError("Using the 'robot.api.interfaces' module requires having " - "the 'typing_extensions' module installed with Python < 3.8.") +from typing import Any, Dict, List, Optional, Sequence, Tuple, TypedDict, Union if sys.version_info >= (3, 10): from types import UnionType else: diff --git a/src/robot/htmldata/template.py b/src/robot/htmldata/template.py index 39a8452f5b4..f83128922c7 100644 --- a/src/robot/htmldata/template.py +++ b/src/robot/htmldata/template.py @@ -27,13 +27,13 @@ except ImportError: raise ImportError( "'importlib_resources' backport module needs to be installed with " - "Python 3.8 and older when Robot Framework is distributed as a zip " - "package or '__file__' does not exist for other reasons." + "Python 3.8 when Robot Framework is distributed as a zip package " + "or '__file__' does not exist for other reasons." ) else: try: from importlib.resources import files - except ImportError: # Python 3.8 or older + except ImportError: # Python 3.8 BASE_DIR = Path(__file__).absolute().parent.parent.parent # zipsafe def files(module): diff --git a/src/robot/model/control.py b/src/robot/model/control.py index ad306a4c024..3afd1541d30 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -13,10 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys -from typing import Any, cast, Sequence, TypeVar, TYPE_CHECKING -if sys.version_info >= (3, 8): - from typing import Literal +from typing import Any, cast, Literal, Sequence, TypeVar, TYPE_CHECKING from robot.utils import setter diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index c427dcef40d..ebbfef1c191 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys from pathlib import Path from typing import Any, Generic, Sequence, Type, TYPE_CHECKING, TypeVar @@ -35,7 +34,7 @@ KW = TypeVar('KW', bound='Keyword', covariant=True) -class TestCase(ModelObject, Generic[KW] if sys.version_info >= (3, 7) else object): +class TestCase(ModelObject, Generic[KW]): """Base model for a single test case. Extended by :class:`robot.running.model.TestCase` and @@ -43,7 +42,7 @@ class TestCase(ModelObject, Generic[KW] if sys.version_info >= (3, 7) else objec """ body_class = Body # See model.TestSuite on removing the type ignore directive - fixture_class: Type[KW] = Keyword # type: ignore + fixture_class: Type[KW] = Keyword # type: ignore repr_args = ('name',) __slots__ = ['parent', 'name', 'doc', 'timeout', 'lineno', '_setup', '_teardown'] diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 60711773957..2f9603b0f4a 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys from collections.abc import Mapping from pathlib import Path from typing import Any, Generic, Iterator, Sequence, Type, TypeVar @@ -37,14 +36,14 @@ TC = TypeVar('TC', bound=TestCase, covariant=True) -class TestSuite(ModelObject, Generic[KW, TC] if sys.version_info >= (3, 7) else object): +class TestSuite(ModelObject, Generic[KW, TC]): """Base model for single suite. Extended by :class:`robot.running.model.TestSuite` and :class:`robot.result.model.TestSuite`. """ # FIXME: Type Ignore declarations: Typevars only accept subclasses of the bound class - # assiging `Type[KW]` to `Keyword` results in an error. In RF 7 the class should be + # assigning `Type[KW]` to `Keyword` results in an error. In RF 7 the class should be # made impossible to instantiate directly, and the assignments can be replaced with # KnownAtRuntime fixture_class: Type[KW] = Keyword # type: ignore diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 56930d9d943..6aea410ee1b 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -35,21 +35,17 @@ """ -import sys import warnings from collections import OrderedDict from datetime import datetime, timedelta from itertools import chain from pathlib import Path -from typing import Generic, Mapping, Sequence, Type, Union, TypeVar - -if sys.version_info >= (3, 8): - from typing import Literal +from typing import Generic, Literal, Mapping, Sequence, Type, Union, TypeVar from robot import model from robot.model import (BodyItem, create_fixture, DataDict, Keywords, Tags, SuiteVisitor, TotalStatistics, TotalStatisticsBuilder, - TestCases, TestSuites) + TestSuites) from robot.utils import copy_signature, get_elapsed_time, KnownAtRuntime, setter from .configurer import SuiteConfigurer diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 3ab51987128..c71033a97dc 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -151,9 +151,10 @@ def _literal_eval(self, value, expected): def _get_nested_types(self, type_hint, expected_count=None): types = getattr(type_hint, '__args__', ()) - # With generics from typing like Dict, __args__ is None with Python 3.6 and - # contains TypeVars with 3.7-3.8. Newer versions don't have __args__ at all. - # Subscripted usages like Dict[x, y].__args__ work fine with all. + # `__args__` contains TypeVars when accessed directly from `typing.List` and + # other such types with Python 3.8. Python 3.9+ don't have `__args__` at all. + # Parameterize usages like `List[int].__args__` always work the same way. + # The TypeVar check can be removed when we don't support Python 3.8 anymore. if not types or all(isinstance(a, TypeVar) for a in types): return () if expected_count and len(types) != expected_count: @@ -456,8 +457,7 @@ def __init__(self, used_type, custom_converters=None, languages=None): @classmethod def handles(cls, type_): - # `type_ is not Tuple` is needed with Python 3.6. - return super().handles(type_) and type_ is not Tuple + return super().handles(type_) def no_conversion_needed(self, value): if isinstance(value, str) or not super().no_conversion_needed(value): diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index 3b2d58966ad..ca65e72723f 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -13,31 +13,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys from collections.abc import Sequence +from typing import TypedDict from ..model import TestCase -if sys.version_info >= (3, 8): - from typing import TypedDict +class OptionalItems(TypedDict, total=False): + args: 'Sequence[str]' + lineno: int - class OptionalItems(TypedDict, total=False): - args: 'Sequence[str]' - lineno: int +class FixtureDict(OptionalItems): + """Dictionary containing setup or teardown info. - - class FixtureDict(OptionalItems): - """Dictionary containing setup or teardown info. - - :attr:`args` and :attr:`lineno` are optional. - """ - name: str - -else: - class FixtureDict(dict): - pass + :attr:`args` and :attr:`lineno` are optional. + """ + name: str class TestDefaults: diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 28a865f84bd..c5764aa1f65 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -43,9 +43,6 @@ def is_loop_required(self, obj): return inspect.iscoroutine(obj) and not self._is_loop_running() def _is_loop_running(self): - # ensure 3.6 compatibility - if sys.version_info.minor == 6: - return asyncio._get_running_loop() is not None try: asyncio.get_running_loop() except RuntimeError: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index b801048f817..385ca51915f 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -34,18 +34,15 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface """ -import sys import warnings from pathlib import Path -from typing import Any, Mapping, Sequence, TYPE_CHECKING, Union -if sys.version_info >= (3, 8): - from typing import Literal +from typing import Any, Literal, Mapping, Sequence, TYPE_CHECKING, Union from robot import model from robot.conf import RobotSettings from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword from robot.model import (BodyItem, create_fixture, DataDict, Keywords, ModelObject, - TestCases, TestSuites) + TestSuites) from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, Error as ErrorResult, Return as ReturnResult) diff --git a/src/robot/utils/encodingsniffer.py b/src/robot/utils/encodingsniffer.py index 14e20faa9ab..ca51fdef292 100644 --- a/src/robot/utils/encodingsniffer.py +++ b/src/robot/utils/encodingsniffer.py @@ -75,7 +75,7 @@ def _get_unixy_encoding(): def _get_stream_output_encoding(): - # Python 3.6+ uses UTF-8 as encoding with output streams. + # Python uses UTF-8 as encoding with output streams. # We want the real console encoding regardless the platform. if WINDOWS: return None diff --git a/src/robot/utils/robottypes.py b/src/robot/utils/robottypes.py index e82c13ddbe4..a2cadfefd0a 100644 --- a/src/robot/utils/robottypes.py +++ b/src/robot/utils/robottypes.py @@ -17,30 +17,25 @@ from collections import UserString from io import IOBase from os import PathLike -from typing import Any, TypeVar +from typing import Union, TypedDict, TypeVar try: from types import UnionType except ImportError: # Python < 3.10 UnionType = () -from typing import Union -try: - from typing import TypedDict -except ImportError: # Python < 3.8 - typeddict_types = () -else: - typeddict_types = (type(TypedDict('Dummy', {})),) + try: from typing_extensions import TypedDict as ExtTypedDict except ImportError: - pass -else: - typeddict_types += (type(ExtTypedDict('Dummy', {})),) + ExtTypedDict = None from .platform import PY_VERSION TRUE_STRINGS = {'TRUE', 'YES', 'ON', '1'} FALSE_STRINGS = {'FALSE', 'NO', 'OFF', '0', 'NONE', ''} +typeddict_types = (type(TypedDict('Dummy', {})),) +if ExtTypedDict: + typeddict_types += (type(ExtTypedDict('Dummy', {})),) def is_integer(item): @@ -99,12 +94,6 @@ def type_name(item, capitalize=False): named_types = {str: 'string', bool: 'boolean', int: 'integer', type(None): 'None', dict: 'dictionary'} name = named_types.get(typ, typ.__name__.strip('_')) - # Generics from typing. With newer versions we get "real" type via __origin__. - if PY_VERSION < (3, 7): - if name in ('List', 'Set', 'Tuple'): - name = name.lower() - elif name == 'Dict': - name = 'dictionary' return name.capitalize() if capitalize and name.islower() else name @@ -118,8 +107,6 @@ def type_repr(typ, nested=True): return 'None' if typ is Ellipsis: return '...' - if typ is Any: # Needed with Python 3.6, with newer `Any._name` exists. - return 'Any' if is_union(typ): return ' | '.join(type_repr(a) for a in typ.__args__) if nested else 'Union' name = _get_type_name(typ) @@ -140,10 +127,9 @@ def _get_type_name(typ): def has_args(type): """Helper to check has type valid ``__args__``. - ``__args__`` contains TypeVars when accessed directly from ``typing.List`` and - other such types with Python 3.7-3.8. With Python 3.6 ``__args__`` is None - in that case and with Python 3.9+ it doesn't exist at all. When using like - ``List[int].__args__``, everything works the same way regardless the version. + ``__args__`` contains TypeVars when accessed directly from ``typing.List`` and + other such types with Python 3.8. Python 3.9+ don't have ``__args__`` at all. + Parameterize usages like ``List[int].__args__`` always work the same way. This helper can be removed in favor of using ``hasattr(type, '__args__')`` when we support only Python 3.9 and newer. diff --git a/src/robot/utils/setter.py b/src/robot/utils/setter.py index dd429263b1c..be7ccfb26ec 100644 --- a/src/robot/utils/setter.py +++ b/src/robot/utils/setter.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys from typing import Callable, Generic, overload, TypeVar, Type, Union @@ -93,7 +92,3 @@ def __new__(cls, name, bases, dct): slots.append(item.attr_name) dct['__slots__'] = slots return type.__new__(cls, name, bases, dct) - - if sys.version_info < (3, 7): - def __getitem__(self, item): - return self From a45bdf7e76ba1d3d3fa3793fd5ff12f6616eaeda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 28 Aug 2023 13:21:42 +0300 Subject: [PATCH 0656/1592] UG: Remove references to older Python versions. Part of #4294. --- doc/userguide/src/ExecutingTestCases/BasicUsage.rst | 4 ++-- .../src/ExtendingRobotFramework/CreatingTestLibraries.rst | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst index 6baf86bfd9b..40655648d40 100644 --- a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst +++ b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst @@ -459,10 +459,10 @@ the option :option:`--version`. This information also contains Python version and the platform type:: $ robot --version - Robot Framework 5.0 (Python 3.8.12 on darwin) + Robot Framework 7.0 (Python 3.12.1 on darwin) C:\>rebot --version - Rebot 5.0 (Python 3.7.0 on win32) + Rebot 6.1.1 (Python 3.11.0 on win32) .. _start-up script: .. _start-up scripts: diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index a945f89d02e..ed28faa5889 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -916,13 +916,12 @@ Example: Sort Words Foo bar baZ case_sensitive=True Strip Spaces ${word} left=False - __ https://www.python.org/dev/peps/pep-3102 Positional-only arguments ~~~~~~~~~~~~~~~~~~~~~~~~~ -Python 3.8 introduced `positional-only arguments`__ that make it possible to +Python supports so called `positional-only arguments`__ that make it possible to specify that an argument can only be given as a `positional argument`_, not as a `named argument`_ like `name=value`. Positional-only arguments are specified before normal arguments and a special `/` marker must be used after them: From 1082f16475d0dd163a979ea9ea1ef4b63d133f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 28 Aug 2023 15:10:14 +0300 Subject: [PATCH 0657/1592] Deprecate variables used as embedded args not matching custom patterns. Fixes #4524. --- atest/robot/keywords/embedded_arguments.robot | 10 ++++- .../embedded_arguments_library_keywords.robot | 10 ++++- .../keywords/embedded_arguments.robot | 2 +- .../embedded_arguments_library_keywords.robot | 2 +- src/robot/running/arguments/embedded.py | 37 ++++++++----------- 5 files changed, 34 insertions(+), 27 deletions(-) diff --git a/atest/robot/keywords/embedded_arguments.robot b/atest/robot/keywords/embedded_arguments.robot index fb0853e2870..0a1da5a547b 100644 --- a/atest/robot/keywords/embedded_arguments.robot +++ b/atest/robot/keywords/embedded_arguments.robot @@ -90,10 +90,16 @@ Custom Regexp Matching Variables Check Test Case ${TEST NAME} Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) - Check Test Case ${TEST NAME} + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.body[0].msgs[0]} + ... Embedded argument 'x' got value 'foo' that does not match custom pattern 'bar'. The argument is still accepted, but this behavior will change in Robot Framework 8.0. WARN Partially Matching Variable Is Accepted With Custom Regexp (But Not For Long) - Check Test Case ${TEST NAME} + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.body[0].msgs[0]} + ... Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. The argument is still accepted, but this behavior will change in Robot Framework 8.0. WARN + Check Log Message ${tc.body[0].msgs[1]} + ... Embedded argument 'y' got value 'zapzap' that does not match custom pattern '...'. The argument is still accepted, but this behavior will change in Robot Framework 8.0. WARN Non String Variable Is Accepted With Custom Regexp Check Test Case ${TEST NAME} diff --git a/atest/robot/keywords/embedded_arguments_library_keywords.robot b/atest/robot/keywords/embedded_arguments_library_keywords.robot index c705193c792..68f232beefe 100755 --- a/atest/robot/keywords/embedded_arguments_library_keywords.robot +++ b/atest/robot/keywords/embedded_arguments_library_keywords.robot @@ -78,10 +78,16 @@ Custom Regexp Matching Variables Check Test Case ${TEST NAME} Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) - Check Test Case ${TEST NAME} + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.body[0].msgs[0]} + ... Embedded argument 'x' got value 'foo' that does not match custom pattern 'bar'. The argument is still accepted, but this behavior will change in Robot Framework 8.0. WARN Partially Matching Variable Is Accepted With Custom Regexp (But Not For Long) - Check Test Case ${TEST NAME} + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.body[0].msgs[0]} + ... Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. The argument is still accepted, but this behavior will change in Robot Framework 8.0. WARN + Check Log Message ${tc.body[0].msgs[1]} + ... Embedded argument 'y' got value 'zapzap' that does not match custom pattern '...'. The argument is still accepted, but this behavior will change in Robot Framework 8.0. WARN Non String Variable Is Accepted With Custom Regexp Check Test Case ${TEST NAME} diff --git a/atest/testdata/keywords/embedded_arguments.robot b/atest/testdata/keywords/embedded_arguments.robot index eab0b236995..534b60a2bc3 100644 --- a/atest/testdata/keywords/embedded_arguments.robot +++ b/atest/testdata/keywords/embedded_arguments.robot @@ -106,7 +106,7 @@ Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) Partially Matching Variable Is Accepted With Custom Regexp (But Not For Long) [Documentation] FAIL ba != bar # ValueError: Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. - I execute "${bar[:2]}" with "${zap}" + I execute "${bar[:2]}" with "${zap * 2}" Non String Variable Is Accepted With Custom Regexp [Documentation] FAIL 42 != foo diff --git a/atest/testdata/keywords/embedded_arguments_library_keywords.robot b/atest/testdata/keywords/embedded_arguments_library_keywords.robot index af6531064cf..1f0b2566c34 100755 --- a/atest/testdata/keywords/embedded_arguments_library_keywords.robot +++ b/atest/testdata/keywords/embedded_arguments_library_keywords.robot @@ -91,7 +91,7 @@ Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) Partially Matching Variable Is Accepted With Custom Regexp (But Not For Long) [Documentation] FAIL ba != bar # ValueError: Embedded argument 'x' got value 'ba' that does not match custom pattern 'bar'. - I execute "${bar[:2]}" with "${zap}" + I execute "${bar[:2]}" with "${zap * 2}" Non String Variable Is Accepted With Custom Regexp [Documentation] FAIL 42 != foo diff --git a/src/robot/running/arguments/embedded.py b/src/robot/running/arguments/embedded.py index 1b501430b49..1301b93741c 100644 --- a/src/robot/running/arguments/embedded.py +++ b/src/robot/running/arguments/embedded.py @@ -19,8 +19,7 @@ from robot.utils import get_error_message, is_string from robot.variables import VariableIterator - -ENABLE_STRICT_ARGUMENT_VALIDATION = False +from ..context import EXECUTION_CONTEXTS class EmbeddedArguments: @@ -42,31 +41,27 @@ def map(self, values): return list(zip(self.args, values)) def validate(self, values): - # Validating that embedded args match custom regexps also if args are - # given as variables was initially implemented in RF 6.0. It needed - # to be reverted due to backwards incompatibility reasons but the plan - # is to enable it again in RF 7.0: - # https://github.com/robotframework/robotframework/issues/4069 - # - # TODO: Emit deprecation warnings if patterns don't match in RF 6.1: - # https://github.com/robotframework/robotframework/issues/4524 - # - # Because the plan is to add validation back, the code was not removed - # but the `ENABLE_STRICT_ARGUMENT_VALIDATION` guard was added instead. - # Enabling validation requires only removing the following two lines - # (along with this comment). If someone wants to enable strict validation - # already now, they set `ENABLE_STRICT_ARGUMENT_VALIDATION` to True - # before running tests. - if not ENABLE_STRICT_ARGUMENT_VALIDATION: - return + """Validate that embedded args match custom regexps. + + Initial validation is done already when matching keywords, but this + validation makes sure arguments match also if they are given as variables. + + Currently, argument not matching only causes a deprecation warning, but + that will be changed to an actual failure in RF 8.0: + https://github.com/robotframework/robotframework/issues/4069 + """ if not self.custom_patterns: return for arg, value in zip(self.args, values): if arg in self.custom_patterns and is_string(value): pattern = self.custom_patterns[arg] if not re.fullmatch(pattern, value): - raise ValueError(f"Embedded argument '{arg}' got value '{value}' " - f"that does not match custom pattern '{pattern}'.") + # TODO: Change to `raise ValueError(...)` in RF 8.0. + context = EXECUTION_CONTEXTS.current + context.warn(f"Embedded argument '{arg}' got value {value!r} " + f"that does not match custom pattern {pattern!r}. " + f"The argument is still accepted, but this behavior " + f"will change in Robot Framework 8.0.") def __bool__(self): return self.name is not None From 9db227d0aee02cf4008321b45816fd7ea6b17115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@iki.fi> Date: Sat, 26 Aug 2023 18:39:39 +0300 Subject: [PATCH 0658/1592] lexer: change Token.WITH_NAME value to AS This is in preparation to remove the WITH_NAME altogether in RF 8.0 Fixes #4375 --- src/robot/parsing/lexer/settings.py | 2 +- src/robot/parsing/lexer/tokens.py | 4 +-- src/robot/parsing/model/statements.py | 4 +-- utest/parsing/test_lexer.py | 36 +++++++++++++-------------- utest/parsing/test_statements.py | 4 +-- utest/parsing/test_tokens.py | 2 +- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 0810ad8a13c..63ef3d3a19e 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -132,7 +132,7 @@ def _lex_name_arguments_and_with_name(self, tokens: StatementTokens): self._lex_name_and_arguments(tokens) if len(tokens) > 1 and \ normalize_whitespace(tokens[-2].value) in ('WITH NAME', 'AS'): - tokens[-2].type = Token.WITH_NAME + tokens[-2].type = Token.AS tokens[-1].type = Token.NAME def _lex_arguments(self, tokens: StatementTokens): diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 7dd641922c0..0bebac9c7a3 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -80,7 +80,7 @@ class Token: RETURN_SETTING = RETURN # TODO: Change WITH_NAME value to AS in RF 7.0. Remove WITH_NAME in RF 8. - WITH_NAME = 'WITH NAME' + WITH_NAME = 'AS' AS = 'AS' NAME = 'NAME' @@ -174,7 +174,7 @@ def __init__(self, type: 'str|None' = None, value: 'str|None' = None, Token.TRY: 'TRY', Token.EXCEPT: 'EXCEPT', Token.FINALLY: 'FINALLY', Token.END: 'END', Token.CONTINUE: 'CONTINUE', Token.BREAK: 'BREAK', Token.RETURN_STATEMENT: 'RETURN', Token.CONTINUATION: '...', - Token.EOL: '\n', Token.WITH_NAME: 'WITH NAME', Token.AS: 'AS' + Token.EOL: '\n', Token.WITH_NAME: 'AS', Token.AS: 'AS' }.get(type, '') # type: ignore self.value = cast(str, value) self.lineno = lineno diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 19598e100ca..b3ce87e6d5a 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -344,7 +344,7 @@ def from_params(cls, name: str, args: 'Sequence[str]' = (), alias: 'str|None' = Token(Token.ARGUMENT, arg)]) if alias is not None: tokens.extend([Token(Token.SEPARATOR, separator), - Token(Token.WITH_NAME), + Token(Token.AS), Token(Token.SEPARATOR, separator), Token(Token.NAME, alias)]) tokens.append(Token(Token.EOL, eol)) @@ -360,7 +360,7 @@ def args(self) -> 'tuple[str, ...]': @property def alias(self) -> 'str|None': - separator = self.get_token(Token.WITH_NAME) + separator = self.get_token(Token.AS) return self.get_tokens(Token.NAME)[-1].value if separator else None diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index a45f8b7c44d..7d9ff2e1377 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -229,37 +229,37 @@ def test_imports(self): assert_tokens(data, expected, get_init_tokens, data_only=True) assert_tokens(data, expected, get_resource_tokens, data_only=True) - def test_with_name(self): + def test_aliasing_with_as(self): data = '''\ *** Settings *** -Library Easter WITH NAME Christmas -Library Arguments arg WITH NAME One argument +Library Easter AS Christmas +Library Arguments arg AS One argument Library Arguments arg1 arg2 -... arg3 arg4 WITH NAME Four arguments +... arg3 arg4 AS Four arguments ''' expected = [ (T.SETTING_HEADER, '*** Settings ***', 1, 0), (T.EOS, '', 1, 16), (T.LIBRARY, 'Library', 2, 0), (T.NAME, 'Easter', 2, 16), - (T.WITH_NAME, 'WITH NAME', 2, 45), - (T.NAME, 'Christmas', 2, 58), - (T.EOS, '', 2, 67), + (T.AS, 'AS', 2, 45), + (T.NAME, 'Christmas', 2, 51), + (T.EOS, '', 2, 60), (T.LIBRARY, 'Library', 3, 0), (T.NAME, 'Arguments', 3, 16), (T.ARGUMENT, 'arg', 3, 29), - (T.WITH_NAME, 'WITH NAME', 3, 45), - (T.NAME, 'One argument', 3, 58), - (T.EOS, '', 3, 70), + (T.AS, 'AS', 3, 45), + (T.NAME, 'One argument', 3, 51), + (T.EOS, '', 3, 63), (T.LIBRARY, 'Library', 4, 0), (T.NAME, 'Arguments', 4, 16), (T.ARGUMENT, 'arg1', 4, 29), (T.ARGUMENT, 'arg2', 4, 37), (T.ARGUMENT, 'arg3', 5, 29), (T.ARGUMENT, 'arg4', 5, 37), - (T.WITH_NAME, 'WITH NAME', 5, 45), - (T.NAME, 'Four arguments', 5, 58), - (T.EOS, '', 5, 72) + (T.AS, 'AS', 5, 45), + (T.NAME, 'Four arguments', 5, 51), + (T.EOS, '', 5, 65) ] assert_tokens(data, expected, get_tokens, data_only=True) assert_tokens(data, expected, get_init_tokens, data_only=True) @@ -1685,7 +1685,7 @@ class TestTokenizeVariables(unittest.TestCase): def test_settings(self): data = '''\ *** Settings *** -Library My${Name} my ${arg} ${x}[0] WITH NAME Your${Name} +Library My${Name} my ${arg} ${x}[0] AS Your${Name} ${invalid} ${usage} ''' expected = [(T.SETTING_HEADER, '*** Settings ***', 1, 0), @@ -1696,10 +1696,10 @@ def test_settings(self): (T.ARGUMENT, 'my ', 2, 27), (T.VARIABLE, '${arg}', 2, 30), (T.VARIABLE, '${x}[0]', 2, 40), - (T.WITH_NAME, 'WITH NAME', 2, 51), - (T.NAME, 'Your', 2, 64), - (T.VARIABLE, '${Name}', 2, 68), - (T.EOS, '', 2, 75), + (T.AS, 'AS', 2, 51), + (T.NAME, 'Your', 2, 57), + (T.VARIABLE, '${Name}', 2, 61), + (T.EOS, '', 2, 68), (T.ERROR, '${invalid}', 3, 0, "Non-existing setting '${invalid}'."), (T.EOS, '', 3, 10)] assert_tokens(data, expected, get_tokens=get_tokens, diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index ce2720783a0..f8c76f4f0ee 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -336,13 +336,13 @@ def test_LibraryImport(self): name='library_name.py' ) - # Library library_name.py WITH NAME anothername + # Library library_name.py AS anothername tokens = [ Token(Token.LIBRARY, 'Library'), Token(Token.SEPARATOR, ' '), Token(Token.NAME, 'library_name.py'), Token(Token.SEPARATOR, ' '), - Token(Token.WITH_NAME), + Token(Token.AS), Token(Token.SEPARATOR, ' '), Token(Token.NAME, 'anothername'), Token(Token.EOL, '\n') diff --git a/utest/parsing/test_tokens.py b/utest/parsing/test_tokens.py index 68cdf82b1d2..c7534c929c9 100644 --- a/utest/parsing/test_tokens.py +++ b/utest/parsing/test_tokens.py @@ -30,7 +30,7 @@ def test_automatic_value(self): (Token.END, 'END'), (Token.CONTINUATION, '...'), (Token.EOL, '\n'), - (Token.WITH_NAME, 'WITH NAME')]: + (Token.AS, 'AS')]: assert_equal(Token(typ).value, value) From 14b0a163f2c8c59e86b75997865ff2eef4f45181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@iki.fi> Date: Sat, 26 Aug 2023 22:32:45 +0300 Subject: [PATCH 0659/1592] lexer: use ASSIGN as variable token type in FOR/EXCEPT header Fixes #4708 --- src/robot/parsing/lexer/statementlexers.py | 4 ++-- src/robot/parsing/model/statements.py | 10 +++++----- utest/parsing/test_lexer.py | 16 ++++++++-------- utest/parsing/test_model.py | 16 ++++++++-------- utest/parsing/test_statements.py | 8 ++++---- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 12f4c8a8461..af6987d6b15 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -223,7 +223,7 @@ def lex(self): token.type = Token.FOR_SEPARATOR separator = normalize_whitespace(token.value) else: - token.type = Token.VARIABLE + token.type = Token.ASSIGN if separator == 'IN ENUMERATE': self._lex_options('start=') elif separator == 'IN ZIP': @@ -295,7 +295,7 @@ def lex(self): token.type = Token.AS as_index = index elif as_index: - token.type = Token.VARIABLE + token.type = Token.ASSIGN else: token.type = Token.ARGUMENT self._lex_options('type=', end_index=as_index) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index b3ce87e6d5a..0c0acf6d060 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -917,7 +917,7 @@ def from_params(cls, variables: 'Sequence[str]', values: 'Sequence[str]', Token(Token.FOR), Token(Token.SEPARATOR, separator)] for variable in variables: - tokens.extend([Token(Token.VARIABLE, variable), + tokens.extend([Token(Token.ASSIGN, variable), Token(Token.SEPARATOR, separator)]) tokens.append(Token(Token.FOR_SEPARATOR, flavor)) for value in values: @@ -928,7 +928,7 @@ def from_params(cls, variables: 'Sequence[str]', values: 'Sequence[str]', @property def variables(self) -> 'tuple[str, ...]': - return self.get_values(Token.VARIABLE) + return self.get_values(Token.ASSIGN) @property def values(self) -> 'tuple[str, ...]': @@ -1101,7 +1101,7 @@ def from_params(cls, patterns: 'Sequence[str]' = (), type: 'str|None' = None, tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.AS), Token(Token.SEPARATOR, separator), - Token(Token.VARIABLE, variable)]) + Token(Token.ASSIGN, variable)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -1115,13 +1115,13 @@ def pattern_type(self) -> 'str|None': @property def variable(self) -> 'str|None': - return self.get_value(Token.VARIABLE) + return self.get_value(Token.ASSIGN) def validate(self, ctx: 'ValidationContext'): self._validate_options() as_token = self.get_token(Token.AS) if as_token: - variables = self.get_tokens(Token.VARIABLE) + variables = self.get_tokens(Token.ASSIGN) if not variables: self.errors += ("EXCEPT's AS requires variable.",) elif len(variables) > 1: diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 7d9ff2e1377..2de5ca55ee6 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -939,7 +939,7 @@ def test_for_loop_header(self): header = 'FOR ${i} IN foo bar' expected = [ (T.FOR, 'FOR', 3, 4), - (T.VARIABLE, '${i}', 3, 11), + (T.ASSIGN, '${i}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, 'foo', 3, 25), (T.ARGUMENT, 'bar', 3, 32), @@ -1953,7 +1953,7 @@ def test_in_for(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.VARIABLE, '${x}', 3, 11), + (T.ASSIGN, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2004,7 +2004,7 @@ def test_in_if(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.VARIABLE, '${x}', 3, 11), + (T.ASSIGN, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2030,7 +2030,7 @@ def test_in_try(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.VARIABLE, '${x}', 3, 11), + (T.ASSIGN, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2055,7 +2055,7 @@ def test_in_for(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.VARIABLE, '${x}', 3, 11), + (T.ASSIGN, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2120,7 +2120,7 @@ def test_in_if(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.VARIABLE, '${x}', 3, 11), + (T.ASSIGN, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2142,7 +2142,7 @@ def test_in_for(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.VARIABLE, '${x}', 3, 11), + (T.ASSIGN, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2178,7 +2178,7 @@ def test_in_try(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.VARIABLE, '${x}', 3, 11), + (T.ASSIGN, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index a538732b4dc..56e5def9dc7 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -227,7 +227,7 @@ def test_valid(self): expected = For( header=ForHeader([ Token(Token.FOR, 'FOR', 3, 4), - Token(Token.VARIABLE, '${x}', 3, 11), + Token(Token.ASSIGN, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 19), Token(Token.ARGUMENT, 'a', 3, 25), Token(Token.ARGUMENT, 'b', 3, 30), @@ -254,7 +254,7 @@ def test_enumerate_with_start(self): expected = For( header=ForHeader([ Token(Token.FOR, 'FOR', 3, 4), - Token(Token.VARIABLE, '${x}', 3, 11), + Token(Token.ASSIGN, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN ENUMERATE', 3, 19), Token(Token.ARGUMENT, '@{stuff}', 3, 35), Token(Token.OPTION, 'start=1', 3, 47), @@ -282,7 +282,7 @@ def test_nested(self): expected = For( header=ForHeader([ Token(Token.FOR, 'FOR', 3, 4), - Token(Token.VARIABLE, '${x}', 3, 11), + Token(Token.ASSIGN, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 19), Token(Token.ARGUMENT, '1', 3, 25), Token(Token.ARGUMENT, 'start=has no special meaning here', 3, 30), @@ -291,7 +291,7 @@ def test_nested(self): For( header=ForHeader([ Token(Token.FOR, 'FOR', 4, 8), - Token(Token.VARIABLE, '${y}', 4, 15), + Token(Token.ASSIGN, '${y}', 4, 15), Token(Token.FOR_SEPARATOR, 'IN RANGE', 4, 23), Token(Token.ARGUMENT, '${x}', 4, 35), ]), @@ -339,7 +339,7 @@ def test_invalid(self): expected2 = For( header=ForHeader( tokens=[Token(Token.FOR, 'FOR', 3, 4), - Token(Token.VARIABLE, 'wrong', 3, 11), + Token(Token.ASSIGN, 'wrong', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 20)], errors=("FOR loop has invalid loop variable 'wrong'.", "FOR loop has no loop values."), @@ -792,7 +792,7 @@ def test_try_except_else_finally(self): next=Try( header=ExceptHeader((Token(Token.EXCEPT, 'EXCEPT', 7, 4), Token(Token.AS, 'AS', 7, 14), - Token(Token.VARIABLE, '${exp}', 7, 20))), + Token(Token.ASSIGN, '${exp}', 7, 20))), body=[KeywordCall((Token(Token.KEYWORD, 'Log', 8, 8), Token(Token.ARGUMENT, 'Catch', 8, 15)))], next=Try( @@ -845,7 +845,7 @@ def test_invalid(self): header=ExceptHeader( tokens=[Token(Token.EXCEPT, 'EXCEPT', 8, 4), Token(Token.AS, 'AS', 8, 14), - Token(Token.VARIABLE, 'invalid', 8, 20)], + Token(Token.ASSIGN, 'invalid', 8, 20)], errors=("EXCEPT's AS variable 'invalid' is invalid.",) ), errors=('EXCEPT branch cannot be empty.',) @@ -1121,7 +1121,7 @@ def test_continue(self): ''' expected = For( header=ForHeader([Token(Token.FOR, 'FOR', 3, 4), - Token(Token.VARIABLE, '${x}', 3, 11), + Token(Token.ASSIGN, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 19), Token(Token.ARGUMENT, '@{stuff}', 3, 25)]), body=[KeywordCall([Token(Token.KEYWORD, 'Continue', 4, 8), diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index f8c76f4f0ee..201e51ec1ec 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -678,9 +678,9 @@ def test_ForHeader(self): Token(Token.SEPARATOR, ' '), Token(Token.FOR), Token(Token.SEPARATOR, ' '), - Token(Token.VARIABLE, '${value1}'), + Token(Token.ASSIGN, '${value1}'), Token(Token.SEPARATOR, ' '), - Token(Token.VARIABLE, '${value2}'), + Token(Token.ASSIGN, '${value2}'), Token(Token.SEPARATOR, ' '), Token(Token.FOR_SEPARATOR, 'IN ZIP'), Token(Token.SEPARATOR, ' '), @@ -823,7 +823,7 @@ def test_ExceptHeader(self): Token(Token.SEPARATOR, ' '), Token(Token.AS, 'AS'), Token(Token.SEPARATOR, ' '), - Token(Token.VARIABLE, '${var}'), + Token(Token.ASSIGN, '${var}'), Token(Token.EOL, '\n') ] assert_created_statement( @@ -861,7 +861,7 @@ def test_ExceptHeader(self): Token(Token.SEPARATOR, ' '), Token(Token.AS, 'AS'), Token(Token.SEPARATOR, ' '), - Token(Token.VARIABLE, '${var}'), + Token(Token.ASSIGN, '${var}'), Token(Token.EOL, '\n')] assert_created_statement( tokens, From b9855899b60defb4d23589b887dfc8b4b883ae80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@iki.fi> Date: Sun, 27 Aug 2023 12:06:54 +0300 Subject: [PATCH 0660/1592] process: change the default value of stdin to None Fixes #4103 --- .../process/passing_arguments.robot | 4 +-- .../standard_libraries/process/stdin.robot | 9 +++--- .../process/passing_arguments.robot | 2 +- .../process/process_library.robot | 2 +- .../process/process_resource.robot | 6 ++-- .../standard_libraries/process/stdin.robot | 28 ++++++------------- src/robot/libraries/Process.py | 13 ++++----- 7 files changed, 25 insertions(+), 39 deletions(-) diff --git a/atest/robot/standard_libraries/process/passing_arguments.robot b/atest/robot/standard_libraries/process/passing_arguments.robot index ee5e0d7075f..02d9c00c644 100644 --- a/atest/robot/standard_libraries/process/passing_arguments.robot +++ b/atest/robot/standard_libraries/process/passing_arguments.robot @@ -41,7 +41,7 @@ Log process config ... shell:${SPACE*3}True ... stdout:${SPACE*2}%{TEMPDIR}${/}stdout ... stderr:${SPACE*2}PIPE - ... stdin:${SPACE*3}PIPE + ... stdin:${SPACE*3}None ... alias:${SPACE*3}äliäs ... env:${SPACE*5}None Check Log Message ${tc.kws[0].msgs[1]} Process configuration:\n${config} level=DEBUG @@ -51,7 +51,7 @@ Log process config ... shell:${SPACE*3}False ... stdout:${SPACE*2}PIPE ... stderr:${SPACE*2}STDOUT - ... stdin:${SPACE*3}None + ... stdin:${SPACE*3}PIPE ... alias:${SPACE*3}None ... env:${SPACE*5}None Check Log Message ${tc.kws[1].msgs[1]} Process configuration:\n${config} level=DEBUG diff --git a/atest/robot/standard_libraries/process/stdin.robot b/atest/robot/standard_libraries/process/stdin.robot index a89ccf75b6e..806e073d48c 100644 --- a/atest/robot/standard_libraries/process/stdin.robot +++ b/atest/robot/standard_libraries/process/stdin.robot @@ -3,15 +3,14 @@ Suite Setup Run Tests ${EMPTY} standard_libraries/process/stdin.robo Resource atest_resource.robot *** Test Cases *** -Stdin is PIPE by defauls +Stdin is NONE by default Check Test Case ${TESTNAME} -Stdin as PIPE explicitly +Stdin can be set to PIPE Check Test Case ${TESTNAME} -Stdin can be disabled - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 +Stdin can be disabled explicitly + Check Test Case ${TESTNAME} Stdin can be disabled with None object Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/process/passing_arguments.robot b/atest/testdata/standard_libraries/process/passing_arguments.robot index ed76cf7e6a0..cd3717d98c8 100644 --- a/atest/testdata/standard_libraries/process/passing_arguments.robot +++ b/atest/testdata/standard_libraries/process/passing_arguments.robot @@ -48,4 +48,4 @@ Unsupported kwargs cause error Log process config Run Process python -c pass shell=yes stdout=%{TEMPDIR}/stdout cwd=%{TEMPDIR} alias=äliäs - Run Process python -c pass stderr=STDOUT cwd=${CURDIR} stdin=None + Run Process python -c pass stderr=STDOUT cwd=${CURDIR} stdin=PIPE diff --git a/atest/testdata/standard_libraries/process/process_library.robot b/atest/testdata/standard_libraries/process/process_library.robot index a7fe9108728..7ac2357d999 100644 --- a/atest/testdata/standard_libraries/process/process_library.robot +++ b/atest/testdata/standard_libraries/process/process_library.robot @@ -34,7 +34,7 @@ Running a process in a shell Run Keyword And Expect Error * Run Process python -c "print('hello')" shell=false Input things to process - Start Process python -c "print('inp %s' % input())" shell=True + Start Process python -c "print('inp %s' % input())" shell=True stdin=PIPE ${process}= Get Process Object Log ${process.stdin.write(b"42\n")} Log ${process.stdin.flush()} diff --git a/atest/testdata/standard_libraries/process/process_resource.robot b/atest/testdata/standard_libraries/process/process_resource.robot index bdaea17db53..7f8a8116ecc 100644 --- a/atest/testdata/standard_libraries/process/process_resource.robot +++ b/atest/testdata/standard_libraries/process/process_resource.robot @@ -19,7 +19,7 @@ Some process [Arguments] ${alias}=${null} ${stderr}=STDOUT Remove File ${STARTED} ${handle}= Start Python Process open(r'${STARTED}', 'w').close(); print(input()) - ... alias=${alias} stderr=${stderr} + ... alias=${alias} stderr=${stderr} stdin=PIPE Wait Until Created ${STARTED} timeout=10s Process Should Be Running [Return] ${handle} @@ -65,9 +65,9 @@ Script result should equal Result should equal ${result} ${stdout} ${stderr} ${rc} Start Python Process - [Arguments] ${command} ${alias}=${NONE} ${stdout}=${NONE} ${stderr}=${NONE} ${shell}=False + [Arguments] ${command} ${alias}=${NONE} ${stdout}=${NONE} ${stderr}=${NONE} ${stdin}=None ${shell}=False ${handle}= Start Process python -c ${command} - ... alias=${alias} stdout=${stdout} stderr=${stderr} shell=${shell} + ... alias=${alias} stdout=${stdout} stderr=${stderr} stdin=${stdin} shell=${shell} [Return] ${handle} Run Python Process diff --git a/atest/testdata/standard_libraries/process/stdin.robot b/atest/testdata/standard_libraries/process/stdin.robot index bea3a048ac1..4ba32a73591 100644 --- a/atest/testdata/standard_libraries/process/stdin.robot +++ b/atest/testdata/standard_libraries/process/stdin.robot @@ -2,38 +2,28 @@ Resource process_resource.robot *** Test Cases *** -Stdin is PIPE by defauls - Start Process python -c import sys; print(sys.stdin.read()) - ${process} = Get Process Object - Call Method ${process.stdin} write ${{b'Hello, world!'}} - Call Method ${process.stdin} close +Stdin is NONE by default + ${process} = Start Process python -c import sys; print('Hello, world!') + Should Be Equal ${process.stdin} ${None} ${result} = Wait For Process Should Be Equal ${result.stdout} Hello, world! -Stdin as PIPE explicitly - Start Process python -c import sys; print(sys.stdin.read()) stdin=PIPE - ${process} = Get Process Object +Stdin can be set to PIPE + ${process} = Start Process python -c import sys; print(sys.stdin.read()) stdin=PIPE Call Method ${process.stdin} write ${{b'Hello, world!'}} Call Method ${process.stdin} close ${result} = Wait For Process Should Be Equal ${result.stdout} Hello, world! -Stdin can be disabled 1 - Start Process python -c import sys; print('Hello, world!') stdin=NONE - ${process} = Get Process Object - Should Be Equal ${process.stdin} ${None} +Stdin can be disabled explicitly + ${process} = Start Process python -c import sys; print('Hello, world!') stdin=None ${result} = Wait For Process - Should Be Equal ${result.stdout} Hello, world! - -Stdin can be disabled 2 - ${result} = Run Process python -c import sys; print('Hello, world!') stdin=None - ${process} = Get Process Object Should Be Equal ${process.stdin} ${None} Should Be Equal ${result.stdout} Hello, world! Stdin can be disabled with None object - ${result} = Run Process python -c import sys; print('Hello, world!') stdin=${None} - ${process} = Get Process Object + ${process} = Start Process python -c import sys; print('Hello, world!') stdin=${None} + ${result} = Wait For Process Should Be Equal ${process.stdin} ${None} Should Be Equal ${result.stdout} Hello, world! diff --git a/src/robot/libraries/Process.py b/src/robot/libraries/Process.py index 0518d567c90..512c66ec968 100644 --- a/src/robot/libraries/Process.py +++ b/src/robot/libraries/Process.py @@ -191,8 +191,8 @@ class Process: explained in the table below. | = Value = | = Explanation = | - | String ``PIPE`` | Make stdin a pipe that can be written to. This is the default. | - | String ``NONE`` | Inherit stdin from the parent process. This value is case-insensitive. | + | String ``NONE`` | Inherit stdin from the parent process. This is the default. This value is case-insensitive. | + | String ``PIPE`` | Make stdin a pipe that can be written to. | | Path to a file | Open the specified file and use it as the stdin. | | Any other string | Create a temporary file with the text as its content and use it as the stdin. | | Any non-string value | Used as-is. Could be a file descriptor, stdout of another process, etc. | @@ -200,12 +200,9 @@ class Process: Values ``PIPE`` and ``NONE`` are internally mapped directly to ``subprocess.PIPE`` and ``None``, respectively, when calling [https://docs.python.org/3/library/subprocess.html#subprocess.Popen|subprocess.Popen]. - The default behavior may change from ``PIPE`` to ``NONE`` in future - releases. If you depend on the ``PIPE`` behavior, it is a good idea to use - it explicitly. Examples: - | `Run Process` | command | stdin=NONE | + | `Run Process` | command | stdin=PIPE | | `Run Process` | command | stdin=${CURDIR}/stdin.txt | | `Run Process` | command | stdin=Stdin as text. | @@ -337,7 +334,7 @@ def run_process(self, command, *arguments, **configuration): configuration` for more details about configuration related to starting processes. Configuration related to waiting for processes consists of ``timeout`` and ``on_timeout`` arguments that have same semantics as - with `Wait For Process` keyword. By default there is no timeout, and + with `Wait For Process` keyword. By default, there is no timeout, and if timeout is defined the default action on timeout is ``terminate``. Returns a `result object` containing information about the execution. @@ -882,7 +879,7 @@ def __str__(self): class ProcessConfiguration: - def __init__(self, cwd=None, shell=False, stdout=None, stderr=None, stdin='PIPE', + def __init__(self, cwd=None, shell=False, stdout=None, stderr=None, stdin='NONE', output_encoding='CONSOLE', alias=None, env=None, **rest): self.cwd = os.path.normpath(cwd) if cwd else os.path.abspath('.') self.shell = is_truthy(shell) From f44e184db997090a3d559493cab94b90c2732041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@iki.fi> Date: Sun, 27 Aug 2023 13:34:50 +0300 Subject: [PATCH 0661/1592] schema: remove deprecated libdoc attributes Fixes #4667 --- doc/schema/libdoc.json | 8 +------- doc/schema/libdoc.xsd | 9 --------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/doc/schema/libdoc.json b/doc/schema/libdoc.json index 80f90b91b74..8ddeaf25596 100644 --- a/doc/schema/libdoc.json +++ b/doc/schema/libdoc.json @@ -64,12 +64,6 @@ "$ref": "#/definitions/Keyword" } }, - "dataTypes": { - "title": "Datatypes", - "description": "Deprecated. Use 'typedocs' instead.", - "default": {}, - "type": "object" - }, "typedocs": { "title": "Typedocs", "type": "array", @@ -454,4 +448,4 @@ ] } } -} \ No newline at end of file +} diff --git a/doc/schema/libdoc.xsd b/doc/schema/libdoc.xsd index 860b89e6f55..0621c730826 100644 --- a/doc/schema/libdoc.xsd +++ b/doc/schema/libdoc.xsd @@ -9,9 +9,6 @@ <xs:element name="tags" type="Tags" minOccurs="0" /> <xs:element name="inits" type="Inits" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="keywords" type="Keywords" minOccurs="0" maxOccurs="unbounded" /> - <!-- 'datatypes' was deprecated in RF 5.0. --> - <xs:element name="datatypes" type="DataTypes" minOccurs="0" maxOccurs="unbounded" /> - <!-- 'typedocs' should be used instead. --> <xs:element name="typedocs" type="TypeDocs" minOccurs="0" maxOccurs="unbounded" /> </xs:sequence> <xs:attribute name="name" type="xs:string" use="required" /> @@ -67,12 +64,6 @@ </xs:sequence> <xs:attribute name="repr" type="xs:string" use="required" /> </xs:complexType> - <xs:complexType name="DataTypes"> - <xs:sequence> - <xs:element name="enums" type="EnumList" minOccurs="0" /> - <xs:element name="typeddicts" type="TypedDictList" minOccurs="0" /> - </xs:sequence> - </xs:complexType> <xs:complexType name="EnumList"> <xs:sequence> <xs:element name="enum" type="EnumType" maxOccurs="unbounded" /> From cf896995f822f571c33dc5651d51365778b1cf40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@iki.fi> Date: Sun, 27 Aug 2023 21:14:48 +0300 Subject: [PATCH 0662/1592] parsing: rename ForceTags->TestTags Fixes #4385 --- src/robot/api/parsing.py | 2 +- src/robot/parsing/lexer/settings.py | 3 +-- src/robot/parsing/lexer/tokens.py | 4 ++-- src/robot/parsing/model/statements.py | 8 ++++---- src/robot/running/builder/transformers.py | 2 +- utest/parsing/test_lexer.py | 8 ++++---- utest/parsing/test_statements.py | 4 ++-- 7 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/robot/api/parsing.py b/src/robot/api/parsing.py index 1a43eda253f..ca0a8ec8883 100644 --- a/src/robot/api/parsing.py +++ b/src/robot/api/parsing.py @@ -519,7 +519,7 @@ def visit_File(self, node): TestTeardown, TestTemplate, TestTimeout, - ForceTags, + TestTags, DefaultTags, KeywordTags, Variable, diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 63ef3d3a19e..9d508c26879 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -112,8 +112,7 @@ def _lex_error(self, statement: StatementTokens, error: str): token.type = Token.COMMENT def _lex_setting(self, statement: StatementTokens, name: str): - # TODO: Change token type from 'FORCE TAGS' to 'TEST TAGS' in RF 7.0. - statement[0].type = {'Test Tags': Token.FORCE_TAGS, + statement[0].type = {'Test Tags': Token.TEST_TAGS, 'Name': Token.SUITE_NAME}.get(name, name.upper()) self.settings[name] = values = statement[1:] if name in self.name_and_arguments: diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 0bebac9c7a3..d61ef2c24db 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -62,7 +62,7 @@ class Token: TEST_TEARDOWN = 'TEST TEARDOWN' TEST_TEMPLATE = 'TEST TEMPLATE' TEST_TIMEOUT = 'TEST TIMEOUT' - FORCE_TAGS = 'FORCE TAGS' + TEST_TAGS = 'TEST TAGS' DEFAULT_TAGS = 'DEFAULT TAGS' KEYWORD_TAGS = 'KEYWORD TAGS' LIBRARY = 'LIBRARY' @@ -132,7 +132,7 @@ class Token: TEST_TEARDOWN, TEST_TEMPLATE, TEST_TIMEOUT, - FORCE_TAGS, + TEST_TAGS, DEFAULT_TAGS, KEYWORD_TAGS, LIBRARY, diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 0c0acf6d060..69b85690543 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -467,13 +467,13 @@ def name(self) -> str: @Statement.register -class ForceTags(MultiValue): - type = Token.FORCE_TAGS +class TestTags(MultiValue): + type = Token.TEST_TAGS @classmethod def from_params(cls, values: 'Sequence[str]', separator: str = FOUR_SPACES, - eol: str = EOL) -> 'ForceTags': - tokens = [Token(Token.FORCE_TAGS, 'Force Tags')] + eol: str = EOL) -> 'TestTags': + tokens = [Token(Token.TEST_TAGS, 'Force Tags')] for tag in values: tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.ARGUMENT, tag)]) diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 7be36af7ce8..9fd4473a780 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -61,7 +61,7 @@ def visit_TestTimeout(self, node): def visit_DefaultTags(self, node): self.settings.default_tags = node.values - def visit_ForceTags(self, node): + def visit_TestTags(self, node): self.settings.test_tags = node.values def visit_KeywordTags(self, node): diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 2de5ca55ee6..825e21cb332 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -82,7 +82,7 @@ def test_common_suite_settings(self): (T.TEST_TIMEOUT, 'Test Timeout', 10, 0), (T.ARGUMENT, '1 day', 10, 18), (T.EOS, '', 10, 23), - (T.FORCE_TAGS, 'Force Tags', 11, 0), + (T.TEST_TAGS, 'Force Tags', 11, 0), (T.ARGUMENT, 'foo', 11, 18), (T.ARGUMENT, 'bar', 11, 25), (T.EOS, '', 11, 28), @@ -109,7 +109,7 @@ def test_suite_settings_not_allowed_in_init_file(self): (T.TEST_TEMPLATE, 'Test Template', 2, 0), (T.NAME, 'Not allowed in init file', 2, 18), (T.EOS, '', 2, 42), - (T.FORCE_TAGS, 'Force Tags', 3, 0), + (T.TEST_TAGS, 'Force Tags', 3, 0), (T.ARGUMENT, 'Allowed in both', 3, 18), (T.EOS, '', 3, 33), (T.DEFAULT_TAGS, 'Default Tags', 4, 0), @@ -124,7 +124,7 @@ def test_suite_settings_not_allowed_in_init_file(self): (T.ERROR, 'Test Template', 2, 0, "Setting 'Test Template' is not allowed in suite initialization file."), (T.EOS, '', 2, 13), - (T.FORCE_TAGS, 'Force Tags', 3, 0), + (T.TEST_TAGS, 'Force Tags', 3, 0), (T.ARGUMENT, 'Allowed in both', 3, 18), (T.EOS, '', 3, 33), (T.ERROR, 'Default Tags', 4, 0, @@ -389,7 +389,7 @@ def test_setting_too_many_times(self): (T.ERROR, 'Test Timeout', 15, 0, "Setting 'Test Timeout' is allowed only once. Only the first value is used."), (T.EOS, '', 15, 12), - (T.FORCE_TAGS, 'Force Tags', 16, 0), + (T.TEST_TAGS, 'Force Tags', 16, 0), (T.ARGUMENT, 'Used', 16, 18), (T.EOS, '', 16, 22), (T.ERROR, 'Force Tags', 17, 0, diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 201e51ec1ec..debbe88758f 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -534,7 +534,7 @@ def test_Tags(self): def test_ForceTags(self): tokens = [ - Token(Token.FORCE_TAGS, 'Force Tags'), + Token(Token.TEST_TAGS, 'Force Tags'), Token(Token.SEPARATOR, ' '), Token(Token.ARGUMENT, 'some tag'), Token(Token.SEPARATOR, ' '), @@ -543,7 +543,7 @@ def test_ForceTags(self): ] assert_created_statement( tokens, - ForceTags, + TestTags, values=['some tag', 'another_tag'] ) From 5266af3f348822a08e257c89f075666a865c5a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 5 Sep 2023 18:48:50 +0300 Subject: [PATCH 0663/1592] Fine tune ForceTags -> TestTags change in parsing model. #4385 - Add `Token.FORCE_TAGS` back as an alias for new `Token.TEST_TAGS`. - Use `'Test Tags'` in `TestTags.from_params` instead of `'Force Tags'`. - Use `'Test Tags'` also in unit tests. --- src/robot/parsing/lexer/tokens.py | 4 ++-- src/robot/parsing/model/statements.py | 2 +- utest/parsing/test_lexer.py | 30 +++++++++++++-------------- utest/parsing/test_statements.py | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index d61ef2c24db..301a9a68de5 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -63,6 +63,7 @@ class Token: TEST_TEMPLATE = 'TEST TEMPLATE' TEST_TIMEOUT = 'TEST TIMEOUT' TEST_TAGS = 'TEST TAGS' + FORCE_TAGS = TEST_TAGS # TODO: Remove FORCE_TAGS in RF 8. DEFAULT_TAGS = 'DEFAULT TAGS' KEYWORD_TAGS = 'KEYWORD TAGS' LIBRARY = 'LIBRARY' @@ -79,9 +80,8 @@ class Token: RETURN = 'RETURN' RETURN_SETTING = RETURN - # TODO: Change WITH_NAME value to AS in RF 7.0. Remove WITH_NAME in RF 8. - WITH_NAME = 'AS' AS = 'AS' + WITH_NAME = AS # TODO: Remove WITH_NAME in RF 8. NAME = 'NAME' VARIABLE = 'VARIABLE' diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 69b85690543..4a2698d7b63 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -473,7 +473,7 @@ class TestTags(MultiValue): @classmethod def from_params(cls, values: 'Sequence[str]', separator: str = FOUR_SPACES, eol: str = EOL) -> 'TestTags': - tokens = [Token(Token.TEST_TAGS, 'Force Tags')] + tokens = [Token(Token.TEST_TAGS, 'Test Tags')] for tag in values: tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.ARGUMENT, tag)]) diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 825e21cb332..9fa965c71f8 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -41,7 +41,7 @@ def test_common_suite_settings(self): Test Setup None Shall Pass ${NONE} TEST TEARDOWN No Operation Test Timeout 1 day -Force Tags foo bar +Test Tags foo bar Keyword Tags tag Name Custom Suite Name ''' @@ -82,7 +82,7 @@ def test_common_suite_settings(self): (T.TEST_TIMEOUT, 'Test Timeout', 10, 0), (T.ARGUMENT, '1 day', 10, 18), (T.EOS, '', 10, 23), - (T.TEST_TAGS, 'Force Tags', 11, 0), + (T.TEST_TAGS, 'Test Tags', 11, 0), (T.ARGUMENT, 'foo', 11, 18), (T.ARGUMENT, 'bar', 11, 25), (T.EOS, '', 11, 28), @@ -100,7 +100,7 @@ def test_suite_settings_not_allowed_in_init_file(self): data = '''\ *** Settings *** Test Template Not allowed in init file -Force Tags Allowed in both +Test Tags Allowed in both Default Tags Not allowed in init file ''' expected = [ @@ -109,7 +109,7 @@ def test_suite_settings_not_allowed_in_init_file(self): (T.TEST_TEMPLATE, 'Test Template', 2, 0), (T.NAME, 'Not allowed in init file', 2, 18), (T.EOS, '', 2, 42), - (T.TEST_TAGS, 'Force Tags', 3, 0), + (T.TEST_TAGS, 'Test Tags', 3, 0), (T.ARGUMENT, 'Allowed in both', 3, 18), (T.EOS, '', 3, 33), (T.DEFAULT_TAGS, 'Default Tags', 4, 0), @@ -124,7 +124,7 @@ def test_suite_settings_not_allowed_in_init_file(self): (T.ERROR, 'Test Template', 2, 0, "Setting 'Test Template' is not allowed in suite initialization file."), (T.EOS, '', 2, 13), - (T.TEST_TAGS, 'Force Tags', 3, 0), + (T.TEST_TAGS, 'Test Tags', 3, 0), (T.ARGUMENT, 'Allowed in both', 3, 18), (T.EOS, '', 3, 33), (T.ERROR, 'Default Tags', 4, 0, @@ -143,7 +143,7 @@ def test_suite_settings_not_allowed_in_resource_file(self): TEST TEARDOWN No Operation Test Template NONE Test Timeout 1 day -Force Tags foo bar +Test Tags foo bar Default Tags zap Task Tags quux Documentation Valid in all data files. @@ -174,9 +174,9 @@ def test_suite_settings_not_allowed_in_resource_file(self): (T.ERROR, 'Test Timeout', 8, 0, "Setting 'Test Timeout' is not allowed in resource file."), (T.EOS, '', 8, 12), - (T.ERROR, 'Force Tags', 9, 0, - "Setting 'Force Tags' is not allowed in resource file."), - (T.EOS, '', 9, 10), + (T.ERROR, 'Test Tags', 9, 0, + "Setting 'Test Tags' is not allowed in resource file."), + (T.EOS, '', 9, 9), (T.ERROR, 'Default Tags', 10, 0, "Setting 'Default Tags' is not allowed in resource file."), (T.EOS, '', 10, 12), @@ -336,8 +336,8 @@ def test_setting_too_many_times(self): Test Template Ignored Test Timeout Used Test Timeout Ignored -Force Tags Used -Force Tags Ignored +Test Tags Used +Test Tags Ignored Default Tags Used Default Tags Ignored Name Used @@ -389,12 +389,12 @@ def test_setting_too_many_times(self): (T.ERROR, 'Test Timeout', 15, 0, "Setting 'Test Timeout' is allowed only once. Only the first value is used."), (T.EOS, '', 15, 12), - (T.TEST_TAGS, 'Force Tags', 16, 0), + (T.TEST_TAGS, 'Test Tags', 16, 0), (T.ARGUMENT, 'Used', 16, 18), (T.EOS, '', 16, 22), - (T.ERROR, 'Force Tags', 17, 0, - "Setting 'Force Tags' is allowed only once. Only the first value is used."), - (T.EOS, '', 17, 10), + (T.ERROR, 'Test Tags', 17, 0, + "Setting 'Test Tags' is allowed only once. Only the first value is used."), + (T.EOS, '', 17, 9), (T.DEFAULT_TAGS, 'Default Tags', 18, 0), (T.ARGUMENT, 'Used', 18, 18), (T.EOS, '', 18, 22), diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index debbe88758f..a10033f2a80 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -534,7 +534,7 @@ def test_Tags(self): def test_ForceTags(self): tokens = [ - Token(Token.TEST_TAGS, 'Force Tags'), + Token(Token.TEST_TAGS, 'Test Tags'), Token(Token.SEPARATOR, ' '), Token(Token.ARGUMENT, 'some tag'), Token(Token.SEPARATOR, ' '), From cd2d7aeb426934ba85ce9830ef91f784a80acee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 5 Sep 2023 21:22:21 +0300 Subject: [PATCH 0664/1592] Deprecate SHORTEST mode being default with FOR IN ZIP loops Fixes #4685. --- atest/robot/running/for/for_in_zip.robot | 16 +++++---- atest/testdata/running/for/for_in_zip.robot | 36 ++++++++++----------- src/robot/running/bodyrunner.py | 17 ++++++++-- 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/atest/robot/running/for/for_in_zip.robot b/atest/robot/running/for/for_in_zip.robot index fbb1dfa6a34..f4c09575f2a 100644 --- a/atest/robot/running/for/for_in_zip.robot +++ b/atest/robot/running/for/for_in_zip.robot @@ -10,12 +10,14 @@ Two variables and lists Should be FOR iteration ${loop.body[1]} \${x}=b \${y}=y Should be FOR iteration ${loop.body[2]} \${x}=c \${y}=z -Uneven lists +Uneven lists cause deprecation warning by default ${loop} = Check test and get loop ${TEST NAME} Should be IN ZIP loop ${loop} 3 - Should be FOR iteration ${loop.body[0]} \${x}=a \${y}=1 - Should be FOR iteration ${loop.body[1]} \${x}=b \${y}=2 - Should be FOR iteration ${loop.body[2]} \${x}=c \${y}=3 + Check Log Message ${loop.body[0]} + ... FOR IN ZIP default mode will be changed from SHORTEST to STRICT in Robot Framework 8.0. Use 'mode=SHORTEST' to keep using the SHORTEST mode. If the mode is not changed, execution will fail like this in the future: FOR IN ZIP items must have equal lengths in the STRICT mode, but lengths are 3 and 5. WARN + Should be FOR iteration ${loop.body[1]} \${x}=a \${y}=1 + Should be FOR iteration ${loop.body[2]} \${x}=b \${y}=2 + Should be FOR iteration ${loop.body[3]} \${x}=c \${y}=3 Three variables and lists ${loop} = Check test and get loop ${TEST NAME} @@ -56,7 +58,7 @@ Other iterables Check Test Case ${TEST NAME} List variable containing iterables - ${loop} = Check test and get loop ${TEST NAME} 2 + ${loop} = Check test and get loop ${TEST NAME} 1 Should be IN ZIP loop ${loop} 3 Should be FOR iteration ${loop.body[0]} \${x}=a \${y}=x \${z}=f Should be FOR iteration ${loop.body[1]} \${x}=b \${y}=y \${z}=o @@ -86,7 +88,7 @@ Shortest mode Shortest mode supports infinite iterators ${tc} = Check Test Case ${TEST NAME} - Should be IN ZIP loop ${tc.body[0]} 5 PASS mode=SHORTEST + Should be IN ZIP loop ${tc.body[0]} 3 PASS mode=SHORTEST Longest mode ${tc} = Check Test Case ${TEST NAME} @@ -96,7 +98,7 @@ Longest mode Longest mode with custom fill value ${tc} = Check Test Case ${TEST NAME} Should be IN ZIP loop ${tc.body[0]} 5 PASS mode=longest fill=? - Should be IN ZIP loop ${tc.body[3]} 5 PASS mode=longest fill=\${0} + Should be IN ZIP loop ${tc.body[3]} 3 PASS mode=longest fill=\${0} Invalid mode ${tc} = Check Test Case ${TEST NAME} diff --git a/atest/testdata/running/for/for_in_zip.robot b/atest/testdata/running/for/for_in_zip.robot index 784fcd2a30f..07b944d109c 100644 --- a/atest/testdata/running/for/for_in_zip.robot +++ b/atest/testdata/running/for/for_in_zip.robot @@ -2,7 +2,8 @@ @{result} @{LIST1} a b c @{LIST2} x y z -@{LIST3} ${1} ${2} ${3} ${4} ${5} +@{LIST3} ${1} ${2} ${3} +@{UNEVEN} 1 2 3 4 5 *** Test Cases *** Two variables and lists @@ -11,10 +12,8 @@ Two variables and lists END Should Be True ${result} == ['a:x', 'b:y', 'c:z'] -Uneven lists - [Documentation] Items in longer lists are ignored. - ... This behavior can be configured using `mode` option. - FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} +Uneven lists cause deprecation warning by default + FOR ${x} ${y} IN ZIP ${LIST1} ${UNEVEN} @{result} = Create List @{result} ${x}:${y} END Should Be True ${result} == ['a:1', 'b:2', 'c:3'] @@ -64,8 +63,7 @@ Other iterables Should Be True ${result} == ['f:0', 'o:1', 'o:2'] List variable containing iterables - ${tuple} = Evaluate tuple('foobar') - @{items} = Create List ${LIST1} ${LIST2} ${tuple} + @{items} = Create List ${LIST1} ${LIST2} ${{('f', 'o', 'o')}} FOR ${x} ${y} ${z} IN ZIP @{items} @{result}= Create List @{result} ${x}:${y}:${z} END @@ -81,17 +79,17 @@ List variable with iterables can be empty Log Executed! Strict mode - [Documentation] FAIL FOR IN ZIP items should have equal lengths in STRICT mode, but lengths are 3, 3 and 5. + [Documentation] FAIL FOR IN ZIP items must have equal lengths in the STRICT mode, but lengths are 3, 3 and 5. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=STRICT @{result} = Create List @{result} ${x}:${y} END Should Be True ${result} == ['a:x', 'b:y', 'c:z'] - FOR ${x} ${y} ${z} IN ZIP ${LIST1} ${LIST2} ${LIST 3} mode=strict + FOR ${x} ${y} ${z} IN ZIP ${LIST1} ${LIST2} ${UNEVEN} mode=strict Fail Not executed END Strict mode requires items to have length - [Documentation] FAIL FOR IN ZIP items should have length in STRICT mode, but item 2 does not. + [Documentation] FAIL FOR IN ZIP items must have length in the STRICT mode, but item 2 does not. FOR ${x} ${y} IN ZIP ${LIST3} ${{itertools.cycle(['A', 'B'])}} mode=STRICT Fail Not executed END @@ -102,7 +100,7 @@ Shortest mode END Should Be True ${result} == ['a:x', 'b:y', 'c:z'] @{result} = Create List - FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=${{'shortest'}} + FOR ${x} ${y} IN ZIP ${LIST1} ${UNEVEN} mode=${{'shortest'}} @{result} = Create List @{result} ${x}:${y} END Should Be True ${result} == ['a:1', 'b:2', 'c:3'] @@ -111,7 +109,7 @@ Shortest mode supports infinite iterators FOR ${x} ${y} IN ZIP ${LIST3} ${{itertools.cycle(['A', 'B'])}} mode=SHORTEST @{result} = Create List @{result} ${x}:${y} END - Should Be True ${result} == ['1:A', '2:B', '3:A', '4:B', '5:A'] + Should Be True ${result} == ['1:A', '2:B', '3:A'] Longest mode FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=LONGEST @@ -119,21 +117,21 @@ Longest mode END Should Be True ${result} == ['a:x', 'b:y', 'c:z'] @{result} = Create List - FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=LoNgEsT + FOR ${x} ${y} IN ZIP ${LIST1} ${UNEVEN} mode=LoNgEsT @{result} = Create List @{result} ${{($x, $y)}} END - Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), (None, 4), (None, 5)] + Should Be True ${result} == [('a', '1'), ('b', '2'), ('c', '3'), (None, '4'), (None, '5')] Longest mode with custom fill value - FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=longest fill=? + FOR ${x} ${y} IN ZIP ${LIST1} ${UNEVEN} mode=longest fill=? @{result} = Create List @{result} ${{($x, $y)}} END - Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), ('?', 4), ('?', 5)] + Should Be True ${result} == [('a', '1'), ('b', '2'), ('c', '3'), ('?', '4'), ('?', '5')] @{result} = Create List - FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} fill=${0} mode=longest - @{result} = Create List @{result} ${{($x, $y)}} + FOR ${x} ${y} ${z} IN ZIP ${{(1, 2)}} ${LIST1} ${{[1]}} fill=${0} mode=longest + @{result} = Create List @{result} ${{($x, $y, $z)}} END - Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), (0, 4), (0, 5)] + Should Be True ${result} == [(1, 'a', 1), (2, 'b', 0), (0, 'c', 0)] Invalid mode [Documentation] FAIL Invalid mode: Mode must be 'STRICT', 'SHORTEST' or 'LONGEST', got 'BAD'. diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 2d564d2f912..cbb9c3fd358 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -302,6 +302,8 @@ def _map_values_to_rounds(self, values, per_round): return zip_longest(*values, fillvalue=self._fill) if self._mode == 'STRICT': self._validate_strict_lengths(values) + if self._mode is None: + self._deprecate_different_lengths(values) return zip(*values) def _validate_types(self, values): @@ -316,12 +318,21 @@ def _validate_strict_lengths(self, values): try: lengths.append(len(item)) except TypeError: - raise DataError(f"FOR IN ZIP items should have length in STRICT mode, " - f"but item {index} does not.") + raise DataError(f"FOR IN ZIP items must have length in the STRICT " + f"mode, but item {index} does not.") if len(set(lengths)) > 1: - raise DataError(f"FOR IN ZIP items should have equal lengths in STRICT " + raise DataError(f"FOR IN ZIP items must have equal lengths in the STRICT " f"mode, but lengths are {seq2str(lengths, quote='')}.") + def _deprecate_different_lengths(self, values): + try: + self._validate_strict_lengths(values) + except DataError as err: + logger.warn(f"FOR IN ZIP default mode will be changed from SHORTEST to " + f"STRICT in Robot Framework 8.0. Use 'mode=SHORTEST' to keep " + f"using the SHORTEST mode. If the mode is not changed, " + f"execution will fail like this in the future: {err}") + class ForInEnumerateRunner(ForInRunner): flavor = 'IN ENUMERATE' From 460fb10a8dfbe397a1a38b4ef1e7a2a92bfbdcb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 6 Sep 2023 07:43:40 +0300 Subject: [PATCH 0665/1592] libdoc: remove datatypes generation `datatypes` has already been deprecated in favor of `typedocs` This commit removes the generation of `datatypes` and fixes/removes associated tests. The schema files have been updated earlier. Part of #4846 --- .../libdoc/backwards_compatibility.robot | 24 --- atest/robot/libdoc/datatypes_json-xml.robot | 81 --------- atest/robot/libdoc/datatypes_py-json.robot | 164 ------------------ atest/robot/libdoc/datatypes_py-xml.robot | 114 ------------ atest/robot/libdoc/datatypes_xml-json.robot | 137 --------------- atest/robot/libdoc/invalid_usage.robot | 2 +- atest/robot/libdoc/libdoc_resource.robot | 2 +- atest/testdata/libdoc/DataTypesLibrary.json | 84 +-------- atest/testdata/libdoc/DynamicLibrary.json | 6 +- doc/schema/libdoc.json | 4 +- doc/schema/libdoc.xsd | 4 +- doc/schema/libdoc_json_schema.py | 3 +- src/robot/libdocpkg/model.py | 12 +- src/robot/libdocpkg/xmlbuilder.py | 4 +- src/robot/libdocpkg/xmlwriter.py | 4 +- utest/libdoc/test_libdoc.py | 2 +- 16 files changed, 14 insertions(+), 633 deletions(-) delete mode 100644 atest/robot/libdoc/datatypes_json-xml.robot delete mode 100644 atest/robot/libdoc/datatypes_py-json.robot delete mode 100644 atest/robot/libdoc/datatypes_py-xml.robot delete mode 100644 atest/robot/libdoc/datatypes_xml-json.robot diff --git a/atest/robot/libdoc/backwards_compatibility.robot b/atest/robot/libdoc/backwards_compatibility.robot index ffa1086feb3..587664238ce 100644 --- a/atest/robot/libdoc/backwards_compatibility.robot +++ b/atest/robot/libdoc/backwards_compatibility.robot @@ -44,7 +44,6 @@ Validate Validate keyword 'Types' Validate keyword 'Special Types' Validate keyword 'Union' - Validate typedocs ${datatypes} Validate library [Arguments] ${source} @@ -86,26 +85,3 @@ Validate keyword 'Special Types' Validate keyword 'Union' Keyword Name Should Be 4 Union Keyword Arguments Should Be 4 a: int | float - -Validate typedocs - [Arguments] ${datatypes}=False - DataType Enum Should Be 0 Color RGB colors. - ... {"name": "RED", "value": "R"} - ... {"name": "GREEN", "value": "G"} - ... {"name": "BLUE", "value": "B"} - DataType TypedDict Should Be 0 Size Some size. - ... {"key": "width", "type": "int", "required": "true"} - ... {"key": "height", "type": "int", "required": "true"} - IF ${datatypes} - Usages Should Be 0 Enum Color - Usages Should Be 1 TypedDict Size - ELSE - DataType Standard Should Be 0 boolean Strings ``TRUE``, ``YES``, - DataType Standard Should Be 1 float Conversion is done using - DataType Standard Should Be 2 integer Conversion is done using - Usages Should Be 0 Standard boolean Types - Usages Should Be 1 Enum Color Special Types - Usages Should Be 2 Standard float Union - Usages Should Be 3 Standard integer Types Union - Usages Should Be 4 TypedDict Size Special Types - END diff --git a/atest/robot/libdoc/datatypes_json-xml.robot b/atest/robot/libdoc/datatypes_json-xml.robot deleted file mode 100644 index c83c3c9de68..00000000000 --- a/atest/robot/libdoc/datatypes_json-xml.robot +++ /dev/null @@ -1,81 +0,0 @@ -*** Settings *** -Resource libdoc_resource.robot -Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/DataTypesLibrary.json - -*** Test Cases *** -Enum - DataType Enum Should Be 0 - ... AssertionOperator - ... <p>This is some Doc</p>\n<p>This has was defined by assigning to __doc__.</p> - ... {"name": "equal","value": "=="} - ... {"name": "==","value": "=="} - ... {"name": "<","value": "<"} - ... {"name": ">","value": ">"} - ... {"name": "<=","value": "<="} - ... {"name": ">=","value": ">="} - DataType Enum Should Be 1 - ... Small - ... <p>This is the Documentation.</p>\n<p>This was defined within the class definition.</p> - ... {"name": "one","value": "1"} - ... {"name": "two","value": "2"} - ... {"name": "three","value": "3"} - ... {"name": "four","value": "4"} - -TypedDict - DataType TypedDict Should Be 0 - ... GeoLocation - ... <p>Defines the geolocation.</p>\n<ul>\n<li><code>latitude</code> Latitude between -90 and 90.</li>\n<li><code>longitude</code> Longitude between -180 and 180.</li>\n<li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0.</li>\n</ul>\n<p>Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></p> - ... {"key": "longitude", "type": "float", "required": "true"} - ... {"key": "latitude", "type": "float", "required": "true"} - ... {"key": "accuracy", "type": "float", "required": "false"} - -Custom - DataType Custom Should Be 0 - ... CustomType - ... <p>Converter method doc is used when defined.</p> - DataType Custom Should Be 1 - ... CustomType2 - ... <p>Class doc is used when converter method has no doc.</p> - -Accepted types - Accepted Types Should Be 0 Standard Any - ... Any - Accepted Types Should Be 2 Standard boolean - ... string integer float None - Accepted Types Should Be 3 Custom CustomType - ... string integer - Accepted Types Should Be 4 Custom CustomType2 - Accepted Types Should Be 7 TypedDict GeoLocation - ... string - Accepted Types Should Be 1 Enum AssertionOperator - ... string - Accepted Types Should Be 11 Enum Small - ... string integer - -Usages - Usages Should Be 2 Standard boolean - ... Funny Unions - Usages Should Be 3 Custom CustomType - ... Custom - Usages Should be 7 TypedDict GeoLocation - ... Funny Unions Set Location - Usages Should Be 11 Enum Small - ... __init__ Funny Unions - Usages Should Be 12 Standard string - ... Assert Something Funny Unions Typing Types - -Typedoc links in arguments - Typedoc links should be 0 1 Union: - ... AssertionOperator None - Typedoc links should be 0 2 str:string - Typedoc links should be 1 0 CustomType - Typedoc links should be 1 1 CustomType2 - Typedoc links should be 2 0 Union: - ... bool:boolean int:integer float str:string AssertionOperator Small GeoLocation None - Typedoc links should be 4 0 List:list - ... str:string - Typedoc links should be 4 1 Dict:dictionary - ... str:string int:integer - Typedoc links should be 4 2 Any - Typedoc links should be 4 3 List:list - ... Any diff --git a/atest/robot/libdoc/datatypes_py-json.robot b/atest/robot/libdoc/datatypes_py-json.robot deleted file mode 100644 index b28fbf9b75c..00000000000 --- a/atest/robot/libdoc/datatypes_py-json.robot +++ /dev/null @@ -1,164 +0,0 @@ -*** Settings *** -Documentation Tests are not run using Python 3.6 because `typing.get_type_hints` handles -... Unions incorrectly with it making test results too different compared to others. -Force Tags require-py3.7 -Suite Setup Run Libdoc And Parse Model From JSON ${TESTDATADIR}/DataTypesLibrary.py -Test Template Should Be Equal Multiline -Resource libdoc_resource.robot - -*** Test Cases *** -Documentation - ${MODEL}[doc] <p>This Library has Data Types.</p> - ... <p>It has some in <code>__init__</code> and others in the <a href="#Keywords" class="name">Keywords</a>.</p> - ... <p>The DataTypes are the following that should be linked. <span class="name">HttpCredentials</span> , <a href="#type-GeoLocation" class="name">GeoLocation</a> , <a href="#type-Small" class="name">Small</a> and <a href="#type-AssertionOperator" class="name">AssertionOperator</a>.</p> - -Init Arguments - [Template] Verify Argument Models - ${MODEL}[inits][0][args] credentials: Small = one - -Init docs - ${MODEL}[inits][0][doc] <p>This is the init Docs.</p> - ... <p>It links to <a href="#Set%20Location" class="name">Set Location</a> keyword and to <a href="#type-GeoLocation" class="name">GeoLocation</a> data type.</p> - -Keyword Arguments - [Template] Verify Argument Models - ${MODEL}[keywords][0][args] value operator: AssertionOperator | None = None exp: str = something? - ${MODEL}[keywords][1][args] arg: CustomType arg2: CustomType2 arg3: CustomType arg4: Unknown - ${MODEL}[keywords][2][args] funny: bool | int | float | str | AssertionOperator | Small | GeoLocation | None = equal - ${MODEL}[keywords][3][args] location: GeoLocation - ${MODEL}[keywords][4][args] list_of_str: List[str] dict_str_int: Dict[str, int] whatever: Any *args: List[Any] - -TypedDict - ${MODEL}[dataTypes][typedDicts][0][type] TypedDict - ${MODEL}[dataTypes][typedDicts][0][name] GeoLocation - ${MODEL}[dataTypes][typedDicts][0][doc] <p>Defines the geolocation.</p> - ... <ul> - ... <li><code>latitude</code> Latitude between -90 and 90.</li> - ... <li><code>longitude</code> Longitude between -180 and 180.</li> - ... <li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0.</li> - ... </ul> - ... <p>Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></p> - ${MODEL}[typedocs][7][type] TypedDict - ${MODEL}[typedocs][7][name] GeoLocation - ${MODEL}[typedocs][7][doc] <p>Defines the geolocation.</p> - ... <ul> - ... <li><code>latitude</code> Latitude between -90 and 90.</li> - ... <li><code>longitude</code> Longitude between -180 and 180.</li> - ... <li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0.</li> - ... </ul> - ... <p>Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></p> - -TypedDict Items - [Template] NONE - ${required} Set Variable ${Model}[dataTypes][typedDicts][0][items][0][required] - IF $required is None - ${longitude}= Create Dictionary key=longitude type=float required=${None} - ${latitude}= Create Dictionary key=latitude type=float required=${None} - ${accuracy}= Create Dictionary key=accuracy type=float required=${None} - ELSE - ${longitude}= Create Dictionary key=longitude type=float required=${True} - ${latitude}= Create Dictionary key=latitude type=float required=${True} - ${accuracy}= Create Dictionary key=accuracy type=float required=${False} - END - FOR ${exp} IN ${longitude} ${latitude} ${accuracy} - FOR ${item} IN @{Model}[dataTypes][typedDicts][0][items] - IF $exp['key'] == $item['key'] - Dictionaries Should Be Equal ${item} ${exp} - BREAK - END - END - END - -Enum - ${MODEL}[dataTypes][enums][0][type] Enum - ${MODEL}[dataTypes][enums][0][name] AssertionOperator - ${MODEL}[dataTypes][enums][0][doc] <p>This is some Doc</p> - ... <p>This has was defined by assigning to __doc__.</p> - ${MODEL}[typedocs][1][type] Enum - ${MODEL}[typedocs][1][name] AssertionOperator - ${MODEL}[typedocs][1][doc] <p>This is some Doc</p> - ... <p>This has was defined by assigning to __doc__.</p> - -Enum Members - [Template] NONE - ${exp_list} Evaluate [{"name": "equal","value": "=="},{"name": "==","value": "=="},{"name": "<","value": "<"},{"name": ">","value": ">"},{"name": "<=","value": "<="},{"name": ">=","value": ">="}] - FOR ${cur} ${exp} IN ZIP ${MODEL}[dataTypes][enums][0][members] ${exp_list} - Dictionaries Should Be Equal ${cur} ${exp} - END - FOR ${cur} ${exp} IN ZIP ${MODEL}[typedocs][1][members] ${exp_list} - Dictionaries Should Be Equal ${cur} ${exp} - END - -Custom types - ${MODEL}[typedocs][3][type] Custom - ${MODEL}[typedocs][3][name] CustomType - ${MODEL}[typedocs][3][doc] <p>Converter method doc is used when defined.</p> - ${MODEL}[typedocs][4][type] Custom - ${MODEL}[typedocs][4][name] CustomType2 - ${MODEL}[typedocs][4][doc] <p>Class doc is used when converter method has no doc.</p> - -Standard types - ${MODEL}[typedocs][0][type] Standard - ${MODEL}[typedocs][0][name] Any - ${MODEL}[typedocs][0][doc] <p>Any value is accepted. No conversion is done.</p> - ${MODEL}[typedocs][2][type] Standard - ${MODEL}[typedocs][2][name] boolean - ${MODEL}[typedocs][2][doc] <p>Strings <code>TRUE</code>, <code>YES</code>, start=True - -Standard types with generics - ${MODEL}[typedocs][5][type] Standard - ${MODEL}[typedocs][5][name] dictionary - ${MODEL}[typedocs][5][doc] <p>Strings must be Python <a start=True - ${MODEL}[typedocs][9][type] Standard - ${MODEL}[typedocs][9][name] list - ${MODEL}[typedocs][9][doc] <p>Strings must be Python <a start=True - -Accepted types - ${MODEL}[typedocs][0][type] Standard - ${MODEL}[typedocs][0][accepts] ['Any'] - ${MODEL}[typedocs][2][type] Standard - ${MODEL}[typedocs][2][accepts] ['string', 'integer', 'float', 'None'] - ${MODEL}[typedocs][3][type] Custom - ${MODEL}[typedocs][3][accepts] ['string', 'integer'] - ${MODEL}[typedocs][4][type] Custom - ${MODEL}[typedocs][4][accepts] [] - ${MODEL}[typedocs][7][type] TypedDict - ${MODEL}[typedocs][7][accepts] ['string', 'Mapping'] - ${MODEL}[typedocs][1][type] Enum - ${MODEL}[typedocs][1][accepts] ['string'] - ${MODEL}[typedocs][11][type] Enum - ${MODEL}[typedocs][11][accepts] ['string', 'integer'] - -Usages - ${MODEL}[typedocs][2][type] Standard - ${MODEL}[typedocs][2][usages] ['Funny Unions'] - ${MODEL}[typedocs][5][type] Standard - ${MODEL}[typedocs][5][usages] ['Typing Types'] - ${MODEL}[typedocs][3][type] Custom - ${MODEL}[typedocs][3][usages] ['Custom'] - ${MODEL}[typedocs][7][type] TypedDict - ${MODEL}[typedocs][7][usages] ['Funny Unions', 'Set Location'] - ${MODEL}[typedocs][11][type] Enum - ${MODEL}[typedocs][11][usages] ['__init__', 'Funny Unions'] - -Typedoc links in arguments - ${MODEL}[keywords][0][args][1][typedocs] {'AssertionOperator': 'AssertionOperator', 'None': 'None'} - ${MODEL}[keywords][0][args][2][typedocs] {'str': 'string'} - ${MODEL}[keywords][1][args][0][typedocs] {'CustomType': 'CustomType'} - ${MODEL}[keywords][1][args][1][typedocs] {'CustomType2': 'CustomType2'} - ${MODEL}[keywords][1][args][2][typedocs] {'CustomType': 'CustomType'} - ${MODEL}[keywords][1][args][3][typedocs] {} - ${MODEL}[keywords][2][args][0][typedocs] {'bool': 'boolean', 'int': 'integer', 'float': 'float', 'str': 'string', 'AssertionOperator': 'AssertionOperator', 'Small': 'Small', 'GeoLocation': 'GeoLocation', 'None': 'None'} - ${MODEL}[keywords][4][args][0][typedocs] {'List': 'list', 'str': 'string'} - ${MODEL}[keywords][4][args][1][typedocs] {'Dict': 'dictionary', 'str': 'string', 'int': 'integer'} - ${MODEL}[keywords][4][args][2][typedocs] {'Any': 'Any'} - ${MODEL}[keywords][4][args][3][typedocs] {'List': 'list', 'Any': 'Any'} - -*** Keywords *** -Verify Argument Models - [Arguments] ${arg_models} @{expected_reprs} - [Tags] robot:continue-on-failure - Should Be True len($arg_models) == len($expected_reprs) - FOR ${arg_model} ${expected_repr} IN ZIP ${arg_models} ${expected_reprs} - Verify Argument Model ${arg_model} ${expected_repr} json=True - END diff --git a/atest/robot/libdoc/datatypes_py-xml.robot b/atest/robot/libdoc/datatypes_py-xml.robot deleted file mode 100644 index 821200ca728..00000000000 --- a/atest/robot/libdoc/datatypes_py-xml.robot +++ /dev/null @@ -1,114 +0,0 @@ -*** Settings *** -Documentation Tests are not run using Python 3.6 because `typing.get_type_hints` handles -... Unions incorrectly with it making test results too different compared to others. -Force Tags require-py3.7 -Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/DataTypesLibrary.py -Resource libdoc_resource.robot - -*** Test Cases *** -Enum - DataType Enum Should Be 0 - ... AssertionOperator - ... This is some Doc\n\nThis has was defined by assigning to __doc__. - ... {"name": "equal","value": "=="} - ... {"name": "==","value": "=="} - ... {"name": "<","value": "<"} - ... {"name": ">","value": ">"} - ... {"name": "<=","value": "<="} - ... {"name": ">=","value": ">="} - DataType Enum Should Be 1 - ... Small - ... This is the Documentation.\n\nThis was defined within the class definition. - ... {"name": "one","value": "1"} - ... {"name": "two","value": "2"} - ... {"name": "three","value": "3"} - ... {"name": "four","value": "4"} - -TypedDict - ${required} Get Element Count ${LIBDOC} xpath=dataTypes/typedDicts/typedDict/items/item[@required] - IF $required == 0 - DataType TypedDict Should Be 0 - ... GeoLocation - ... Defines the geolocation.\n\n- ``latitude`` Latitude between -90 and 90.\n- ``longitude`` Longitude between -180 and 180.\n- ``accuracy`` *Optional* Non-negative accuracy value. Defaults to 0.\n\nExample usage: ``{'latitude': 59.95, 'longitude': 30.31667}`` - ... {"key": "longitude", "type": "float"} - ... {"key": "latitude", "type": "float"} - ... {"key": "accuracy", "type": "float"} - ELSE - DataType TypedDict Should Be 0 - ... GeoLocation - ... Defines the geolocation.\n\n- ``latitude`` Latitude between -90 and 90.\n- ``longitude`` Longitude between -180 and 180.\n- ``accuracy`` *Optional* Non-negative accuracy value. Defaults to 0.\n\nExample usage: ``{'latitude': 59.95, 'longitude': 30.31667}`` - ... {"key": "longitude", "type": "float", "required": "true"} - ... {"key": "latitude", "type": "float", "required": "true"} - ... {"key": "accuracy", "type": "float", "required": "false"} - END - -Custom - DataType Custom Should Be 0 - ... CustomType - ... Converter method doc is used when defined. - DataType Custom Should Be 1 - ... CustomType2 - ... Class doc is used when converter method has no doc. - -Standard - DataType Standard Should Be 0 - ... Any - ... Any value is accepted. No conversion is done. - DataType Standard Should Be 1 - ... boolean - ... Strings ``TRUE``, ``YES``, ``ON`` and ``1`` are converted to Boolean ``True``, - -Standard with generics - DataType Standard Should Be 2 - ... dictionary - ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#dict|dictionary] - DataType Standard Should Be 5 - ... list - ... Strings must be Python [[]https://docs.python.org/library/stdtypes.html#list|list] - -Accepted types - Accepted Types Should Be 0 Standard Any - ... Any - Accepted Types Should Be 2 Standard boolean - ... string integer float None - Accepted Types Should Be 3 Custom CustomType - ... string integer - Accepted Types Should Be 4 Custom CustomType2 - Accepted Types Should Be 7 TypedDict GeoLocation - ... string Mapping - Accepted Types Should Be 1 Enum AssertionOperator - ... string - Accepted Types Should Be 11 Enum Small - ... string integer - -Usages - Usages Should Be 0 Standard Any - ... Typing Types - Usages Should Be 2 Standard boolean - ... Funny Unions - Usages Should Be 5 Standard dictionary - ... Typing Types - Usages Should Be 3 Custom CustomType - ... Custom - Usages Should be 7 TypedDict GeoLocation - ... Funny Unions Set Location - Usages Should Be 11 Enum Small - ... __init__ Funny Unions - -Typedoc links in arguments - Typedoc links should be 0 1 Union: - ... AssertionOperator None - Typedoc links should be 0 2 str:string - Typedoc links should be 1 0 CustomType - Typedoc links should be 1 1 CustomType2 - Typedoc links should be 1 2 CustomType - Typedoc links should be 1 3 Unknown: - Typedoc links should be 2 0 Union: - ... bool:boolean int:integer float str:string AssertionOperator Small GeoLocation None - Typedoc links should be 4 0 List:list - ... str:string - Typedoc links should be 4 1 Dict:dictionary - ... str:string int:integer - Typedoc links should be 4 2 Any - Typedoc links should be 4 3 List:list - ... Any diff --git a/atest/robot/libdoc/datatypes_xml-json.robot b/atest/robot/libdoc/datatypes_xml-json.robot deleted file mode 100644 index 685d2a518d0..00000000000 --- a/atest/robot/libdoc/datatypes_xml-json.robot +++ /dev/null @@ -1,137 +0,0 @@ -*** Settings *** -Resource libdoc_resource.robot -Suite Setup Run Libdoc And Parse Model From JSON ${TESTDATADIR}/DataTypesLibrary.xml -Test Template Should Be Equal Multiline - -*** Test Cases *** -Documentation - ${MODEL}[doc] <p>This Library has Data Types.</p> - ... <p>It has some in <code>__init__</code> and others in the <a href=\"#Keywords\" class=\"name\">Keywords</a>.</p> - ... <p>The DataTypes are the following that should be linked. <span class=\"name\">HttpCredentials</span> , <a href=\"#type-GeoLocation\" class=\"name\">GeoLocation</a> , <a href=\"#type-Small\" class=\"name\">Small</a> and <a href=\"#type-AssertionOperator\" class=\"name\">AssertionOperator</a>.</p> - -Init Arguments - [Template] Verify Argument Models - ${MODEL}[inits][0][args] credentials: Small = one - -Init docs - ${MODEL}[inits][0][doc] <p>This is the init Docs.</p> - ... <p>It links to <a href=\"#Set%20Location\" class=\"name\">Set Location</a> keyword and to <a href=\"#type-GeoLocation\" class=\"name\">GeoLocation</a> data type.</p> - -Keyword Arguments - [Template] Verify Argument Models - ${MODEL}[keywords][0][args] value operator: AssertionOperator | None = None exp: str = something? - ${MODEL}[keywords][1][args] arg: CustomType arg2: CustomType2 arg3: CustomType - ${MODEL}[keywords][2][args] funny: bool | int | float | str | AssertionOperator | Small | GeoLocation | None = equal - ${MODEL}[keywords][3][args] location: GeoLocation - ${MODEL}[keywords][4][args] list_of_str: List[str] dict_str_int: Dict[str, int] whatever: Any *args: List[Any] - -TypedDict - ${MODEL}[dataTypes][typedDicts][0][name] GeoLocation - ${MODEL}[dataTypes][typedDicts][0][type] TypedDict - ${MODEL}[dataTypes][typedDicts][0][doc] <p>Defines the geolocation.</p> - ... <ul> - ... <li><code>latitude</code> Latitude between -90 and 90.</li> - ... <li><code>longitude</code> Longitude between -180 and 180.</li> - ... <li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0.</li> - ... </ul> - ... <p>Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></p> - ${MODEL}[typedocs][6][type] TypedDict - ${MODEL}[typedocs][6][name] GeoLocation - ${MODEL}[typedocs][6][doc] <p>Defines the geolocation.</p> - ... <ul> - ... <li><code>latitude</code> Latitude between -90 and 90.</li> - ... <li><code>longitude</code> Longitude between -180 and 180.</li> - ... <li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0.</li> - ... </ul> - ... <p>Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></p> - -TypedDict Items - [Template] NONE - ${longitude}= Create Dictionary key=longitude type=float required=${True} - ${latitude}= Create Dictionary key=latitude type=float required=${True} - ${accuracy}= Create Dictionary key=accuracy type=float required=${False} - FOR ${exp} IN ${longitude} ${latitude} ${accuracy} - FOR ${item} IN @{Model}[dataTypes][typedDicts][0][items] - IF $exp['key'] == $item['key'] - Dictionaries Should Be Equal ${item} ${exp} - BREAK - END - END - END - -Enum - ${MODEL}[dataTypes][enums][0][type] Enum - ${MODEL}[dataTypes][enums][0][name] AssertionOperator - ${MODEL}[dataTypes][enums][0][doc] <p>This is some Doc</p> - ... <p>This has was defined by assigning to __doc__.</p> - ${MODEL}[typedocs][0][type] Enum - ${MODEL}[typedocs][0][name] AssertionOperator - ${MODEL}[typedocs][0][doc] <p>This is some Doc</p> - ... <p>This has was defined by assigning to __doc__.</p> - -Enum Members - [Template] NONE - ${exp_list} Evaluate [{"name": "equal","value": "=="},{"name": "==","value": "=="},{"name": "<","value": "<"},{"name": ">","value": ">"},{"name": "<=","value": "<="},{"name": ">=","value": ">="}] - FOR ${cur} ${exp} IN ZIP ${MODEL}[dataTypes][enums][0][members] ${exp_list} - Dictionaries Should Be Equal ${cur} ${exp} - END - FOR ${cur} ${exp} IN ZIP ${MODEL}[typedocs][0][members] ${exp_list} - Dictionaries Should Be Equal ${cur} ${exp} - END - -Custom types - ${MODEL}[typedocs][2][type] Custom - ${MODEL}[typedocs][2][name] CustomType - ${MODEL}[typedocs][2][doc] <p>Converter method doc is used when defined.</p> - ${MODEL}[typedocs][3][type] Custom - ${MODEL}[typedocs][3][name] CustomType2 - ${MODEL}[typedocs][3][doc] <p>Class doc is used when converter method has no doc.</p> - -Standard types - ${MODEL}[typedocs][1][type] Standard - ${MODEL}[typedocs][1][name] boolean - ${MODEL}[typedocs][1][doc] <p>Strings <code>TRUE</code>, <code>YES</code>, start=True - -Accepted types - ${MODEL}[typedocs][1][type] Standard - ${MODEL}[typedocs][1][accepts] ['string', 'integer', 'float', 'None'] - ${MODEL}[typedocs][2][type] Custom - ${MODEL}[typedocs][2][accepts] ['string', 'integer'] - ${MODEL}[typedocs][3][type] Custom - ${MODEL}[typedocs][3][accepts] [] - ${MODEL}[typedocs][6][type] TypedDict - ${MODEL}[typedocs][6][accepts] ['string'] - ${MODEL}[typedocs][0][type] Enum - ${MODEL}[typedocs][0][accepts] ['string'] - ${MODEL}[typedocs][10][type] Enum - ${MODEL}[typedocs][10][accepts] ['string', 'integer'] - -Usages - ${MODEL}[typedocs][1][type] Standard - ${MODEL}[typedocs][1][usages] ['Funny Unions'] - ${MODEL}[typedocs][2][type] Custom - ${MODEL}[typedocs][2][usages] ['Custom'] - ${MODEL}[typedocs][6][type] TypedDict - ${MODEL}[typedocs][6][usages] ['Funny Unions', 'Set Location'] - ${MODEL}[typedocs][10][type] Enum - ${MODEL}[typedocs][10][usages] ['__init__', 'Funny Unions'] - -Typedoc links in arguments - ${MODEL}[keywords][0][args][1][typedocs] {'AssertionOperator': 'AssertionOperator', 'None': 'None'} - ${MODEL}[keywords][0][args][2][typedocs] {'str': 'string'} - ${MODEL}[keywords][1][args][0][typedocs] {'CustomType': 'CustomType'} - ${MODEL}[keywords][1][args][1][typedocs] {'CustomType2': 'CustomType2'} - ${MODEL}[keywords][2][args][0][typedocs] {'bool': 'boolean', 'int': 'integer', 'float': 'float', 'str': 'string', 'AssertionOperator': 'AssertionOperator', 'Small': 'Small', 'GeoLocation': 'GeoLocation', 'None': 'None'} - ${MODEL}[keywords][4][args][0][typedocs] {'List[str]': 'list'} - ${MODEL}[keywords][4][args][1][typedocs] {'Dict[str, int]': 'dictionary'} - ${MODEL}[keywords][4][args][2][typedocs] {} - ${MODEL}[keywords][4][args][3][typedocs] {'List[Any]': 'list'} - -*** Keywords *** -Verify Argument Models - [Arguments] ${arg_models} @{expected_reprs} - [Tags] robot:continue-on-failure - Should Be True len($arg_models) == len($expected_reprs) - FOR ${arg_model} ${expected_repr} IN ZIP ${arg_models} ${expected_reprs} - Verify Argument Model ${arg_model} ${expected_repr} json=True - END diff --git a/atest/robot/libdoc/invalid_usage.robot b/atest/robot/libdoc/invalid_usage.robot index 082e0625cc4..51b7daada69 100644 --- a/atest/robot/libdoc/invalid_usage.robot +++ b/atest/robot/libdoc/invalid_usage.robot @@ -81,7 +81,7 @@ Invalid output file ... Remove Directory ${OUT XML} Invalid Spec File version - ${TESTDATADIR}/OldSpec.xml ${OUT XML} Invalid spec file version 'None'. Supported versions are 3, 4 and 5. + ${TESTDATADIR}/OldSpec.xml ${OUT XML} Invalid spec file version 'None'. Supported versions are 3, 4, 5, and 6. *** Keywords *** Run libdoc and verify error diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index 1da69cc2571..1cca607d29d 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -102,7 +102,7 @@ Generated Should Be Element Attribute Should Be ${LIBDOC} generated ${generated} Spec version should be correct - Element Attribute Should Be ${LIBDOC} specversion 5 + Element Attribute Should Be ${LIBDOC} specversion 6 Should Have No Init ${inits} = Get Elements ${LIBDOC} xpath=inits/init diff --git a/atest/testdata/libdoc/DataTypesLibrary.json b/atest/testdata/libdoc/DataTypesLibrary.json index 095ec9d15f8..3346ac00a9a 100644 --- a/atest/testdata/libdoc/DataTypesLibrary.json +++ b/atest/testdata/libdoc/DataTypesLibrary.json @@ -1,5 +1,5 @@ { - "specversion": 2, + "specversion": 3, "name": "DataTypesLibrary", "doc": "<p>This Library has Data Types.</p>\n<p>It has some in <code>__init__</code> and others in the <a href=\"#Keywords\" class=\"name\">Keywords</a>.</p>\n<p>The DataTypes are the following that should be linked. <span class=\"name\">HttpCredentials</span> , <a href=\"#type-GeoLocation\" class=\"name\">GeoLocation</a> , <a href=\"#type-Small\" class=\"name\">Small</a> and <a href=\"#type-AssertionOperator\" class=\"name\">AssertionOperator</a>.</p>", "version": "", @@ -438,88 +438,6 @@ "lineno": 128 } ], - "dataTypes": { - "enums": [ - { - "type": "Enum", - "name": "AssertionOperator", - "doc": "<p>This is some Doc</p>\n<p>This has was defined by assigning to __doc__.</p>", - "members": [ - { - "name": "equal", - "value": "==" - }, - { - "name": "==", - "value": "==" - }, - { - "name": "<", - "value": "<" - }, - { - "name": ">", - "value": ">" - }, - { - "name": "<=", - "value": "<=" - }, - { - "name": ">=", - "value": ">=" - } - ] - }, - { - "type": "Enum", - "name": "Small", - "doc": "<p>This is the Documentation.</p>\n<p>This was defined within the class definition.</p>", - "members": [ - { - "name": "one", - "value": "1" - }, - { - "name": "two", - "value": "2" - }, - { - "name": "three", - "value": "3" - }, - { - "name": "four", - "value": "4" - } - ] - } - ], - "typedDicts": [ - { - "type": "TypedDict", - "name": "GeoLocation", - "doc": "<p>Defines the geolocation.</p>\n<ul>\n<li><code>latitude</code> Latitude between -90 and 90.</li>\n<li><code>longitude</code> Longitude between -180 and 180.</li>\n<li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0.</li>\n</ul>\n<p>Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></p>", - "items": [ - { - "key": "longitude", - "type": "float", - "required": true - }, - { - "key": "latitude", - "type": "float", - "required": true - }, - { - "key": "accuracy", - "type": "float", - "required": false - } - ] - } - ] - }, "typedocs": [ { "type": "Standard", diff --git a/atest/testdata/libdoc/DynamicLibrary.json b/atest/testdata/libdoc/DynamicLibrary.json index 8cd0047fdd0..86d8dc08127 100644 --- a/atest/testdata/libdoc/DynamicLibrary.json +++ b/atest/testdata/libdoc/DynamicLibrary.json @@ -1,5 +1,5 @@ { - "specversion": 2, + "specversion": 3, "name": "DynamicLibrary", "doc": "<p>Dummy documentation for <span class=\"name\">__intro__</span>.</p>", "version": "0.1", @@ -693,10 +693,6 @@ "lineno": -1 } ], - "dataTypes": { - "enums": [], - "typedDicts": [] - }, "typedocs": [ { "type": "Standard", diff --git a/doc/schema/libdoc.json b/doc/schema/libdoc.json index 8ddeaf25596..7e0e5c3d788 100644 --- a/doc/schema/libdoc.json +++ b/doc/schema/libdoc.json @@ -94,7 +94,7 @@ "title": "SpecVersion", "description": "Version of the spec.", "enum": [ - 2 + 3 ], "type": "integer" }, @@ -448,4 +448,4 @@ ] } } -} +} \ No newline at end of file diff --git a/doc/schema/libdoc.xsd b/doc/schema/libdoc.xsd index 0621c730826..6c2c8d88aa2 100644 --- a/doc/schema/libdoc.xsd +++ b/doc/schema/libdoc.xsd @@ -165,8 +165,8 @@ </xs:simpleType> <xs:simpleType name="SpecVersion"> <xs:restriction base="xs:integer"> - <xs:minInclusive value="5" /> - <xs:maxInclusive value="5" /> + <xs:minInclusive value="6" /> + <xs:maxInclusive value="6" /> </xs:restriction> </xs:simpleType> <xs:simpleType name="LibraryScope"> diff --git a/doc/schema/libdoc_json_schema.py b/doc/schema/libdoc_json_schema.py index 8bd4e38c20d..838f69cfe99 100755 --- a/doc/schema/libdoc_json_schema.py +++ b/doc/schema/libdoc_json_schema.py @@ -41,7 +41,7 @@ def schema_extra(schema, model): class SpecVersion(int, Enum): """Version of the spec.""" - VERSION = 2 + VERSION = 3 class DocumentationType(str, Enum): @@ -155,7 +155,6 @@ class Libdoc(BaseModel): tags: List[str] = Field(description='List of all tags used by keywords.') inits: List[Keyword] keywords: List[Keyword] - dataTypes: dict = Field({}, description="Deprecated. Use 'typedocs' instead.") typedocs: List[TypeDoc] # pydantic doesn't add schema version automatically. diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index c3bde49f3e4..4f43540ac55 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -113,7 +113,7 @@ def convert_docs_to_html(self): def to_dictionary(self, include_private=False, theme=None): data = { - 'specversion': 2, + 'specversion': 3, 'name': self.name, 'doc': self.doc, 'version': self.version, @@ -127,22 +127,12 @@ def to_dictionary(self, include_private=False, theme=None): 'inits': [init.to_dictionary() for init in self.inits], 'keywords': [kw.to_dictionary() for kw in self.keywords if include_private or not kw.private], - # 'dataTypes' was deprecated in RF 5, 'typedoc' should be used instead. - 'dataTypes': self._get_data_types(self.type_docs), 'typedocs': [t.to_dictionary() for t in sorted(self.type_docs)] } if theme: data['theme'] = theme.lower() return data - def _get_data_types(self, types): - enums = sorted(t for t in types if t.type == 'Enum') - typed_dicts = sorted(t for t in types if t.type == 'TypedDict') - return { - 'enums': [t.to_dictionary(legacy=True) for t in enums], - 'typedDicts': [t.to_dictionary(legacy=True) for t in typed_dicts] - } - def to_json(self, indent=None, include_private=True, theme=None): data = self.to_dictionary(include_private, theme) return json.dumps(data, indent=indent) diff --git a/src/robot/libdocpkg/xmlbuilder.py b/src/robot/libdocpkg/xmlbuilder.py index fd622e8819d..26086810bfc 100644 --- a/src/robot/libdocpkg/xmlbuilder.py +++ b/src/robot/libdocpkg/xmlbuilder.py @@ -52,9 +52,9 @@ def _parse_spec(self, path): if root.tag != 'keywordspec': raise DataError(f"Invalid spec file '{path}'.") version = root.get('specversion') - if version not in ('3', '4', '5'): + if version not in ('3', '4', '5', '6'): raise DataError(f"Invalid spec file version '{version}'. " - f"Supported versions are 3, 4 and 5.") + f"Supported versions are 3, 4, 5, and 6.") return root def _create_keywords(self, spec, path, lib_source): diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index fff9a134b28..c8a18105db9 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -26,8 +26,6 @@ def write(self, libdoc, outfile): self._write_start(libdoc, writer) self._write_keywords('inits', 'init', libdoc.inits, libdoc.source, writer) self._write_keywords('keywords', 'kw', libdoc.keywords, libdoc.source, writer) - # Write deprecated '<datatypes>' element. - self._write_data_types(libdoc.type_docs, writer) # Write new '<types>' element. self._write_type_docs(libdoc.type_docs, writer) self._write_end(writer) @@ -38,7 +36,7 @@ def _write_start(self, libdoc, writer): 'format': libdoc.doc_format, 'scope': libdoc.scope, 'generated': get_generation_time(), - 'specversion': '5'} + 'specversion': '6'} self._add_source_info(attrs, libdoc) writer.start('keywordspec', attrs) writer.element('version', libdoc.version) diff --git a/utest/libdoc/test_libdoc.py b/utest/libdoc/test_libdoc.py index cee45b5e16d..6551884d4bd 100644 --- a/utest/libdoc/test_libdoc.py +++ b/utest/libdoc/test_libdoc.py @@ -269,7 +269,7 @@ class TestLibdocTypedDictKeys(unittest.TestCase): def test_typed_dict_keys(self): library = DATADIR / 'DataTypesLibrary.py' spec = LibraryDocumentation(library).to_json() - current_items = json.loads(spec)['dataTypes']['typedDicts'][0]['items'] + current_items = json.loads(spec)['typedocs'][7]['items'] expected_items = [ { "key": "longitude", From 2c7e0f7bcc72ecc1cfbc8c56e4df69fd10909686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Wed, 6 Sep 2023 07:56:23 +0300 Subject: [PATCH 0666/1592] libdoc: remove one more deprecated function Relates to #4667 --- src/robot/libdocpkg/xmlwriter.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index c8a18105db9..864fe4f966c 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -106,29 +106,6 @@ def _get_start_attrs(self, kw, lib_source): self._add_source_info(attrs, kw, lib_source) return attrs - # Write legacy 'datatypes'. TODO: Remove in RF 7. - def _write_data_types(self, types, writer): - enums = sorted(t for t in types if t.type == 'Enum') - typed_dicts = sorted(t for t in types if t.type == 'TypedDict') - writer.start('datatypes') - if enums: - writer.start('enums') - for enum in enums: - writer.start('enum', {'name': enum.name}) - writer.element('doc', enum.doc) - self._write_enum_members(enum, writer) - writer.end('enum') - writer.end('enums') - if typed_dicts: - writer.start('typeddicts') - for typ_dict in typed_dicts: - writer.start('typeddict', {'name': typ_dict.name}) - writer.element('doc', typ_dict.doc) - self._write_typed_dict_items(typ_dict, writer) - writer.end('typeddict') - writer.end('typeddicts') - writer.end('datatypes') - def _write_type_docs(self, type_docs, writer): writer.start('typedocs') for doc in sorted(type_docs): From c46d12bc4a2a118798d5dee657e8ec4767e47ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 5 Sep 2023 21:38:03 +0300 Subject: [PATCH 0667/1592] Process: Documentation enhancements. Includes mentioning that `stdin` default value used to be `PIPE` until RF 7 (#4103). --- src/robot/libraries/Process.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/robot/libraries/Process.py b/src/robot/libraries/Process.py index 512c66ec968..93ddb3d19e3 100644 --- a/src/robot/libraries/Process.py +++ b/src/robot/libraries/Process.py @@ -75,7 +75,7 @@ class Process: optional ``**configuration`` keyword arguments. Configuration arguments must be given after other arguments passed to these keywords and must use syntax like ``name=value``. Available configuration arguments are - listed below and discussed further in sections afterwards. + listed below and discussed further in sections afterward. | = Name = | = Explanation = | | shell | Specifies whether to run the command in shell or not. | @@ -96,7 +96,7 @@ class Process: == Running processes in shell == The ``shell`` argument specifies whether to run the process in a shell or - not. By default shell is not used, which means that shell specific commands, + not. By default, shell is not used, which means that shell specific commands, like ``copy`` and ``dir`` on Windows, are not available. You can, however, run shell scripts and batch files without using a shell. @@ -129,8 +129,8 @@ class Process: == Environment variables == - By default the child process will get a copy of the parent process's - environment variables. The ``env`` argument can be used to give the + The child process will get a copy of the parent process's environment + variables by default. The ``env`` argument can be used to give the child a custom environment as a Python dictionary. If there is a need to specify only certain environment variable, it is possible to use the ``env:<name>=<value>`` format to set or override only that named variables. @@ -143,12 +143,12 @@ class Process: == Standard output and error streams == - By default processes are run so that their standard output and standard + By default, processes are run so that their standard output and standard error streams are kept in the memory. This works fine normally, but if there is a lot of output, the output buffers may get full and the program can hang. - To avoid the above mentioned problems, it is possible to use ``stdout`` + To avoid the above-mentioned problems, it is possible to use ``stdout`` and ``stderr`` arguments to specify files on the file system where to redirect the outputs. This can also be useful if other processes or other keywords need to read or manipulate the outputs somehow. @@ -172,8 +172,6 @@ class Process: This way the process will not hang even if there would be a lot of output, but naturally output is not available after execution either. - Support for the special value ``DEVNULL`` is new in Robot Framework 3.2. - Examples: | ${result} = | `Run Process` | program | stdout=${TEMPDIR}/stdout.txt | stderr=${TEMPDIR}/stderr.txt | | `Log Many` | stdout: ${result.stdout} | stderr: ${result.stderr} | @@ -191,13 +189,13 @@ class Process: explained in the table below. | = Value = | = Explanation = | - | String ``NONE`` | Inherit stdin from the parent process. This is the default. This value is case-insensitive. | + | String ``NONE`` | Inherit stdin from the parent process. This is the default. | | String ``PIPE`` | Make stdin a pipe that can be written to. | | Path to a file | Open the specified file and use it as the stdin. | | Any other string | Create a temporary file with the text as its content and use it as the stdin. | | Any non-string value | Used as-is. Could be a file descriptor, stdout of another process, etc. | - Values ``PIPE`` and ``NONE`` are internally mapped directly to + Values ``PIPE`` and ``NONE`` are case-insensitive and internally mapped to ``subprocess.PIPE`` and ``None``, respectively, when calling [https://docs.python.org/3/library/subprocess.html#subprocess.Popen|subprocess.Popen]. @@ -206,7 +204,8 @@ class Process: | `Run Process` | command | stdin=${CURDIR}/stdin.txt | | `Run Process` | command | stdin=Stdin as text. | - The support to configure ``stdin`` is new in Robot Framework 4.1.2. + The support to configure ``stdin`` is new in Robot Framework 4.1.2. Its default + value used to be ``PIPE`` until Robot Framework 7.0. == Output encoding == @@ -295,8 +294,6 @@ class Process: | `Terminate Process` | kill=${EMPTY} | # Empty string is false. | | `Terminate Process` | kill=${FALSE} | # Python ``False`` is false. | - Considering ``OFF`` and ``0`` false is new in Robot Framework 3.1. - = Example = | ***** Settings ***** @@ -489,9 +486,6 @@ def wait_for_process(self, handle=None, timeout=None, on_timeout='continue'): | ${result} = | Wait For Process | timeout=1min 30s | on_timeout=kill | | Process Should Be Stopped | | | | Should Be Equal As Integers | ${result.rc} | -9 | - - Ignoring timeout if it is string ``NONE``, zero, or negative is new - in Robot Framework 3.2. """ process = self._processes[handle] logger.info('Waiting for process to complete.') @@ -879,7 +873,7 @@ def __str__(self): class ProcessConfiguration: - def __init__(self, cwd=None, shell=False, stdout=None, stderr=None, stdin='NONE', + def __init__(self, cwd=None, shell=False, stdout=None, stderr=None, stdin=None, output_encoding='CONSOLE', alias=None, env=None, **rest): self.cwd = os.path.normpath(cwd) if cwd else os.path.abspath('.') self.shell = is_truthy(shell) From 2dfc609206a9bd5f57ab85e1507c9589bf893505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 6 Sep 2023 12:10:49 +0300 Subject: [PATCH 0668/1592] Rename `variable` propertys to `assign` in parsing model. The underlying token types have been changed from `VARIABLE` to `ASSIGN` already earlier as part of #4708. Propertys accessing token values are now renamed for consistency as well. We also use `assign` property with keyword calls and inline IF. Old propertys are preserved for backwards compatibility reasons, but they are deprecated. Also arguments to related `from_params` methods were updated making #4708 a bit more backwards incompatible. --- src/robot/parsing/model/blocks.py | 21 ++++++++++++--- src/robot/parsing/model/statements.py | 31 ++++++++++++++++------- src/robot/running/builder/transformers.py | 4 +-- utest/parsing/test_statements.py | 6 ++--- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 0ae1d41d0f5..38eb48efe29 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import warnings from abc import ABC from contextlib import contextmanager from pathlib import Path @@ -255,8 +256,14 @@ class For(NestedBlock): header: ForHeader @property - def variables(self) -> 'tuple[str, ...]': - return self.header.variables + def assign(self) -> 'tuple[str, ...]': + return self.header.assign + + @property + def variables(self) -> 'tuple[str, ...]': # TODO: Remove in RF 8.0. + warnings.warn("'For.variables' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'For.assign' instead.") + return self.assign @property def values(self) -> 'tuple[str, ...]': @@ -307,8 +314,14 @@ def pattern_type(self) -> 'str|None': return getattr(self.header, 'pattern_type', None) @property - def variable(self) -> 'str|None': - return getattr(self.header, 'variable', None) + def assign(self) -> 'str|None': + return getattr(self.header, 'assign', None) + + @property + def variable(self) -> 'str|None': # TODO: Remove in RF 8.0. + warnings.warn("'Try.variable' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'Try.assign' instead.") + return self.assign def validate(self, ctx: 'ValidationContext'): self._validate_body() diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 4a2698d7b63..05472ebc14a 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -15,6 +15,7 @@ import ast import re +import warnings from abc import ABC, abstractmethod from collections.abc import Iterator, Sequence from typing import cast, ClassVar, overload, TYPE_CHECKING, Type, TypeVar @@ -910,13 +911,13 @@ class ForHeader(Statement): type = Token.FOR @classmethod - def from_params(cls, variables: 'Sequence[str]', values: 'Sequence[str]', + def from_params(cls, assign: 'Sequence[str]', values: 'Sequence[str]', flavor: str = 'IN', indent: str = FOUR_SPACES, separator: str = FOUR_SPACES, eol: str = EOL) -> 'ForHeader': tokens = [Token(Token.SEPARATOR, indent), Token(Token.FOR), Token(Token.SEPARATOR, separator)] - for variable in variables: + for variable in assign: tokens.extend([Token(Token.ASSIGN, variable), Token(Token.SEPARATOR, separator)]) tokens.append(Token(Token.FOR_SEPARATOR, flavor)) @@ -927,9 +928,15 @@ def from_params(cls, variables: 'Sequence[str]', values: 'Sequence[str]', return cls(tokens) @property - def variables(self) -> 'tuple[str, ...]': + def assign(self) -> 'tuple[str, ...]': return self.get_values(Token.ASSIGN) + @property + def variables(self) -> 'tuple[str, ...]': # TODO: Remove in RF 8.0. + warnings.warn("'ForHeader.variables' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'ForHeader.assign' instead.") + return self.assign + @property def values(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) @@ -953,12 +960,12 @@ def fill(self) -> 'str|None': def validate(self, ctx: 'ValidationContext'): self._validate_options() - if not self.variables: + if not self.assign: self._add_error('no loop variables') if not self.flavor: self._add_error("no 'IN' or other valid separator") else: - for var in self.variables: + for var in self.assign: if not is_scalar_assign(var): self._add_error(f"invalid loop variable '{var}'") if not self.values: @@ -1087,7 +1094,7 @@ class ExceptHeader(Statement): @classmethod def from_params(cls, patterns: 'Sequence[str]' = (), type: 'str|None' = None, - variable: 'str|None' = None, indent: str = FOUR_SPACES, + assign: 'str|None' = None, indent: str = FOUR_SPACES, separator: str = FOUR_SPACES, eol: str = EOL) -> 'ExceptHeader': tokens = [Token(Token.SEPARATOR, indent), Token(Token.EXCEPT)] @@ -1097,11 +1104,11 @@ def from_params(cls, patterns: 'Sequence[str]' = (), type: 'str|None' = None, if type: tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.OPTION, f'type={type}')]) - if variable: + if assign: tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.AS), Token(Token.SEPARATOR, separator), - Token(Token.ASSIGN, variable)]) + Token(Token.ASSIGN, assign)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -1114,9 +1121,15 @@ def pattern_type(self) -> 'str|None': return self.get_option('type') @property - def variable(self) -> 'str|None': + def assign(self) -> 'str|None': return self.get_value(Token.ASSIGN) + @property + def variable(self) -> 'str|None': # TODO: Remove in RF 8.0. + warnings.warn("'ExceptHeader.variable' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'ExceptHeader.assigns' instead.") + return self.assign + def validate(self, ctx: 'ValidationContext'): self._validate_options() as_token = self.get_token(Token.AS) diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 9fd4473a780..cc79d7fc092 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -347,7 +347,7 @@ def __init__(self, parent): def build(self, node): error = format_error(self._get_errors(node)) self.model = self.parent.body.create_for( - node.variables, node.flavor, node.values, node.start, node.mode, node.fill, + node.assign, node.flavor, node.values, node.start, node.mode, node.fill, lineno=node.lineno, error=error ) for step in node.body: @@ -483,7 +483,7 @@ def build(self, node): errors = self._get_errors(node) while node: self.model = root.body.create_branch(node.type, node.patterns, - node.pattern_type, node.variable, + node.pattern_type, node.assign, lineno=node.lineno) for step in node.body: self.visit(step) diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index a10033f2a80..5f746d08bff 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -693,7 +693,7 @@ def test_ForHeader(self): tokens, ForHeader, flavor='IN ZIP', - variables=['${value1}', '${value2}'], + assign=['${value1}', '${value2}'], values=['${list1}', '${list2}'], separator=' ' ) @@ -830,7 +830,7 @@ def test_ExceptHeader(self): tokens, ExceptHeader, patterns=['one', 'two'], - variable='${var}' + assign='${var}' ) # EXCEPT Example: * type=glob tokens = [ @@ -868,7 +868,7 @@ def test_ExceptHeader(self): ExceptHeader, patterns=['Error \\d', '(x|y)'], type='regexp', - variable='${var}' + assign='${var}' ) def test_FinallyHeader(self): From 49398d9fcba89560f8aea1a553fa6d26f7db6b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 6 Sep 2023 15:59:15 +0300 Subject: [PATCH 0669/1592] Rename `variable` attributes to `assign` in running and result models. This change was done already earlier in the parsing model as part of results models. Old attributes still exist as deprecated propertys, but the change affects also output.xml as well as JSON serialization. For details about all backwards compatibilities and deprecations see #4708. Fixes #4708. --- atest/resources/atest_resource.robot | 8 +++ atest/robot/cli/dryrun/try_except.robot | 4 +- .../cli/model_modifiers/ModelModifier.py | 6 +- .../robot/cli/model_modifiers/pre_rebot.robot | 22 +++---- atest/robot/running/for/for.resource | 4 +- doc/schema/robot.xsd | 6 +- doc/schema/running.json | 10 ++-- doc/schema/running_json_schema.py | 4 +- src/robot/model/control.py | 59 ++++++++++++++----- src/robot/output/listenerarguments.py | 18 ++++-- src/robot/output/xmllogger.py | 11 ++-- src/robot/reporting/jsmodelbuilders.py | 7 ++- src/robot/result/model.py | 33 +++++++---- src/robot/result/xmlelementhandlers.py | 8 +-- src/robot/running/bodyrunner.py | 18 +++--- src/robot/running/model.py | 22 +++++-- src/robot/testdoc.py | 4 +- utest/model/test_control.py | 16 ++--- utest/reporting/test_jsmodelbuilders.py | 2 +- utest/result/test_resultbuilder.py | 4 +- utest/result/test_resultmodel.py | 1 - utest/result/test_visitor.py | 2 +- utest/running/test_run_model.py | 14 +++-- 23 files changed, 178 insertions(+), 105 deletions(-) diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index ff0707bea4c..f239dbe9cea 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -122,6 +122,14 @@ Check Keyword Data Should Be Equal ${{', '.join($kw.tags)}} ${tags} Should Be Equal ${kw.type} ${type} +Check TRY Data + [Arguments] ${try} ${patterns}= ${pattern_type}=${None} ${assign}=${None} ${status}=PASS + Should Be Equal ${try.type} TRY + Should Be Equal ${{', '.join($try.patterns)}} ${patterns} + Should Be Equal ${try.pattern_type} ${pattern_type} + Should Be Equal ${try.assign} ${assign} + Should Be Equal ${try.status} ${status} + Test And All Keywords Should Have Passed [Arguments] ${name}=${TESTNAME} ${allow not run}=False ${tc} = Check Test Case ${name} diff --git a/atest/robot/cli/dryrun/try_except.robot b/atest/robot/cli/dryrun/try_except.robot index a5e9738dc0e..d2c1d31db95 100644 --- a/atest/robot/cli/dryrun/try_except.robot +++ b/atest/robot/cli/dryrun/try_except.robot @@ -6,11 +6,11 @@ Resource dryrun_resource.robot *** Test Cases *** TRY ${tc} = Check Test Case ${TESTNAME} - Check Keyword Data ${tc.body[0].body[0]} ${EMPTY} type=TRY + Check TRY Data ${tc.body[0].body[0]} Check Keyword Data ${tc.body[0].body[0].body[0]} resource.Simple UK Check Keyword Data ${tc.body[0].body[0].body[0].body[0]} BuiltIn.Log args=Hello from UK status=NOT RUN Check Keyword Data ${tc.body[0].body[1].body[0]} BuiltIn.Log args=handling it status=NOT RUN Check Keyword Data ${tc.body[0].body[2].body[0]} BuiltIn.Log args=in the else status=NOT RUN Check Keyword Data ${tc.body[0].body[3].body[0]} BuiltIn.Log args=in the finally status=NOT RUN - Check Keyword Data ${tc.body[1].body[0]} ${EMPTY} status=FAIL type=TRY + Check TRY Data ${tc.body[1].body[0]} status=FAIL Check Keyword Data ${tc.body[1].body[0].body[0]} resource.Anarchy in the UK status=FAIL args=1, 2 diff --git a/atest/robot/cli/model_modifiers/ModelModifier.py b/atest/robot/cli/model_modifiers/ModelModifier.py index 41aa2f2220f..383d618d91d 100644 --- a/atest/robot/cli/model_modifiers/ModelModifier.py +++ b/atest/robot/cli/model_modifiers/ModelModifier.py @@ -30,9 +30,9 @@ def start_for(self, for_): for_.values = ['FOR', 'is', 'modified!'] def start_for_iteration(self, iteration): - for name, value in iteration.variables.items(): - iteration.variables[name] = value + ' (modified)' - iteration.variables['${x}'] = 'new' + for name, value in iteration.assign.items(): + iteration.assign[name] = value + ' (modified)' + iteration.assign['${x}'] = 'new' def start_if_branch(self, branch): if branch.condition == "'IF' == 'WRONG'": diff --git a/atest/robot/cli/model_modifiers/pre_rebot.robot b/atest/robot/cli/model_modifiers/pre_rebot.robot index 7c49b041f4e..dbe583e96e2 100644 --- a/atest/robot/cli/model_modifiers/pre_rebot.robot +++ b/atest/robot/cli/model_modifiers/pre_rebot.robot @@ -60,17 +60,17 @@ Modifiers are used before normal configuration Modify FOR [Setup] Modify FOR and IF ${tc} = Check Test Case FOR IN RANGE - Should Be Equal ${tc.body[0].flavor} IN - Should Be Equal ${tc.body[0].values} ${{('FOR', 'is', 'modified!')}} - Should Be Equal ${tc.body[0].body[0].variables['\${i}']} 0 (modified) - Should Be Equal ${tc.body[0].body[0].variables['\${x}']} new - Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} 0 - Should Be Equal ${tc.body[0].body[1].variables['\${i}']} 1 (modified) - Should Be Equal ${tc.body[0].body[1].variables['\${x}']} new - Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} 1 - Should Be Equal ${tc.body[0].body[2].variables['\${i}']} 2 (modified) - Should Be Equal ${tc.body[0].body[2].variables['\${x}']} new - Check Log Message ${tc.body[0].body[2].body[0].msgs[0]} 2 + Should Be Equal ${tc.body[0].flavor} IN + Should Be Equal ${tc.body[0].values} ${{('FOR', 'is', 'modified!')}} + Should Be Equal ${tc.body[0].body[0].assign['\${i}']} 0 (modified) + Should Be Equal ${tc.body[0].body[0].assign['\${x}']} new + Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} 0 + Should Be Equal ${tc.body[0].body[1].assign['\${i}']} 1 (modified) + Should Be Equal ${tc.body[0].body[1].assign['\${x}']} new + Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} 1 + Should Be Equal ${tc.body[0].body[2].assign['\${i}']} 2 (modified) + Should Be Equal ${tc.body[0].body[2].assign['\${x}']} new + Check Log Message ${tc.body[0].body[2].body[0].msgs[0]} 2 Modify IF [Setup] Should Be Equal ${PREV TEST NAME} Modify FOR diff --git a/atest/robot/running/for/for.resource b/atest/robot/running/for/for.resource index 40fdfb30c12..9a71e966d7d 100644 --- a/atest/robot/running/for/for.resource +++ b/atest/robot/running/for/for.resource @@ -39,6 +39,6 @@ Should be IN ENUMERATE loop Should Be FOR Loop ${loop} ${iterations} ${status} IN ENUMERATE start=${start} Should be FOR iteration - [Arguments] ${iteration} &{variables} + [Arguments] ${iteration} &{assign} Should Be Equal ${iteration.type} ITERATION - Should Be Equal ${iteration.variables} ${variables} + Should Be Equal ${iteration.assign} ${assign} diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index 2ff14a970e1..e534830910c 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="4"> +<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="5"> <xs:annotation> <xs:documentation xml:lang="en"> = Robot Framework output.xml schema = @@ -33,7 +33,7 @@ <xs:simpleType name="SpecVersion"> <xs:restriction base="xs:integer"> <xs:minInclusive value="3" /> - <xs:maxInclusive value="4" /> + <xs:maxInclusive value="5" /> </xs:restriction> </xs:simpleType> <xs:complexType name="Suite"> @@ -220,7 +220,7 @@ </xs:choice> <xs:attribute name="type" type="TryType" use="required" /> <xs:attribute name="pattern_type" type="xs:string" /> - <xs:attribute name="variable" type="xs:string" /> + <xs:attribute name="assign" type="xs:string" /> </xs:complexType> <xs:simpleType name="TryType"> <xs:restriction base="xs:string"> diff --git a/doc/schema/running.json b/doc/schema/running.json index d332bbd68bd..b1d565ef620 100644 --- a/doc/schema/running.json +++ b/doc/schema/running.json @@ -170,8 +170,8 @@ "title": "Pattern Type", "type": "string" }, - "variable": { - "title": "Variable", + "assign": { + "title": "Assign", "type": "string" }, "body": { @@ -427,8 +427,8 @@ "title": "Error", "type": "string" }, - "variables": { - "title": "Variables", + "assign": { + "title": "Assign", "type": "array", "items": { "type": "string" @@ -500,7 +500,7 @@ } }, "required": [ - "variables", + "assign", "flavor", "values", "body" diff --git a/doc/schema/running_json_schema.py b/doc/schema/running_json_schema.py index c4930d73ab4..b49be48ce22 100755 --- a/doc/schema/running_json_schema.py +++ b/doc/schema/running_json_schema.py @@ -47,7 +47,7 @@ class Keyword(BodyItem): class For(BodyItem): type = Field('FOR', const=True) - variables: Sequence[str] + assign: Sequence[str] flavor: str values: Sequence[str] start: str | None @@ -80,7 +80,7 @@ class TryBranch(BodyItem): type: Literal['TRY', 'EXCEPT', 'ELSE', 'FINALLY'] patterns: Sequence[str] | None pattern_type: str | None - variable: str | None + assign: str | None body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 3afd1541d30..3069bbdb144 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import warnings from typing import Any, cast, Literal, Sequence, TypeVar, TYPE_CHECKING from robot.utils import setter @@ -42,17 +43,17 @@ class For(BodyItem): """ type = BodyItem.FOR body_class = Body - repr_args = ('variables', 'flavor', 'values', 'start', 'mode', 'fill') - __slots__ = ['variables', 'flavor', 'values', 'start', 'mode', 'fill'] + repr_args = ('assign', 'flavor', 'values', 'start', 'mode', 'fill') + __slots__ = ['assign', 'flavor', 'values', 'start', 'mode', 'fill'] - def __init__(self, variables: Sequence[str] = (), + def __init__(self, assign: Sequence[str] = (), flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', values: Sequence[str] = (), start: 'str|None' = None, mode: 'str|None' = None, fill: 'str|None' = None, parent: BodyItemParent = None): - self.variables = tuple(variables) + self.assign = tuple(assign) self.flavor = flavor self.values = tuple(values) self.start = start @@ -61,6 +62,19 @@ def __init__(self, variables: Sequence[str] = (), self.parent = parent self.body = () + @property + def variables(self) -> 'tuple[str, ...]': # TODO: Remove in RF 8.0. + """Deprecated since Robot Framework 7.0. Use :attr:`assign` instead.""" + warnings.warn("'For.variables' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'For.assign' instead.") + return self.assign + + @variables.setter + def variables(self, assign: 'tuple[str, ...]'): + warnings.warn("'For.variables' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'For.assign' instead.") + self.assign = assign + @setter def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @@ -78,7 +92,7 @@ def visit(self, visitor: SuiteVisitor): visitor.visit_for(self) def __str__(self): - parts = ['FOR', *self.variables, self.flavor, *self.values] + parts = ['FOR', *self.assign, self.flavor, *self.values] for name, value in [('start', self.start), ('mode', self.mode), ('fill', self.fill)]: @@ -91,7 +105,7 @@ def _include_in_repr(self, name: str, value: Any) -> bool: def to_dict(self) -> DataDict: data = {'type': self.type, - 'variables': self.variables, + 'assign': self.assign, 'flavor': self.flavor, 'values': self.values} for name, value in [('start', self.start), @@ -234,23 +248,36 @@ def to_dict(self) -> DataDict: class TryBranch(BodyItem): """Represents individual ``TRY``, ``EXCEPT``, ``ELSE`` or ``FINALLY`` branch.""" body_class = Body - repr_args = ('type', 'patterns', 'pattern_type', 'variable') - __slots__ = ['type', 'patterns', 'pattern_type', 'variable'] + repr_args = ('type', 'patterns', 'pattern_type', 'assign') + __slots__ = ['type', 'patterns', 'pattern_type', 'assign'] def __init__(self, type: str = BodyItem.TRY, patterns: Sequence[str] = (), pattern_type: 'str|None' = None, - variable: 'str|None' = None, + assign: 'str|None' = None, parent: BodyItemParent = None): - if (patterns or pattern_type or variable) and type != BodyItem.EXCEPT: - raise TypeError(f"'{type}' branches do not accept patterns or variables.") + if (patterns or pattern_type or assign) and type != BodyItem.EXCEPT: + raise TypeError(f"'{type}' branches do not accept patterns or assignment.") self.type = type self.patterns = tuple(patterns) self.pattern_type = pattern_type - self.variable = variable + self.assign = assign self.parent = parent self.body = () + @property + def variable(self) -> 'str|None': # TODO: Remove in RF 8.0. + """Deprecated since Robot Framework 7.0. Use :attr:`assign` instead.""" + warnings.warn("'TryBranch.variable' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'TryBranch.assign' instead.") + return self.assign + + @variable.setter + def variable(self, assign: 'str|None'): + warnings.warn("'TryBranch.variable' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'TryBranch.assign' instead.") + self.assign = assign + @setter def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @@ -270,8 +297,8 @@ def __str__(self) -> str: parts = ['EXCEPT', *self.patterns] if self.pattern_type: parts.append(f'type={self.pattern_type}') - if self.variable: - parts.extend(['AS', self.variable]) + if self.assign: + parts.extend(['AS', self.assign]) return ' '.join(parts) def _include_in_repr(self, name: str, value: Any) -> bool: @@ -286,8 +313,8 @@ def to_dict(self) -> DataDict: data['patterns'] = self.patterns if self.pattern_type: data['pattern_type'] = self.pattern_type - if self.variable: - data['variable'] = self.variable + if self.assign: + data['assign'] = self.assign data['body'] = self.body.to_dicts() return data diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index dcf03513d0d..2b930b08979 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -130,15 +130,14 @@ class EndTestArguments(StartTestArguments): class StartKeywordArguments(_ListenerArgumentsFromItem): - _attribute_names = ('doc', 'assign', 'tags', 'lineno', 'type', 'status', 'starttime') + _attribute_names = ('doc', 'tags', 'lineno', 'type', 'status', 'starttime') _type_attributes = { BodyItem.FOR: ('variables', 'flavor', 'values'), BodyItem.IF: ('condition',), BodyItem.ELSE_IF: ('condition',), BodyItem.EXCEPT: ('patterns', 'pattern_type', 'variable'), - BodyItem.WHILE: ('condition', 'limit', 'on_limit_message'), + BodyItem.WHILE: ('condition', 'limit', 'on_limit_message'), # FIXME: Add 'on_limit' BodyItem.RETURN: ('values',), - BodyItem.ITERATION: ('variables',) } _for_flavor_attributes = { 'IN ENUMERATE': ('start',), @@ -146,14 +145,23 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): } def _get_extra_attributes(self, kw): + # FOR and TRY model objects use `assign` starting from RF 7.0, but for + # backwards compatibility reasons we pass them as `variable(s)`. + assign = kw.assign if kw.type in ('KEYWORD', 'SETUP', 'TEARDOWN') else () attrs = {'kwname': kw.kwname or '', 'libname': kw.libname or '', 'args': [a if is_string(a) else safe_str(a) for a in kw.args], + 'assign': list(assign), 'source': str(kw.source or '')} if kw.type in self._type_attributes: for name in self._type_attributes[kw.type]: - if hasattr(kw, name): - attrs[name] = self._get_attribute_value(kw, name) + # FOR and TRY model objects use `assign` instead of `variable(s)` + # starting from RF 7.0, but we want to use old names with listeners. + model_name = name if name not in ('variables', 'variable') else 'assign' + if hasattr(kw, model_name): + attrs[name] = self._get_attribute_value(kw, model_name) + elif kw.type == BodyItem.ITERATION and kw.parent.type == BodyItem.FOR: + attrs['variables'] = dict(kw.assign) if kw.type == BodyItem.FOR: for name in self._for_flavor_attributes.get(kw.flavor, ()): attrs[name] = self._get_attribute_value(kw, name) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index b4a4852e15e..c3f6cf2021c 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -35,7 +35,7 @@ def _get_writer(self, path, rpa, generator): writer.start('robot', {'generator': get_full_version(generator), 'generated': get_timestamp(), 'rpa': 'true' if rpa else 'false', - 'schemaversion': '4'}) + 'schemaversion': '5'}) return writer def close(self): @@ -104,7 +104,7 @@ def start_for(self, for_): 'start': for_.start, 'mode': for_.mode, 'fill': for_.fill}) - for name in for_.variables: + for name in for_.assign: self._writer.element('var', name) for value in for_.values: self._writer.element('value', value) @@ -116,7 +116,7 @@ def end_for(self, for_): def start_for_iteration(self, iteration): self._writer.start('iter') - for name, value in iteration.variables.items(): + for name, value in iteration.assign.items(): self._writer.element('var', value, {'name': name}) self._writer.element('doc', iteration.doc) @@ -134,8 +134,9 @@ def end_try(self, root): def start_try_branch(self, branch): if branch.type == branch.EXCEPT: self._writer.start('branch', attrs={ - 'type': 'EXCEPT', 'variable': branch.variable, - 'pattern_type': branch.pattern_type + 'type': 'EXCEPT', + 'pattern_type': branch.pattern_type, + 'assign': branch.assign }) self._write_list('pattern', branch.patterns) else: diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index 90bb7bfcdc5..acfffe7e36e 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -70,7 +70,7 @@ def _get_status(self, item): def _build_keywords(self, steps, split=False): splitting = self._context.start_splitting_if_needed(split) - # tuple([<listcomp>>]) is faster than tuple(<genex>) with short lists. + # tuple([<listcomp>]) is faster than tuple(<genex>) with short lists. model = tuple([self._build_keyword(step) for step in steps]) return model if not splitting else self._context.end_splitting(model) @@ -155,13 +155,16 @@ def build_keyword(self, kw, split=False): if getattr(kw, 'has_teardown', False): items.append(kw.teardown) with self._context.prune_input(kw.body): + # Hack to avoid new `For.assign` or `Try.assign` to be used here. + # Can be removed when building doesn't expect everything to be keywords. + assign = kw.assign if kw.type in ('KEYWORD', 'SETUP', 'TEARDOWN') else () return (KEYWORD_TYPES[kw.type], self._string(kw.kwname, attr=True), self._string(kw.libname, attr=True), self._string(kw.timeout), self._html(kw.doc), self._string(', '.join(kw.args)), - self._string(', '.join(kw.assign)), + self._string(', '.join(assign)), self._string(', '.join(kw.tags)), self._get_status(kw), self._build_keywords(items, split)) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 6aea410ee1b..593eae5d8f0 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -216,16 +216,16 @@ class ForIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): """Represents one FOR loop iteration.""" type = BodyItem.ITERATION body_class = Body - repr_args = ('variables',) - __slots__ = ['variables', 'status', 'starttime', 'endtime', 'doc'] + repr_args = ('assign',) + __slots__ = ['assign', 'status', 'starttime', 'endtime', 'doc'] - def __init__(self, variables: 'Mapping[str, str]|None' = None, + def __init__(self, assign: 'Mapping[str, str]|None' = None, status: str = 'FAIL', starttime: 'str|None' = None, endtime: 'str|None' = None, doc: str = '', parent: BodyItemParent = None): - self.variables = OrderedDict(variables or ()) + self.assign = OrderedDict(assign or ()) self.parent = parent self.status = status self.starttime = starttime @@ -233,6 +233,13 @@ def __init__(self, variables: 'Mapping[str, str]|None' = None, self.doc = doc self.body = [] + @property + def variables(self) -> 'Mapping[str, str]': # TODO: Remove in RF 8.0. + """Deprecated since Robot Framework 7.0. Use :attr:`assign` instead.""" + warnings.warn("'ForIteration.variables' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'ForIteration.assign' instead.") + return self.assign + @setter def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @@ -243,7 +250,7 @@ def visit(self, visitor: SuiteVisitor): @property @deprecated def name(self) -> str: - return ', '.join('%s = %s' % item for item in self.variables.items()) + return ', '.join('%s = %s' % item for item in self.assign.items()) @Body.register @@ -252,7 +259,7 @@ class For(model.For, StatusMixin, DeprecatedAttributesMixin): iterations_class = Iterations[iteration_class] __slots__ = ['status', 'starttime', 'endtime', 'doc'] - def __init__(self, variables: Sequence[str] = (), + def __init__(self, assign: Sequence[str] = (), flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', values: Sequence[str] = (), start: 'str|None' = None, @@ -263,7 +270,7 @@ def __init__(self, variables: Sequence[str] = (), endtime: 'str|None' = None, doc: str = '', parent: BodyItemParent = None): - super().__init__(variables, flavor, values, start, mode, fill, parent) + super().__init__(assign, flavor, values, start, mode, fill, parent) self.status = status self.starttime = starttime self.endtime = endtime @@ -276,14 +283,14 @@ def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> iterations_clas @property @deprecated def name(self) -> str: - variables = ' | '.join(self.variables) + assign = ' | '.join(self.assign) values = ' | '.join(self.values) for name, value in [('start', self.start), ('mode', self.mode), ('fill', self.fill)]: if value is not None: values += f' | {name}={value}' - return f'{variables} {self.flavor} [ {values} ]' + return f'{assign} {self.flavor} [ {values} ]' class WhileIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): @@ -405,13 +412,13 @@ class TryBranch(model.TryBranch, StatusMixin, DeprecatedAttributesMixin): def __init__(self, type: str = BodyItem.TRY, patterns: Sequence[str] = (), pattern_type: 'str|None' = None, - variable: 'str|None' = None, + assign: 'str|None' = None, status: str = 'FAIL', starttime: 'str|None' = None, endtime: 'str|None' = None, doc: str = '', parent: BodyItemParent = None): - super().__init__(type, patterns, pattern_type, variable, parent) + super().__init__(type, patterns, pattern_type, assign, parent) self.status = status self.starttime = starttime self.endtime = endtime @@ -426,8 +433,8 @@ def name(self) -> str: parts = [] if patterns: parts.append(' | '.join(patterns)) - if self.variable: - parts.append(f'AS {self.variable}') + if self.assign: + parts.append(f'AS {self.assign}') return ' '.join(parts) diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 8f86c8d2aa0..6f02d3497c2 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -223,6 +223,8 @@ class BranchHandler(ElementHandler): 'return', 'pattern', 'break', 'continue', 'error')) def start(self, elem, result): + if 'variable' in elem.attrib: # RF < 7.0 compatibility. + elem.attrib['assign'] = elem.attrib.pop('variable') return result.body.create_branch(**elem.attrib) @@ -372,12 +374,10 @@ class VarHandler(ElementHandler): def end(self, elem, result): value = elem.text or '' - if result.type == result.KEYWORD: + if result.type in (result.KEYWORD, result.FOR): result.assign += (value,) - elif result.type == result.FOR: - result.variables += (value,) elif result.type == result.ITERATION: - result.variables[elem.get('name')] = value + result.assign[elem.get('name')] = value else: raise DataError(f"Invalid element '{elem}' for result '{result!r}'.") diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index cbb9c3fd358..376d0be3b92 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -101,7 +101,7 @@ def run(self, data): error = DataError(data.error, syntax=True) else: run = True - result = ForResult(data.variables, data.flavor, data.values, data.start, + result = ForResult(data.assign, data.flavor, data.values, data.start, data.mode, data.fill) with StatusReporter(data, result, self._context, run) as status: if run: @@ -143,7 +143,7 @@ def _run_loop(self, data, result, values_for_rounds): def _get_values_for_rounds(self, data): if self._context.dry_run: return [None] - values_per_round = len(data.variables) + values_per_round = len(data.assign) if self._is_dict_iteration(data.values): values = self._resolve_dict_values(data.values) values = self._map_dict_values_to_rounds(values, values_per_round) @@ -218,10 +218,10 @@ def _run_one_round(self, data, result, values=None, run=True): variables = self._context.variables else: # Not really run (earlier failure, unexecuted IF branch, dry-run) variables = {} - values = [''] * len(data.variables) - for name, value in self._map_variables_and_values(data.variables, values): + values = [''] * len(data.assign) + for name, value in self._map_variables_and_values(data.assign, values): variables[name] = value - result.variables[name] = cut_assign_value(value) + result.assign[name] = cut_assign_value(value) runner = BodyRunner(self._context, run, self._templated) with StatusReporter(data, result, self._context, run): runner.run(data.body) @@ -556,7 +556,7 @@ def _run_invalid(self, data): error_reported = False for branch in data.body: result = TryBranchResult(branch.type, branch.patterns, branch.pattern_type, - branch.variable) + branch.assign) with StatusReporter(branch, result, self._context, run=False, suppress=True): runner = BodyRunner(self._context, run=False, templated=self._templated) runner.run(branch.body) @@ -598,10 +598,10 @@ def _run_excepts(self, data, error, run): else: pattern_error = None result = TryBranchResult(branch.type, branch.patterns, - branch.pattern_type, branch.variable) + branch.pattern_type, branch.assign) if run_branch: - if branch.variable: - self._context.variables[branch.variable] = str(error) + if branch.assign: + self._context.variables[branch.assign] = str(error) error = self._run_branch(branch, result, error=pattern_error) run = False else: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 385ca51915f..9241234cfd7 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -114,7 +114,7 @@ class For(model.For, WithSource): __slots__ = ['lineno', 'error'] body_class = Body - def __init__(self, variables: Sequence[str] = (), + def __init__(self, assign: Sequence[str] = (), flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', values: Sequence[str] = (), start: 'str|None' = None, @@ -123,10 +123,17 @@ def __init__(self, variables: Sequence[str] = (), parent: BodyItemParent = None, lineno: 'int|None' = None, error: 'str|None' = None): - super().__init__(variables, flavor, values, start, mode, fill, parent) + super().__init__(assign, flavor, values, start, mode, fill, parent) self.lineno = lineno self.error = error + @classmethod + def from_dict(cls, data: DataDict) -> 'For': + # RF 6.1 compatibility + if 'variables' in data: + data['assign'] = data.pop('variables') + return super().from_dict(data) + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: @@ -216,12 +223,19 @@ class TryBranch(model.TryBranch, WithSource): def __init__(self, type: str = BodyItem.TRY, patterns: Sequence[str] = (), pattern_type: 'str|None' = None, - variable: 'str|None' = None, + assign: 'str|None' = None, parent: BodyItemParent = None, lineno: 'int|None' = None): - super().__init__(type, patterns, pattern_type, variable, parent) + super().__init__(type, patterns, pattern_type, assign, parent) self.lineno = lineno + @classmethod + def from_dict(cls, data: DataDict) -> 'TryBranch': + # RF 6.1 compatibility. + if 'variable' in data: + data['assign'] = data.pop('variable') + return super().from_dict(data) + def to_dict(self) -> DataDict: data = super().to_dict() if self.lineno: diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index eb73200ad7a..97a0014c6fa 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -233,7 +233,7 @@ def _convert_keywords(self, keywords): yield self._convert_keyword(kw, 'KEYWORD') def _convert_for(self, data): - name = '%s %s %s' % (', '.join(data.variables), data.flavor, + name = '%s %s %s' % (', '.join(data.assign), data.flavor, seq2str2(data.values)) return {'type': 'FOR', 'name': self._escape(name), 'arguments': ''} @@ -250,7 +250,7 @@ def _convert_try(self, data): for branch in data.body: if branch.type == branch.EXCEPT: patterns = ', '.join(branch.patterns) - as_var = f'AS {branch.variable}' if branch.variable else '' + as_var = f'AS {branch.assign}' if branch.assign else '' name = f'{patterns} {as_var}'.strip() else: name = '' diff --git a/utest/model/test_control.py b/utest/model/test_control.py index 8a11db83b36..77d38e6d0f9 100644 --- a/utest/model/test_control.py +++ b/utest/model/test_control.py @@ -18,22 +18,22 @@ def test_string_reprs(self): for for_, exp_str, exp_repr in [ (For(), 'FOR IN', - "For(variables=(), flavor='IN', values=())"), + "For(assign=(), flavor='IN', values=())"), (For(('${x}',), 'IN RANGE', ('10',)), 'FOR ${x} IN RANGE 10', - "For(variables=('${x}',), flavor='IN RANGE', values=('10',))"), + "For(assign=('${x}',), flavor='IN RANGE', values=('10',))"), (For(('${x}', '${y}'), 'IN ENUMERATE', ('a', 'b')), 'FOR ${x} ${y} IN ENUMERATE a b', - "For(variables=('${x}', '${y}'), flavor='IN ENUMERATE', values=('a', 'b'))"), + "For(assign=('${x}', '${y}'), flavor='IN ENUMERATE', values=('a', 'b'))"), (For(['${x}'], 'IN ENUMERATE', ['@{stuff}'], start='1'), 'FOR ${x} IN ENUMERATE @{stuff} start=1', - "For(variables=('${x}',), flavor='IN ENUMERATE', values=('@{stuff}',), start='1')"), + "For(assign=('${x}',), flavor='IN ENUMERATE', values=('@{stuff}',), start='1')"), (For(('${x}', '${y}'), 'IN ZIP', ('${xs}', '${ys}'), mode='LONGEST', fill='-'), 'FOR ${x} ${y} IN ZIP ${xs} ${ys} mode=LONGEST fill=-', - "For(variables=('${x}', '${y}'), flavor='IN ZIP', values=('${xs}', '${ys}'), mode='LONGEST', fill='-')"), + "For(assign=('${x}', '${y}'), flavor='IN ZIP', values=('${xs}', '${ys}'), mode='LONGEST', fill='-')"), (For(['${ü}'], 'IN', ['föö']), 'FOR ${ü} IN föö', - "For(variables=('${ü}',), flavor='IN', values=('föö',))") + "For(assign=('${ü}',), flavor='IN', values=('föö',))") ]: assert_equal(str(for_), exp_str) assert_equal(repr(for_), 'robot.model.' + exp_repr) @@ -178,10 +178,10 @@ def test_string_reprs(self): "TryBranch(type='EXCEPT', patterns=('M', 'S', 'G', 'S'))"), (TryBranch(EXCEPT, (), None, '${x}'), 'EXCEPT AS ${x}', - "TryBranch(type='EXCEPT', variable='${x}')"), + "TryBranch(type='EXCEPT', assign='${x}')"), (TryBranch(EXCEPT, ('Message',), 'glob', '${x}'), 'EXCEPT Message type=glob AS ${x}', - "TryBranch(type='EXCEPT', patterns=('Message',), pattern_type='glob', variable='${x}')"), + "TryBranch(type='EXCEPT', patterns=('Message',), pattern_type='glob', assign='${x}')"), (TryBranch(ELSE), 'ELSE', "TryBranch(type='ELSE')"), diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index 2df07c964ab..c070a8e5ae9 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -185,7 +185,7 @@ def test_if(self): def test_for(self): test = TestSuite().tests.create() - test.body.create_for(variables=['${x}'], values=['a', 'b']) + test.body.create_for(assign=['${x}'], values=['a', 'b']) test.body.create_for(['${x}'], 'IN ENUMERATE', ['a', 'b'], start='1') end = ('', '', '', '', '', '', (0, None, 0), ()) exp_f1 = (3, '${x} IN [ a | b ]', *end) diff --git a/utest/result/test_resultbuilder.py b/utest/result/test_resultbuilder.py index 7c2c3f277bb..6dcb8bf0236 100644 --- a/utest/result/test_resultbuilder.py +++ b/utest/result/test_resultbuilder.py @@ -78,10 +78,10 @@ def test_message_is_built(self): def test_for_is_built(self): for_ = self.test.body[2] assert_equal(for_.flavor, 'IN') - assert_equal(for_.variables, ('${x}',)) + assert_equal(for_.assign, ('${x}',)) assert_equal(for_.values, ('not in source',)) assert_equal(len(for_.body), 1) - assert_equal(for_.body[0].variables, {'${x}': 'not in source'}) + assert_equal(for_.body[0].assign, {'${x}': 'not in source'}) assert_equal(len(for_.body[0].body), 1) kw = for_.body[0].body[0] assert_equal(kw.name, 'BuiltIn.Log') diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 3ef4e1e6657..fdba55ca6cd 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -456,7 +456,6 @@ def test_deprecated_keyword_specific_properties(self): for_ = For(['${x}', '${y}'], 'IN', ['a', 'b', 'c', 'd']) for name, expected in [('name', '${x} | ${y} IN [ a | b | c | d ]'), ('args', ()), - ('assign', ()), ('tags', Tags()), ('timeout', None)]: assert_equal(getattr(for_, name), expected) diff --git a/utest/result/test_visitor.py b/utest/result/test_visitor.py index 639cc83a669..e94162fe79a 100644 --- a/utest/result/test_visitor.py +++ b/utest/result/test_visitor.py @@ -65,7 +65,7 @@ class VisitFor(SuiteVisitor): in_for = False def start_for(self, for_): - for_.variables = ['${y}'] + for_.assign = ['${y}'] for_.flavor = 'IN RANGE' self.in_for = True diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 4b229e1de9f..21de2e43529 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -292,14 +292,17 @@ def test_keyword(self): name='Setup', lineno=1) def test_for(self): - self._verify(For(), type='FOR', variables=(), flavor='IN', values=(), body=[]) + self._verify(For(), type='FOR', assign=(), flavor='IN', values=(), body=[]) self._verify(For(['${i}'], 'IN RANGE', ['10'], lineno=2), - type='FOR', variables=('${i}',), flavor='IN RANGE', values=('10',), + type='FOR', assign=('${i}',), flavor='IN RANGE', values=('10',), body=[], lineno=2) self._verify(For(['${i}', '${a}'], 'IN ENUMERATE', ['cat', 'dog'], start='1'), - type='FOR', variables=('${i}', '${a}'), flavor='IN ENUMERATE', + type='FOR', assign=('${i}', '${a}'), flavor='IN ENUMERATE', values=('cat', 'dog'), start='1', body=[]) + def test_old_for_json(self): + assert_equal(For.from_dict({'variables': ('${x}',)}).assign, ('${x}',)) + def test_while(self): self._verify(While(), type='WHILE', body=[]) self._verify(While('1 > 0', '1 min'), @@ -352,10 +355,13 @@ def test_try_branch(self): self._verify(TryBranch(), type='TRY', body=[]) self._verify(TryBranch(Try.EXCEPT), type='EXCEPT', patterns=(), body=[]) self._verify(TryBranch(Try.EXCEPT, ['Pa*'], 'glob', '${err}'), type='EXCEPT', - patterns=('Pa*',), pattern_type='glob', variable='${err}', body=[]) + patterns=('Pa*',), pattern_type='glob', assign='${err}', body=[]) self._verify(TryBranch(Try.ELSE, lineno=7), type='ELSE', body=[], lineno=7) self._verify(TryBranch(Try.FINALLY, lineno=8), type='FINALLY', body=[], lineno=8) + def test_old_try_branch_json(self): + assert_equal(TryBranch.from_dict({'variable': '${x}'}).assign, '${x}') + def test_try_structure(self): root = Try() root.body.create_branch(Try.TRY).body.create_keyword('K1') From bb01bc38bc389b1ea805ddf850de3ee25023f947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 7 Sep 2023 09:34:17 +0300 Subject: [PATCH 0670/1592] Avoid singular headers that will soon be deprecated. Actual deprecation is covered by #4432. --- atest/resources/rebot_resource.robot | 6 +-- .../robot/running/duplicate_suite_name.robot | 4 +- atest/robot/running/duplicate_test_name.robot | 4 +- atest/robot/running/test_case_status.robot | 4 +- .../builtin/replace_variables.robot | 4 +- .../run_keyword_based_on_suite_stats.robot | 4 +- .../run_keyword_if_test_passed_failed.robot | 4 +- .../builtin/run_keyword_if_unless.robot | 6 +-- .../run_keyword_variants_registering.robot | 4 +- ...n_keyword_variants_variable_handling.robot | 6 +-- .../builtin/setting_variables.robot | 6 +-- .../operating_system/env_vars.robot | 4 +- .../remote/keyword_tags.robot | 2 +- .../telnet/configuration.robot | 4 +- .../telnet/connections.robot | 4 +- .../standard_libraries/telnet/login.robot | 4 +- .../telnet/read_and_write.robot | 4 +- .../telnet/telnet_resource.robot | 2 +- .../telnet/terminal_emulation.robot | 2 +- atest/robot/tags/default_and_force_tags.robot | 4 +- atest/robot/tags/default_tags.robot | 4 +- atest/robot/tags/force_tags.robot | 4 +- .../tags/no_force_nor_default_tags.robot | 4 +- atest/robot/tags/set_tag.robot | 4 +- atest/robot/tags/set_tag_with_rebot.robot | 8 ++-- atest/robot/tags/test_tags.robot | 2 +- .../test_libraries/deprecated_keywords.robot | 6 +-- .../error_msg_and_details.robot | 6 +-- .../internal_modules_not_importable.robot | 6 +-- .../robot/test_libraries/library_scope.robot | 4 +- .../test_libraries/library_version.robot | 4 +- .../test_libraries/new_style_classes.robot | 4 +- atest/robot/testdoc/testdoc_resource.robot | 2 +- .../robot/variables/automatic_variables.robot | 4 +- .../variables/commandline_variables.robot | 6 +-- atest/robot/variables/reserved_syntax.robot | 4 +- .../robot/variables/variable_priorities.robot | 8 ++-- .../variables_from_resource_files.robot | 4 +- .../variables_from_variable_files.robot | 4 +- .../variables_in_import_settings.robot | 8 +--- .../remove_keywords/all_combinations.robot | 2 +- .../testdata/core/erroring_suite_setup.robot | 6 +-- .../core/erroring_suite_teardown.robot | 6 +-- atest/testdata/core/failing_suite_setup.robot | 4 +- .../failing_suite_setup_and_teardown.robot | 4 +- .../core/failing_suite_teardown.robot | 6 +-- .../failing_suite_teardown_dir/__init__.robot | 2 +- .../failing_teardown.robot | 4 +- .../failing_teardown_dir/__init__.robot | 2 +- .../ftd_failing_teardown.robot | 4 +- .../ftd_passing_teardown.robot | 2 +- .../passing_teardown.robot | 2 +- .../ptd_failing_teardown.robot | 4 +- .../ptd_passing_teardown.robot | 2 +- atest/testdata/core/passing_suite_setup.robot | 4 +- .../passing_suite_setup_and_teardown.robot | 6 +-- .../core/passing_suite_teardown.robot | 4 +- atest/testdata/core/resources.robot | 4 +- .../resources_and_variables/resources.robot | 4 +- .../resources_and_variables/resources2.robot | 4 +- .../resources_imported_by_resource.robot | 4 +- .../core/test_suite_dir/no_tests_file_1.robot | 8 ++-- .../no_tests_dir_2/no_tests_file_3.robot | 7 --- .../test_dir_1/no_tests_file_2.robot | 8 +--- .../test_dir_2/test_dir_3/test_file_3.robot | 2 +- .../test_dir_1/test_file_2.robot | 2 +- .../core/test_suite_dir/test_file_1.robot | 2 +- .../__init__.robot | 6 +-- .../sub_suite_with_init_file/__INIT__.robot | 6 +-- .../test_cases_1.robot | 4 +- .../test_cases_2.robot | 2 +- .../test_cases_1.robot | 4 +- .../test_cases_2.robot | 2 +- .../test_cases_1.robot | 4 +- .../test_cases_2.robot | 2 +- .../keywords/resources/my_resource_1.robot | 2 +- .../keywords/resources/my_resource_2.robot | 2 +- .../resources/recommendation_resource_1.robot | 2 +- .../resources/recommendation_resource_2.robot | 2 +- atest/testdata/misc/dummy_lib_test.robot | 4 +- atest/testdata/misc/many_tests.robot | 4 +- .../multiple_suites/01__suite_first.robot | 2 +- .../02__sub.suite.1/first__suite4.robot | 2 +- .../02__sub.suite.1/second__.Sui.te.2..robot | 2 +- .../misc/multiple_suites/03__suite3.robot | 2 +- .../misc/multiple_suites/04__suite4.robot | 2 +- .../misc/multiple_suites/05__suite5.robot | 2 +- .../misc/multiple_suites/10__suite10.robot | 2 +- .../misc/multiple_suites/SUite7.robot | 4 +- .../misc/multiple_suites/suiTe_8.robot | 2 +- .../misc/multiple_suites/suite 6.robot | 4 +- .../misc/multiple_suites/suite_9_name.robot | 2 +- atest/testdata/misc/normal.robot | 6 +-- atest/testdata/misc/pass_and_fail.robot | 8 ++-- .../testdata/misc/suites/subsuites/sub1.robot | 6 +-- .../testdata/misc/suites/subsuites/sub2.robot | 6 +-- .../misc/suites/subsuites2/subsuite3.robot | 4 +- .../output/names_needing_escaping.robot | 4 +- .../parsing/data_formats/mixed_data/TSV.tsv | 2 +- .../resources/rest_directive_resource.rst | 4 +- .../resources/rest_directive_resource2.rest | 4 +- .../resources/robot_resource.robot | 4 +- .../resources/robot_resource2.robot | 4 +- .../data_formats/resources/tsv_resource.tsv | 4 +- .../data_formats/resources/tsv_resource2.tsv | 4 +- .../data_formats/resources/txt_resource.txt | 4 +- .../data_formats/resources/txt_resource2.txt | 4 +- .../parsing/data_formats/rest/include.rst | 2 +- .../parsing/data_formats/rest/sample.rst | 6 +-- .../rest/with_init/sub_suite1.RST | 2 +- .../parsing/data_formats/robot/sample.robot | 6 +-- .../robot/with_init/__init__.robot | 2 +- .../robot/with_init/sub_suite1.ROBOT | 2 +- .../parsing/data_formats/tsv/sample.tsv | 6 +-- .../data_formats/tsv/with_init/__init__.tsv | 4 +- .../data_formats/tsv/with_init/sub_suite1.TSV | 2 +- .../parsing/data_formats/txt/sample.txt | 4 +- .../data_formats/txt/with_init/__init__.txt | 2 +- .../data_formats/txt/with_init/sub_suite1.TXT | 2 +- atest/testdata/parsing/escaping.robot | 8 ++-- .../parsing/invalid_tables_resource.robot | 2 +- .../parsing/library_caching/file1.robot | 4 +- .../parsing/library_caching/file2.robot | 4 +- .../parsing/library_caching/resource.robot | 4 +- .../parsing/resource_parsing/01_tests.robot | 4 +- .../resource_parsing/02_resource.robot | 4 +- .../resource_parsing/03_resource.robot | 6 +-- .../parsing/resource_parsing/04_tests.robot | 4 +- atest/testdata/parsing/utf8_data.robot | 6 +-- atest/testdata/parsing/utf8_data.tsv | 8 ++-- atest/testdata/rebot/merge_html.robot | 4 +- .../running/duplicate_test_name.robot | 2 +- .../running/for/continue_for_loop.robot | 2 +- .../testdata/running/for/exit_for_loop.robot | 2 +- .../testdata/running/if/inline_if_else.robot | 2 +- .../keyword_timeout.robot | 2 +- .../stopping_with_signal/run_keyword.robot | 2 +- .../swallow_exception.robot | 2 +- .../stopping_with_signal/test_timeout.robot | 2 +- .../stopping_with_signal/with_teardown.robot | 2 +- .../without_any_timeout.robot | 2 +- atest/testdata/running/test_case_status.robot | 4 +- .../builtin/log_variables.robot | 8 ++-- .../builtin/replace_variables.robot | 8 ++-- ...cal_tests_passed_when_critical_fails.robot | 4 +- ...cal_tests_passed_when_criticals_pass.robot | 8 ++-- ...rd_if_all_tests_passed_when_all_pass.robot | 8 ++-- ..._if_all_tests_passed_when_test_fails.robot | 4 +- ...cal_tests_failed_when_critical_fails.robot | 8 ++-- ...cal_tests_failed_when_criticals_pass.robot | 4 +- ...rd_if_any_tests_failed_when_all_pass.robot | 4 +- ..._if_any_tests_failed_when_test_fails.robot | 8 ++-- ...variants_used_outside_suite_teardown.robot | 2 +- ...ord_if_test_failed_in_suite_fixtures.robot | 4 +- .../run_keyword_if_test_passed_failed.robot | 6 +-- ...ord_if_test_passed_in_suite_fixtures.robot | 4 +- .../builtin/run_keyword_if_unless.robot | 6 +-- .../run_keyword_variants_registering.robot | 8 ++-- ...n_keyword_variants_variable_handling.robot | 8 ++-- .../builtin/setting_variables/variables.robot | 8 ++-- .../setting_variables/variables2.robot | 2 +- .../builtin/tags/__init__.robot | 4 +- .../builtin/tags/sub1.robot | 8 ++-- .../builtin/tags/sub2.robot | 8 ++-- .../standard_libraries/dialogs/dialogs.robot | 2 +- .../operating_system/env_vars.robot | 6 +-- .../standard_libraries/reserved.robot | 4 +- .../telnet/configuration.robot | 2 +- .../telnet/connections.robot | 6 +-- .../standard_libraries/telnet/login.robot | 4 +- .../telnet/read_and_write.robot | 4 +- .../telnet/telnet_resource.robot | 2 +- .../telnet/terminal_emulation.robot | 2 +- .../tags/default_and_force_tags.robot | 4 +- atest/testdata/tags/default_tags.robot | 4 +- atest/testdata/tags/force_tags.robot | 4 +- .../tags/no_force_no_default_tags.robot | 2 +- ...d_properties_when_creating_libraries.robot | 4 +- .../test_libraries/deprecated_keywords.robot | 6 +-- .../error_msg_and_details.robot | 4 +- .../internal_modules_not_importable.robot | 4 +- .../library_import_from_archive.robot | 4 +- .../library_scope/01_tests.robot | 6 +-- .../library_scope/02_tests.robot | 6 +-- .../library_scope/__init__.robot | 4 +- .../library_scope/resource.robot | 4 +- .../test_libraries/library_version.robot | 4 +- .../test_libraries/new_style_classes.robot | 4 +- .../non_main_threads_logging.robot | 2 +- .../variables_for_library_import.robot | 2 +- .../variables/automatic_variables/auto1.robot | 6 +-- .../variables/automatic_variables/auto2.robot | 6 +-- .../automatic_variables/resource.robot | 2 +- .../variables/commandline_variables.robot | 2 +- .../testdata/variables/reserved_syntax.robot | 2 +- .../variables/resvarfiles/resource.robot | 4 +- .../variables/resvarfiles/resource_2.robot | 2 +- .../variables/resvarfiles/resource_3.robot | 16 +++---- .../variables/variable_priorities.robot | 8 ++-- .../testdata/variables/variable_scopes.robot | 4 +- atest/testdata/variables/variable_table.robot | 2 +- .../variable_table_in_resource_file.robot | 2 +- .../variables_from_resource_files.robot | 6 +-- .../variables_from_variable_files.robot | 6 +-- .../common_resource.robot | 2 +- .../resource1.robot | 2 +- .../resource2.robot | 2 +- .../test_cases1.robot | 6 +-- .../test_cases2.robot | 6 +-- .../resource_in_pythonpath.robot | 6 +-- .../resource_in_pythonpath_2.robot | 6 +-- utest/parsing/test_lexer.py | 48 +++++++++---------- 212 files changed, 450 insertions(+), 477 deletions(-) diff --git a/atest/resources/rebot_resource.robot b/atest/resources/rebot_resource.robot index 5faa9d5d468..b4dc7dc8849 100644 --- a/atest/resources/rebot_resource.robot +++ b/atest/resources/rebot_resource.robot @@ -1,12 +1,12 @@ -*** Setting *** +*** Settings *** Resource atest_resource.robot -*** Variable *** +*** Variables *** ${ORIG_START} Set in Create Output With Robot ${ORIG_END} -- ;; -- ${ORIG_ELAPSED} -- ;; -- -*** Keyword *** +*** Keywords *** Create Output With Robot [Arguments] ${outputname} ${options} ${sources} Run Tests ${options} ${sources} diff --git a/atest/robot/running/duplicate_suite_name.robot b/atest/robot/running/duplicate_suite_name.robot index 3eedb5d31bc..6386a65e05e 100644 --- a/atest/robot/running/duplicate_suite_name.robot +++ b/atest/robot/running/duplicate_suite_name.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests --exclude exclude running/duplicate_suite_name Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Suites with same name shoul be executed Should Contain Suites ${SUITE} ... Test diff --git a/atest/robot/running/duplicate_test_name.robot b/atest/robot/running/duplicate_test_name.robot index 56f686b4e8a..8996cfaf565 100644 --- a/atest/robot/running/duplicate_test_name.robot +++ b/atest/robot/running/duplicate_test_name.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests --exclude exclude running/duplicate_test_name.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Tests with same name should be executed Should Contain Tests ${SUITE} ... Same Test Multiple Times diff --git a/atest/robot/running/test_case_status.robot b/atest/robot/running/test_case_status.robot index 842747f7f03..cba04ca835d 100644 --- a/atest/robot/running/test_case_status.robot +++ b/atest/robot/running/test_case_status.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Documentation Tests for setting test case status correctly when test passes ... and when a failure or error occurs. Also includes test cases ... for running test setup and teardown in different situations. Suite Setup Run Tests ${EMPTY} running/test_case_status.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Test Passes Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/replace_variables.robot b/atest/robot/standard_libraries/builtin/replace_variables.robot index 713bc7ff491..96c56e64235 100644 --- a/atest/robot/standard_libraries/builtin/replace_variables.robot +++ b/atest/robot/standard_libraries/builtin/replace_variables.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/replace_variables.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Replace Variables Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/run_keyword_based_on_suite_stats.robot b/atest/robot/standard_libraries/builtin/run_keyword_based_on_suite_stats.robot index 749b766674d..71c1c1fea88 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_based_on_suite_stats.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_based_on_suite_stats.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keyword_based_on_suite_stats Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Run Keyword If All Tests Passed ${suite} = Get Test Suite Run Keyword If All Tests Passed When All Pass Should Be Equal As Integers ${suite.statistics.failed} 0 diff --git a/atest/robot/standard_libraries/builtin/run_keyword_if_test_passed_failed.robot b/atest/robot/standard_libraries/builtin/run_keyword_if_test_passed_failed.robot index 08d0f4e6100..d162baf3204 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_if_test_passed_failed.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_if_test_passed_failed.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keyword_if_test_passed_failed Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Run Keyword If Test Failed when test fails ${tc} = Check Test Case ${TEST NAME} Should Be Equal ${tc.teardown.body[0].name} BuiltIn.Log diff --git a/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot b/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot index cd6869316a6..9a4f0867aab 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keyword_if_unless.robot Resource atest_resource.robot -*** Variable *** +*** Variables *** ${EXECUTED} This is executed -*** Test Case *** +*** Test Cases *** Run Keyword If With True Expression ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.body[0].body[0].msgs[0]} ${EXECUTED} diff --git a/atest/robot/standard_libraries/builtin/run_keyword_variants_registering.robot b/atest/robot/standard_libraries/builtin/run_keyword_variants_registering.robot index 33e6bb7845e..4a253a71d71 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_variants_registering.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_variants_registering.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Documentation Tests for registering own run keyword variant Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keyword_variants_registering.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Not registered Keyword Fails With Content That Should Not Be Evaluated Twice Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/run_keyword_variants_variable_handling.robot b/atest/robot/standard_libraries/builtin/run_keyword_variants_variable_handling.robot index 19b89febcf2..472e828434a 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_variants_variable_handling.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_variants_variable_handling.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keyword_variants_variable_handling.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Variable Values Should Not Be Visible As Keyword's Arguments ${tc} = Check Test Case ${TEST NAME} Check Keyword Data ${tc.kws[0]} BuiltIn.Run Keyword args=My UK, Log, \${OBJECT} @@ -54,7 +54,7 @@ Run Keyword If With List And One Argument That needs to Be Processed ${tc} = Check Test Case ${TEST NAME} Check Keyword Arguments And Messages ${tc} -*** Keyword *** +*** Keywords *** Check Keyword Arguments And Messages [Arguments] ${tc} Check Keyword Data ${tc.kws[0].kws[0]} \\Log Many args=\@{ARGS} diff --git a/atest/robot/standard_libraries/builtin/setting_variables.robot b/atest/robot/standard_libraries/builtin/setting_variables.robot index c889df6f63f..d1e11227a76 100644 --- a/atest/robot/standard_libraries/builtin/setting_variables.robot +++ b/atest/robot/standard_libraries/builtin/setting_variables.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Documentation Tests for set variable and set test/suite/global variable keywords Suite Setup Run Tests ... --variable cli_var_1:CLI1 --variable cli_var_2:CLI2 --variable cli_var_3:CLI3 ... standard_libraries/builtin/setting_variables Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Set Variable ${tc} = Check Test Case ${TESTNAME} Check Log Message ${tc.kws[0].msgs[0]} \${var} = Hello @@ -174,7 +174,7 @@ Setting scalar global variable with list value is not possible Check Test Case ${TEST NAME} 1 Check Test Case ${TEST NAME} 2 -*** Keyword *** +*** Keywords *** Check Suite Teardown Passed ${suite} = Get Test Suite Variables Should Be Equal ${suite.teardown.status} PASS diff --git a/atest/robot/standard_libraries/operating_system/env_vars.robot b/atest/robot/standard_libraries/operating_system/env_vars.robot index bda0e983753..e86ca10693e 100644 --- a/atest/robot/standard_libraries/operating_system/env_vars.robot +++ b/atest/robot/standard_libraries/operating_system/env_vars.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests With Environment Variables Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Get Environment Variable Check test case ${TEST NAME} diff --git a/atest/robot/standard_libraries/remote/keyword_tags.robot b/atest/robot/standard_libraries/remote/keyword_tags.robot index d62fbbad65f..f52497438c3 100644 --- a/atest/robot/standard_libraries/remote/keyword_tags.robot +++ b/atest/robot/standard_libraries/remote/keyword_tags.robot @@ -22,7 +22,7 @@ Empty 'robot_tags' means no tags 'robot_tags' and doc tags bar foo zap -*** Keyword *** +*** Keywords *** Keyword tags should be [Arguments] @{tags} ${tc} = Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/telnet/configuration.robot b/atest/robot/standard_libraries/telnet/configuration.robot index 9bdadd0efbd..02bff5b9625 100644 --- a/atest/robot/standard_libraries/telnet/configuration.robot +++ b/atest/robot/standard_libraries/telnet/configuration.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests --loglevel DEBUG standard_libraries/telnet/configuration.robot Resource telnet_resource.robot -*** Test Case *** +*** Test Cases *** Library Default Window Size Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/telnet/connections.robot b/atest/robot/standard_libraries/telnet/connections.robot index e3991d88d8d..af3d8ab2c6e 100644 --- a/atest/robot/standard_libraries/telnet/connections.robot +++ b/atest/robot/standard_libraries/telnet/connections.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/telnet/connections.robot Resource telnet_resource.robot -*** Test Case *** +*** Test Cases *** Open Connection ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} Opening connection to localhost:23 with prompt: xxx diff --git a/atest/robot/standard_libraries/telnet/login.robot b/atest/robot/standard_libraries/telnet/login.robot index 1d6defc5178..f8820286601 100644 --- a/atest/robot/standard_libraries/telnet/login.robot +++ b/atest/robot/standard_libraries/telnet/login.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/telnet/login.robot Resource telnet_resource.robot -*** Test Case *** +*** Test Cases *** Successful login without prompt Verify successful login diff --git a/atest/robot/standard_libraries/telnet/read_and_write.robot b/atest/robot/standard_libraries/telnet/read_and_write.robot index 94a5e275ebd..83ad104e559 100644 --- a/atest/robot/standard_libraries/telnet/read_and_write.robot +++ b/atest/robot/standard_libraries/telnet/read_and_write.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests --loglevel DEBUG standard_libraries/telnet/read_and_write.robot Resource telnet_resource.robot -*** Test Case *** +*** Test Cases *** Write & Read ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} pwd diff --git a/atest/robot/standard_libraries/telnet/telnet_resource.robot b/atest/robot/standard_libraries/telnet/telnet_resource.robot index 1bff9cca7c7..8b36271d293 100644 --- a/atest/robot/standard_libraries/telnet/telnet_resource.robot +++ b/atest/robot/standard_libraries/telnet/telnet_resource.robot @@ -1,3 +1,3 @@ -*** Setting *** +*** Settings *** Resource atest_resource.robot Variables ${DATADIR}/standard_libraries/telnet/telnet_variables.py diff --git a/atest/robot/standard_libraries/telnet/terminal_emulation.robot b/atest/robot/standard_libraries/telnet/terminal_emulation.robot index 14a72c91e2d..53c5a956c37 100644 --- a/atest/robot/standard_libraries/telnet/terminal_emulation.robot +++ b/atest/robot/standard_libraries/telnet/terminal_emulation.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests --loglevel DEBUG standard_libraries/telnet/terminal_emulation.robot Resource telnet_resource.robot diff --git a/atest/robot/tags/default_and_force_tags.robot b/atest/robot/tags/default_and_force_tags.robot index 0c8b9125c34..e9b9d93d47f 100644 --- a/atest/robot/tags/default_and_force_tags.robot +++ b/atest/robot/tags/default_and_force_tags.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} tags/default_and_force_tags.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** No Own Tags Check Test Tags No Own Tags 01 02 03 four diff --git a/atest/robot/tags/default_tags.robot b/atest/robot/tags/default_tags.robot index b57cc300363..a4786935973 100644 --- a/atest/robot/tags/default_tags.robot +++ b/atest/robot/tags/default_tags.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} tags/default_tags.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** No Own Tags With Default Tags Check Test Tags No Own Tags With Default Tags 03 four diff --git a/atest/robot/tags/force_tags.robot b/atest/robot/tags/force_tags.robot index d983b914ad4..a13a14237ae 100644 --- a/atest/robot/tags/force_tags.robot +++ b/atest/robot/tags/force_tags.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} tags/force_tags.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** No Own Tags With Force Tags Check Test Tags No Own Tags With Force Tags 01 02 diff --git a/atest/robot/tags/no_force_nor_default_tags.robot b/atest/robot/tags/no_force_nor_default_tags.robot index e314b8106e6..24b4c681432 100644 --- a/atest/robot/tags/no_force_nor_default_tags.robot +++ b/atest/robot/tags/no_force_nor_default_tags.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} tags/no_force_no_default_tags.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** No Own Tags No Force Nor Default Check Test Tags No Own Tags No Force Nor Default diff --git a/atest/robot/tags/set_tag.robot b/atest/robot/tags/set_tag.robot index b1171cdd748..779fa54333a 100644 --- a/atest/robot/tags/set_tag.robot +++ b/atest/robot/tags/set_tag.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Test Teardown Remove File ${OUTDIR}/${OUTFILE} Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** File Suite Run Tests --settag cmdlinetag misc/normal.robot Check Test Tags First One cmdlinetag f1 t1 t2 diff --git a/atest/robot/tags/set_tag_with_rebot.robot b/atest/robot/tags/set_tag_with_rebot.robot index c1aa3c0e698..0bd4b6f6f0e 100644 --- a/atest/robot/tags/set_tag_with_rebot.robot +++ b/atest/robot/tags/set_tag_with_rebot.robot @@ -1,13 +1,13 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests And Read Outputs Suite Teardown Remove Files ${INFILE1} ${INFILE2} Resource atest_resource.robot -*** Variable *** +*** Variables *** ${INFILE1} %{TEMPDIR}${/}rebot-test-1.xml ${INFILE2} %{TEMPDIR}${/}rebot-test-2.xml -*** Test Case *** +*** Test Cases *** Tags Defined With Robot Set Tag Should Be Preserved Run Rebot \ ${INFILE1} Check Test Tags First One f1 robottag t1 t2 @@ -30,7 +30,7 @@ Process Multiple Files Using set Tag Check Test Tags First One f1 rebottag robottag t1 t2 Check Test Tags SubSuite1 First f1 rebottag t1 -*** Keyword *** +*** Keywords *** Run Tests And Read Outputs Run Tests Without Processing Output --settag robottag misc${/}normal.robot Move File ${OUT_FILE} ${INFILE1} diff --git a/atest/robot/tags/test_tags.robot b/atest/robot/tags/test_tags.robot index 9b934c77246..a432a1cc114 100644 --- a/atest/robot/tags/test_tags.robot +++ b/atest/robot/tags/test_tags.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} tags/force_tags.robot Resource atest_resource.robot diff --git a/atest/robot/test_libraries/deprecated_keywords.robot b/atest/robot/test_libraries/deprecated_keywords.robot index 6bdd85a6d94..52a99e5c16d 100644 --- a/atest/robot/test_libraries/deprecated_keywords.robot +++ b/atest/robot/test_libraries/deprecated_keywords.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} test_libraries/deprecated_keywords.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Deprecated keywords ${tc} = Check Test Case ${TESTNAME} Verify Deprecation Warning ${tc.kws[0]} DeprecatedKeywords.Deprecated Library Keyword @@ -45,7 +45,7 @@ Not Deprecated Keywords Syslog Should Not Contain ${name}' is deprecated END -*** Keyword *** +*** Keywords *** Verify Deprecation Warning [Arguments] ${kw} ${name} @{extra} ${message} = Catenate Keyword '${name}' is deprecated. @{extra} diff --git a/atest/robot/test_libraries/error_msg_and_details.robot b/atest/robot/test_libraries/error_msg_and_details.robot index 473c040dfe7..8a579e89a34 100644 --- a/atest/robot/test_libraries/error_msg_and_details.robot +++ b/atest/robot/test_libraries/error_msg_and_details.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests --loglevel DEBUG test_libraries/error_msg_and_details.robot Resource atest_resource.robot Test Template Verify Test Case And Error In Log -*** Test Case *** +*** Test Cases *** Exception Type is Removed From Generic Failures Generic Failure foo != bar @@ -92,7 +92,7 @@ Include internal traces when ROBOT_INTERNAL_TRACE is set Should Be True len($tb.splitlines()) > 5 [Teardown] Remove Environment Variable ROBOT_INTERNAL_TRACES -*** Keyword *** +*** Keywords *** Verify Test Case And Error In Log [Arguments] ${name} ${error} ${index}=0 ${msg}=0 ${tc} = Check Test Case ${name} diff --git a/atest/robot/test_libraries/internal_modules_not_importable.robot b/atest/robot/test_libraries/internal_modules_not_importable.robot index bf52382aa21..728968d2f03 100644 --- a/atest/robot/test_libraries/internal_modules_not_importable.robot +++ b/atest/robot/test_libraries/internal_modules_not_importable.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation Robot's internal modules cannot be imported directly. Suite Setup Run Keywords ... Create Directory ${TESTDIR}${/}robot AND @@ -6,10 +6,10 @@ Suite Setup Run Keywords Suite Teardown Remove Directory ${TESTDIR} recursively Resource atest_resource.robot -*** Variable *** +*** Variables *** ${TESTDIR} %{TEMPDIR}${/}module_importing_14350 -*** Test Case *** +*** Test Cases *** Internal modules cannot be imported directly Check Test Case ${TESTNAME} diff --git a/atest/robot/test_libraries/library_scope.robot b/atest/robot/test_libraries/library_scope.robot index 6fa82ae4654..a0d0b850dad 100644 --- a/atest/robot/test_libraries/library_scope.robot +++ b/atest/robot/test_libraries/library_scope.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Python Library Scopes Run Tests sources=test_libraries/library_scope Check Test Case Test 1.1 diff --git a/atest/robot/test_libraries/library_version.robot b/atest/robot/test_libraries/library_version.robot index 927e8eb4e86..c5079c8c6e2 100644 --- a/atest/robot/test_libraries/library_version.robot +++ b/atest/robot/test_libraries/library_version.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} test_libraries/library_version.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Python Library Version Syslog Should Contain Imported library 'classes.VersionLibrary' with arguments [ ] (version 0.1, class type, diff --git a/atest/robot/test_libraries/new_style_classes.robot b/atest/robot/test_libraries/new_style_classes.robot index 2958bc1a880..9c26497e9d7 100644 --- a/atest/robot/test_libraries/new_style_classes.robot +++ b/atest/robot/test_libraries/new_style_classes.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} test_libraries/new_style_classes.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Keyword From New Style Class Library Check Test Case Keyword From New Style Class Library Syslog Should Contain Imported library 'newstyleclasses.NewStyleClassLibrary' with arguments [ ] (version <unknown>, class type, TEST scope, 1 keywords diff --git a/atest/robot/testdoc/testdoc_resource.robot b/atest/robot/testdoc/testdoc_resource.robot index a9ce1559608..e83d94395a1 100644 --- a/atest/robot/testdoc/testdoc_resource.robot +++ b/atest/robot/testdoc/testdoc_resource.robot @@ -9,7 +9,7 @@ ${OUTFILE} %{TEMPDIR}/testdoc-output.html ${ARGFILE 1} %{TEMPDIR}/testdoc_argfile_1.txt ${ARGFILE 2} %{TEMPDIR}/testdoc_argfile_2.txt -*** Keyword *** +*** Keywords *** Run TestDoc [Arguments] @{args} ${rc}=0 ${remove_outfile}=True Run Keyword If ${remove outfile} Remove File ${OUTFILE} diff --git a/atest/robot/variables/automatic_variables.robot b/atest/robot/variables/automatic_variables.robot index d1b7f790ad3..e0f6dfae760 100644 --- a/atest/robot/variables/automatic_variables.robot +++ b/atest/robot/variables/automatic_variables.robot @@ -1,10 +1,10 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ... --exclude exclude -e e2 --include include_this_test --skip skip_me --skiponfailure sof ... variables/automatic_variables/ Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Previous Test Variables Should Have Default Values Check test case ${TEST NAME} diff --git a/atest/robot/variables/commandline_variables.robot b/atest/robot/variables/commandline_variables.robot index 3a67b584642..9167872926b 100644 --- a/atest/robot/variables/commandline_variables.robot +++ b/atest/robot/variables/commandline_variables.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Documentation How variables from CLI override other variables is tested in variable_priorities.robot Suite Setup Run Tests With Variables Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Normal Text Check Test Case ${TEST NAME} @@ -13,7 +13,7 @@ Special Characters No Colon In Variable Check Test Case ${TEST NAME} -*** Keyword *** +*** Keywords *** Run Tests With Variables ${options} = Catenate ... --variable NORMAL_TEXT:Hello diff --git a/atest/robot/variables/reserved_syntax.robot b/atest/robot/variables/reserved_syntax.robot index a3801d4af62..69712031dac 100644 --- a/atest/robot/variables/reserved_syntax.robot +++ b/atest/robot/variables/reserved_syntax.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} variables/reserved_syntax.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Reserved Syntax \*{var} Check Test Case ${TEST NAME} diff --git a/atest/robot/variables/variable_priorities.robot b/atest/robot/variables/variable_priorities.robot index 4aa61dbf64d..91bb2e4f1d0 100644 --- a/atest/robot/variables/variable_priorities.robot +++ b/atest/robot/variables/variable_priorities.robot @@ -1,14 +1,14 @@ -*** Setting *** +*** Settings *** Documentation Some of these tests are testing same features as tests under core/resource_and_variable_imports.html. These tests should all be gone through and all tests moved under variables/. Suite Setup Run Tests --variable PRIORITIES_1:CLI --variablefile ${VARFILE1} --variablefile ${VARFILE2} variables/variable_priorities.robot Resource atest_resource.robot -*** Variable *** +*** Variables *** ${VARDIR} atest/robot/variables${/}..${/}..${/}testdata${/}variables${/}resvarfiles ${VARFILE1} ${VARDIR}${/}cli_vars.py ${VARFILE2} ${VARDIR}${/}cli_vars_2.py:mandatory_argument -*** Test Case *** +*** Test Cases *** Individual CLI Variables Override All Other Variables Check Test Case Individual CLI Variables Override All Other Variables @@ -32,5 +32,3 @@ Variables With Different Priorities Are Seen Also In User Keywords Variables Set During Test Execution Override All Variables In Their Scope Check Test Case Variables Set During Test Execution Override All Variables In Their Scope - -*** Keyword *** diff --git a/atest/robot/variables/variables_from_resource_files.robot b/atest/robot/variables/variables_from_resource_files.robot index 46dd7f766ad..621729ba2b3 100644 --- a/atest/robot/variables/variables_from_resource_files.robot +++ b/atest/robot/variables/variables_from_resource_files.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run tests ${EMPTY} variables/variables_from_resource_files.robot Resource atest_resource.robot -*** Test Case *** +*** Test Cases *** Scalar String Check Test Case ${TEST NAME} diff --git a/atest/robot/variables/variables_from_variable_files.robot b/atest/robot/variables/variables_from_variable_files.robot index 634944eff93..d75dd3d9e27 100644 --- a/atest/robot/variables/variables_from_variable_files.robot +++ b/atest/robot/variables/variables_from_variable_files.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests --pythonpath ${PYTHONPATH DIR} variables/variables_from_variable_files.robot Resource atest_resource.robot *** Variables *** ${PYTHONPATH DIR} ${DATADIR}/variables/resvarfiles/pythonpath_dir -*** Test Case *** +*** Test Cases *** Scalar String Check Test Case ${TEST NAME} diff --git a/atest/robot/variables/variables_in_import_settings.robot b/atest/robot/variables/variables_in_import_settings.robot index a460812d2da..a6c76e29170 100644 --- a/atest/robot/variables/variables_in_import_settings.robot +++ b/atest/robot/variables/variables_in_import_settings.robot @@ -1,14 +1,10 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests \ variables/variables_in_import_settings Resource atest_resource.robot -*** Variable *** - -*** Test Case *** +*** Test Cases *** Variable Defined In Test Case File Is Used To Import Resources ${tc} = Check Test Case Test 1 Check Log Message ${tc.kws[0].kws[0].msgs[0]} Hello, world! ${tc} = Check Test Case Test 2 Check Log Message ${tc.kws[0].kws[0].msgs[0]} Hi, Tellus! - -*** Keyword *** diff --git a/atest/testdata/cli/remove_keywords/all_combinations.robot b/atest/testdata/cli/remove_keywords/all_combinations.robot index f742dc60071..404a1243e9b 100644 --- a/atest/testdata/cli/remove_keywords/all_combinations.robot +++ b/atest/testdata/cli/remove_keywords/all_combinations.robot @@ -13,7 +13,7 @@ ${KEPT BY NAME MESSAGE} +BYNAME -ALL ${REMOVED BY PATTERN MESSAGE} -BYPATTERN -ALL ${KEPT BY PATTERN MESSAGE} +BYPATTERN -ALL -*** Test Case *** +*** Test Cases *** Passing Log ${PASS MESSAGE} diff --git a/atest/testdata/core/erroring_suite_setup.robot b/atest/testdata/core/erroring_suite_setup.robot index 18524ada54f..5a4f46599c2 100644 --- a/atest/testdata/core/erroring_suite_setup.robot +++ b/atest/testdata/core/erroring_suite_setup.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Non-Existing Keyword Suite Teardown My TD -*** Test Case *** +*** Test Cases *** Test 1 [Documentation] FAIL Parent suite setup failed: ... No keyword with name 'Non-Existing Keyword' found. @@ -13,7 +13,7 @@ Test 2 ... No keyword with name 'Non-Existing Keyword' found. Fail This is not executed -*** Keyword *** +*** Keywords *** My TD Log Hello from suite teardown! No Operation diff --git a/atest/testdata/core/erroring_suite_teardown.robot b/atest/testdata/core/erroring_suite_teardown.robot index 421d18bb440..f5e9f9b30cc 100644 --- a/atest/testdata/core/erroring_suite_teardown.robot +++ b/atest/testdata/core/erroring_suite_teardown.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Setup Log Suite setup executed Suite Teardown Non-Existing Keyword Default Tags tag1 tag2 @@ -6,7 +6,7 @@ Default Tags tag1 tag2 *** Variables *** ${ERROR} Parent suite teardown failed:\nNo keyword with name 'Non-Existing Keyword' found. -*** Test Case *** +*** Test Cases *** Test 1 [Documentation] FAIL ${ERROR} Log This is executed normally @@ -16,7 +16,7 @@ Test 2 [Documentation] FAIL ${ERROR} Log All tests pass here -*** Keyword *** +*** Keywords *** My Keyword Log User keywords work normally No Operation diff --git a/atest/testdata/core/failing_suite_setup.robot b/atest/testdata/core/failing_suite_setup.robot index 5741aeb9a4e..6b1621ce269 100644 --- a/atest/testdata/core/failing_suite_setup.robot +++ b/atest/testdata/core/failing_suite_setup.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Fail Expected failure Suite Teardown Log Suite teardown executed -*** Test Case *** +*** Test Cases *** Test 1 [Documentation] FAIL Parent suite setup failed:\nExpected failure Fail This is not executed diff --git a/atest/testdata/core/failing_suite_setup_and_teardown.robot b/atest/testdata/core/failing_suite_setup_and_teardown.robot index a3f6674338a..b0691d7ba42 100644 --- a/atest/testdata/core/failing_suite_setup_and_teardown.robot +++ b/atest/testdata/core/failing_suite_setup_and_teardown.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Fail Setup failure\nin two lines Suite Teardown Fail Teardown failure\nin two lines -*** Test Case *** +*** Test Cases *** Test 1 [Documentation] FAIL Parent suite setup failed: ... Setup failure diff --git a/atest/testdata/core/failing_suite_teardown.robot b/atest/testdata/core/failing_suite_teardown.robot index 0311285c8ff..8ce876da315 100644 --- a/atest/testdata/core/failing_suite_teardown.robot +++ b/atest/testdata/core/failing_suite_teardown.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Setup Log Suite setup executed Suite Teardown Run Keywords Fail first AND Fail second Default Tags tag1 tag2 @@ -9,7 +9,7 @@ ${TEARDOWN FAILURES} SEPARATOR=\n\n ... 1) first ... 2) second -*** Test Case *** +*** Test Cases *** Passing [Documentation] FAIL ... Parent suite teardown failed: @@ -33,7 +33,7 @@ Skipping ... ${TEARDOWN FAILURES} Skip Expected skip -*** Keyword *** +*** Keywords *** My Keyword Log User keywords work normally No Operation diff --git a/atest/testdata/core/failing_suite_teardown_dir/__init__.robot b/atest/testdata/core/failing_suite_teardown_dir/__init__.robot index b324e770739..65789c950b2 100644 --- a/atest/testdata/core/failing_suite_teardown_dir/__init__.robot +++ b/atest/testdata/core/failing_suite_teardown_dir/__init__.robot @@ -1,2 +1,2 @@ -*** Setting *** +*** Settings *** Suite Teardown Fail Failure in top level suite teardown diff --git a/atest/testdata/core/failing_suite_teardown_dir/failing_teardown.robot b/atest/testdata/core/failing_suite_teardown_dir/failing_teardown.robot index 998d20fbdb4..e49f92ec4f0 100644 --- a/atest/testdata/core/failing_suite_teardown_dir/failing_teardown.robot +++ b/atest/testdata/core/failing_suite_teardown_dir/failing_teardown.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Suite Teardown Fail Failure in suite teardown -*** Test Case *** +*** Test Cases *** FTD Passing [Documentation] FAIL ... Parent suite teardown failed: diff --git a/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/__init__.robot b/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/__init__.robot index e63b9af3131..7bf7e96cf7b 100644 --- a/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/__init__.robot +++ b/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/__init__.robot @@ -1,2 +1,2 @@ -*** Setting *** +*** Settings *** Suite Teardown Fail Failure in sub suite teardown diff --git a/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/ftd_failing_teardown.robot b/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/ftd_failing_teardown.robot index 996be9c3dd8..67f1eadfc1f 100644 --- a/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/ftd_failing_teardown.robot +++ b/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/ftd_failing_teardown.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Suite Teardown Fail Failure in suite teardown -*** Test Case *** +*** Test Cases *** FTD FTD Passing [Documentation] FAIL ... Parent suite teardown failed: diff --git a/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/ftd_passing_teardown.robot b/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/ftd_passing_teardown.robot index 991fb01ff90..279bdf7726f 100644 --- a/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/ftd_passing_teardown.robot +++ b/atest/testdata/core/failing_suite_teardown_dir/failing_teardown_dir/ftd_passing_teardown.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** FTD PTD Passing [Documentation] FAIL ... Parent suite teardown failed: diff --git a/atest/testdata/core/failing_suite_teardown_dir/passing_teardown.robot b/atest/testdata/core/failing_suite_teardown_dir/passing_teardown.robot index be02fce1d85..29aff223c91 100644 --- a/atest/testdata/core/failing_suite_teardown_dir/passing_teardown.robot +++ b/atest/testdata/core/failing_suite_teardown_dir/passing_teardown.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** PTD Passing [Documentation] FAIL ... Parent suite teardown failed: diff --git a/atest/testdata/core/failing_suite_teardown_dir/passing_teardown_dir/ptd_failing_teardown.robot b/atest/testdata/core/failing_suite_teardown_dir/passing_teardown_dir/ptd_failing_teardown.robot index a70a3dbf8cd..1c07c5f77cc 100644 --- a/atest/testdata/core/failing_suite_teardown_dir/passing_teardown_dir/ptd_failing_teardown.robot +++ b/atest/testdata/core/failing_suite_teardown_dir/passing_teardown_dir/ptd_failing_teardown.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Suite Teardown Fail Leaf suite failed -*** Test Case *** +*** Test Cases *** PTD FTD Passing [Documentation] FAIL ... Parent suite teardown failed: diff --git a/atest/testdata/core/failing_suite_teardown_dir/passing_teardown_dir/ptd_passing_teardown.robot b/atest/testdata/core/failing_suite_teardown_dir/passing_teardown_dir/ptd_passing_teardown.robot index 7c01d922930..193c9661d7b 100644 --- a/atest/testdata/core/failing_suite_teardown_dir/passing_teardown_dir/ptd_passing_teardown.robot +++ b/atest/testdata/core/failing_suite_teardown_dir/passing_teardown_dir/ptd_passing_teardown.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** PTD PTD Passing [Documentation] FAIL ... Parent suite teardown failed: diff --git a/atest/testdata/core/passing_suite_setup.robot b/atest/testdata/core/passing_suite_setup.robot index f264a63a679..df26b323b3d 100644 --- a/atest/testdata/core/passing_suite_setup.robot +++ b/atest/testdata/core/passing_suite_setup.robot @@ -1,6 +1,6 @@ -*** Setting *** +*** Settings *** Suite Setup Set Suite Variable $SETUP Suite Setup Executed -*** Test Case *** +*** Test Cases *** Verify Suite Setup Should Be Equal ${SETUP} Suite Setup Executed diff --git a/atest/testdata/core/passing_suite_setup_and_teardown.robot b/atest/testdata/core/passing_suite_setup_and_teardown.robot index 58c4f247c8a..98e2fc7ad1f 100644 --- a/atest/testdata/core/passing_suite_setup_and_teardown.robot +++ b/atest/testdata/core/passing_suite_setup_and_teardown.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation Passing suite setup and teardon using user keywords. Suite Setup My Setup Suite Teardown My Teardown @@ -7,12 +7,12 @@ Library OperatingSystem *** Variables *** ${TEARDOWN FILE} %{TEMPDIR}/robot-suite-teardown-executed.txt -*** Test Case *** +*** Test Cases *** Verify Suite Setup [Documentation] PASS Should Be Equal ${SUITE SETUP} Suite Setup Executed -*** Keyword *** +*** Keywords *** My Setup Comment Testing that suite setup can be also a user keyword My Keyword diff --git a/atest/testdata/core/passing_suite_teardown.robot b/atest/testdata/core/passing_suite_teardown.robot index 61e22a168ca..142444b5cfc 100644 --- a/atest/testdata/core/passing_suite_teardown.robot +++ b/atest/testdata/core/passing_suite_teardown.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation Passing suite teardown using base keyword. Suite Teardown Create File ${TEARDOWN FILE} Library OperatingSystem @@ -6,6 +6,6 @@ Library OperatingSystem *** Variables *** ${TEARDOWN FILE} %{TEMPDIR}/robot-suite-teardown-executed.txt -*** Test Case *** +*** Test Cases *** Test No Operation diff --git a/atest/testdata/core/resources.robot b/atest/testdata/core/resources.robot index 731c9dcc5c3..cda1868b08e 100644 --- a/atest/testdata/core/resources.robot +++ b/atest/testdata/core/resources.robot @@ -1,8 +1,8 @@ -*** Variable *** +*** Variables *** ${resource_file_var} Variable from a resource file ${resource_file_var_2} Another variable from a resource file @{resource_file_list_var} List variable from a resource file -*** Keyword *** +*** Keywords *** Imported UK Log This is an imported user keyword diff --git a/atest/testdata/core/resources_and_variables/resources.robot b/atest/testdata/core/resources_and_variables/resources.robot index 9084264bfa7..e1bab759707 100644 --- a/atest/testdata/core/resources_and_variables/resources.robot +++ b/atest/testdata/core/resources_and_variables/resources.robot @@ -8,10 +8,10 @@ Resource resource_with_testcase_table.robot Test Setup Not allowed in resources Non Existing -*** Variable *** +*** Variables *** ${resources} Variable from resources.robot -*** Keyword *** +*** Keywords *** resources [Documentation] Keyword from resources.robot Resources Imported By Resource diff --git a/atest/testdata/core/resources_and_variables/resources2.robot b/atest/testdata/core/resources_and_variables/resources2.robot index f3cc89ec689..0607b270b14 100644 --- a/atest/testdata/core/resources_and_variables/resources2.robot +++ b/atest/testdata/core/resources_and_variables/resources2.robot @@ -1,7 +1,7 @@ -*** Variable *** +*** Variables *** ${resources2} Variable from resources2.robot -*** Keyword *** +*** Keywords *** resources2 [Documentation] Keyword from resources2.robot No Operation diff --git a/atest/testdata/core/resources_and_variables/resources_imported_by_resource.robot b/atest/testdata/core/resources_and_variables/resources_imported_by_resource.robot index 743e9968a1c..09a35106bc3 100644 --- a/atest/testdata/core/resources_and_variables/resources_imported_by_resource.robot +++ b/atest/testdata/core/resources_and_variables/resources_imported_by_resource.robot @@ -1,7 +1,7 @@ -*** Variable *** +*** Variables *** ${resources_imported_by_resource} Variable from resources_imported_by_resource.robot -*** Keyword *** +*** Keywords *** resources Imported By Resource [Documentation] Keyword from resources_imported_by_resource.robot No Operation diff --git a/atest/testdata/core/test_suite_dir/no_tests_file_1.robot b/atest/testdata/core/test_suite_dir/no_tests_file_1.robot index 38c04631cc9..5a8001f13f4 100644 --- a/atest/testdata/core/test_suite_dir/no_tests_file_1.robot +++ b/atest/testdata/core/test_suite_dir/no_tests_file_1.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** -*** Variable *** +*** Variables *** -*** Test Case *** +*** Test Cases *** -*** Keyword *** +*** Keywords *** diff --git a/atest/testdata/core/test_suite_dir/test_dir_1/no_tests_dir_2/no_tests_file_3.robot b/atest/testdata/core/test_suite_dir/test_dir_1/no_tests_dir_2/no_tests_file_3.robot index 38c04631cc9..e69de29bb2d 100644 --- a/atest/testdata/core/test_suite_dir/test_dir_1/no_tests_dir_2/no_tests_file_3.robot +++ b/atest/testdata/core/test_suite_dir/test_dir_1/no_tests_dir_2/no_tests_file_3.robot @@ -1,7 +0,0 @@ -*** Setting *** - -*** Variable *** - -*** Test Case *** - -*** Keyword *** diff --git a/atest/testdata/core/test_suite_dir/test_dir_1/no_tests_file_2.robot b/atest/testdata/core/test_suite_dir/test_dir_1/no_tests_file_2.robot index 38c04631cc9..3b2db3e4c3a 100644 --- a/atest/testdata/core/test_suite_dir/test_dir_1/no_tests_file_2.robot +++ b/atest/testdata/core/test_suite_dir/test_dir_1/no_tests_file_2.robot @@ -1,7 +1 @@ -*** Setting *** - -*** Variable *** - -*** Test Case *** - -*** Keyword *** +*** Test Cases *** diff --git a/atest/testdata/core/test_suite_dir/test_dir_1/test_dir_2/test_dir_3/test_file_3.robot b/atest/testdata/core/test_suite_dir/test_dir_1/test_dir_2/test_dir_3/test_file_3.robot index e41911d2fa5..d33904c8de2 100644 --- a/atest/testdata/core/test_suite_dir/test_dir_1/test_dir_2/test_dir_3/test_file_3.robot +++ b/atest/testdata/core/test_suite_dir/test_dir_1/test_dir_2/test_dir_3/test_file_3.robot @@ -1,3 +1,3 @@ -*** Test Case *** +*** Test Cases *** Test 3.1 No Operation diff --git a/atest/testdata/core/test_suite_dir/test_dir_1/test_file_2.robot b/atest/testdata/core/test_suite_dir/test_dir_1/test_file_2.robot index 264d35c03f1..8830e642754 100644 --- a/atest/testdata/core/test_suite_dir/test_dir_1/test_file_2.robot +++ b/atest/testdata/core/test_suite_dir/test_dir_1/test_file_2.robot @@ -1,3 +1,3 @@ -*** Test Case *** +*** Test Cases *** Test 2.1 No Operation diff --git a/atest/testdata/core/test_suite_dir/test_file_1.robot b/atest/testdata/core/test_suite_dir/test_file_1.robot index b6d417060b6..efe71efb0d1 100644 --- a/atest/testdata/core/test_suite_dir/test_file_1.robot +++ b/atest/testdata/core/test_suite_dir/test_file_1.robot @@ -1,3 +1,3 @@ -*** Test Case *** +*** Test Cases *** Test 1.1 No Operation diff --git a/atest/testdata/core/test_suite_dir_with_init_file/__init__.robot b/atest/testdata/core/test_suite_dir_with_init_file/__init__.robot index 5498659c810..48775697431 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/__init__.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/__init__.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation Setting metadata for test suite directory Suite Setup My Setup Setup of test suite directory Suite Teardown My Teardown Teardown of test suite directory @@ -11,11 +11,11 @@ Invalid Default Tags Not allowed Test Template Not allowed -*** Variable *** +*** Variables *** ${default} default ${default_tag_2} suite${default}2 -*** Keyword *** +*** Keywords *** My Setup [Arguments] ${msg} Log ${msg} diff --git a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot index f20c594a7a7..0e765505939 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Teardown My Teardown Teardown of sub test suite directory Test Setup Log Default setup from sub suite file Force Tags sub suite force @@ -6,10 +6,10 @@ Test Timeout 1 minute 52 seconds Library OperatingSystem Megadata This causes recommendation. -*** Variable *** +*** Variables *** ${default} default -*** Keyword *** +*** Keywords *** My Teardown [Arguments] @{msg_parts} ${msg} Create Message @{msg_parts} diff --git a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/test_cases_1.robot b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/test_cases_1.robot index 3aca5b33322..43792461a55 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/test_cases_1.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/test_cases_1.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Setup Log Setup of test case file Suite Teardown Log Teardown of test case file Test Setup Log Default setup from test file @@ -7,7 +7,7 @@ Force Tags test force Default Tags test default Test Timeout 4 h 5 m 6 s -*** Test Case *** +*** Test Cases *** S1TC1 No metadata No Operation diff --git a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/test_cases_2.robot b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/test_cases_2.robot index ea75b159ef7..bb297e4a9dc 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/test_cases_2.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/test_cases_2.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** S1TC2 No Metadata Log Whatever diff --git a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_without_init_file/test_cases_1.robot b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_without_init_file/test_cases_1.robot index b7940bf3a35..27061daff3f 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_without_init_file/test_cases_1.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_without_init_file/test_cases_1.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Setup Log Setup of test case file Suite Teardown Log Teardown of test case file Test Setup Log Default setup from test file @@ -7,7 +7,7 @@ Force Tags test force Default Tags test default Test Timeout 7 hour 8 minutes 9 seconds -*** Test Case *** +*** Test Cases *** S2TC1 No metadata No Operation diff --git a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_without_init_file/test_cases_2.robot b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_without_init_file/test_cases_2.robot index 12d0d437f76..e2b8f50178c 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_without_init_file/test_cases_2.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_without_init_file/test_cases_2.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** S2TC2 No Metadata Log Whatever diff --git a/atest/testdata/core/test_suite_dir_with_init_file/test_cases_1.robot b/atest/testdata/core/test_suite_dir_with_init_file/test_cases_1.robot index c95782744ce..89a02fab130 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/test_cases_1.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/test_cases_1.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Setup Log Setup of test case file Suite Teardown Log Teardown of test case file Test Setup Log Default setup from test file @@ -7,7 +7,7 @@ Force Tags test force suite force # dublicate should be ignored Default Tags test default Test Timeout 1 hour 2 minutes 3 seconds -*** Test Case *** +*** Test Cases *** TC1 No metadata No Operation diff --git a/atest/testdata/core/test_suite_dir_with_init_file/test_cases_2.robot b/atest/testdata/core/test_suite_dir_with_init_file/test_cases_2.robot index ce8ebcd405e..57345d698c7 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/test_cases_2.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/test_cases_2.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** TC2 No Metadata Log Whatever diff --git a/atest/testdata/keywords/resources/my_resource_1.robot b/atest/testdata/keywords/resources/my_resource_1.robot index ea4f96ea32e..f2835a02708 100644 --- a/atest/testdata/keywords/resources/my_resource_1.robot +++ b/atest/testdata/keywords/resources/my_resource_1.robot @@ -1,4 +1,4 @@ -*** Keyword *** +*** Keywords *** Keyword Only In Resource 1 Log Keyword in resource 1 diff --git a/atest/testdata/keywords/resources/my_resource_2.robot b/atest/testdata/keywords/resources/my_resource_2.robot index 5a1c74361bd..34d814e8ae0 100644 --- a/atest/testdata/keywords/resources/my_resource_2.robot +++ b/atest/testdata/keywords/resources/my_resource_2.robot @@ -1,4 +1,4 @@ -*** Keyword *** +*** Keywords *** Keyword Only In Resource 2 Log Keyword in resource 2 diff --git a/atest/testdata/keywords/resources/recommendation_resource_1.robot b/atest/testdata/keywords/resources/recommendation_resource_1.robot index 7d261c8c5af..d5dc03f68b7 100644 --- a/atest/testdata/keywords/resources/recommendation_resource_1.robot +++ b/atest/testdata/keywords/resources/recommendation_resource_1.robot @@ -1,4 +1,4 @@ -*** Keyword *** +*** Keywords *** Keyword Only In Resource 1 Log Keyword in resource 1 diff --git a/atest/testdata/keywords/resources/recommendation_resource_2.robot b/atest/testdata/keywords/resources/recommendation_resource_2.robot index e9de94a3fae..4282539611e 100644 --- a/atest/testdata/keywords/resources/recommendation_resource_2.robot +++ b/atest/testdata/keywords/resources/recommendation_resource_2.robot @@ -1,4 +1,4 @@ -*** Keyword *** +*** Keywords *** Keyword Only In Resource 2 Log Keyword in resource 2 diff --git a/atest/testdata/misc/dummy_lib_test.robot b/atest/testdata/misc/dummy_lib_test.robot index fd5a43e057e..2bdcff4efa9 100644 --- a/atest/testdata/misc/dummy_lib_test.robot +++ b/atest/testdata/misc/dummy_lib_test.robot @@ -1,6 +1,6 @@ -*** Setting *** +*** Settings *** Library DummyLib -*** Test Case *** +*** Test Cases *** Dummy Test dummykw diff --git a/atest/testdata/misc/many_tests.robot b/atest/testdata/misc/many_tests.robot index 500b75195a4..890f91ce746 100644 --- a/atest/testdata/misc/many_tests.robot +++ b/atest/testdata/misc/many_tests.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation Normal test cases Suite Setup Log Setup Suite Teardown No operation @@ -6,7 +6,7 @@ Force Tags f1 Default Tags d1 d2 Metadata Something My Value -*** Test Case *** +*** Test Cases *** First [Tags] t1 t2 Log Test 1 diff --git a/atest/testdata/misc/multiple_suites/01__suite_first.robot b/atest/testdata/misc/multiple_suites/01__suite_first.robot index a19c972b00c..158b987a684 100644 --- a/atest/testdata/misc/multiple_suites/01__suite_first.robot +++ b/atest/testdata/misc/multiple_suites/01__suite_first.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/02__sub.suite.1/first__suite4.robot b/atest/testdata/misc/multiple_suites/02__sub.suite.1/first__suite4.robot index 4df6c68a8e9..faf246c0615 100644 --- a/atest/testdata/misc/multiple_suites/02__sub.suite.1/first__suite4.robot +++ b/atest/testdata/misc/multiple_suites/02__sub.suite.1/first__suite4.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/02__sub.suite.1/second__.Sui.te.2..robot b/atest/testdata/misc/multiple_suites/02__sub.suite.1/second__.Sui.te.2..robot index a19c972b00c..158b987a684 100644 --- a/atest/testdata/misc/multiple_suites/02__sub.suite.1/second__.Sui.te.2..robot +++ b/atest/testdata/misc/multiple_suites/02__sub.suite.1/second__.Sui.te.2..robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/03__suite3.robot b/atest/testdata/misc/multiple_suites/03__suite3.robot index a19c972b00c..158b987a684 100644 --- a/atest/testdata/misc/multiple_suites/03__suite3.robot +++ b/atest/testdata/misc/multiple_suites/03__suite3.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/04__suite4.robot b/atest/testdata/misc/multiple_suites/04__suite4.robot index a19c972b00c..158b987a684 100644 --- a/atest/testdata/misc/multiple_suites/04__suite4.robot +++ b/atest/testdata/misc/multiple_suites/04__suite4.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/05__suite5.robot b/atest/testdata/misc/multiple_suites/05__suite5.robot index a19c972b00c..158b987a684 100644 --- a/atest/testdata/misc/multiple_suites/05__suite5.robot +++ b/atest/testdata/misc/multiple_suites/05__suite5.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/10__suite10.robot b/atest/testdata/misc/multiple_suites/10__suite10.robot index a19c972b00c..158b987a684 100644 --- a/atest/testdata/misc/multiple_suites/10__suite10.robot +++ b/atest/testdata/misc/multiple_suites/10__suite10.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/SUite7.robot b/atest/testdata/misc/multiple_suites/SUite7.robot index c7dcc3d7069..6ef70d5c189 100644 --- a/atest/testdata/misc/multiple_suites/SUite7.robot +++ b/atest/testdata/misc/multiple_suites/SUite7.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Library Non Existing -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/suiTe_8.robot b/atest/testdata/misc/multiple_suites/suiTe_8.robot index a19c972b00c..158b987a684 100644 --- a/atest/testdata/misc/multiple_suites/suiTe_8.robot +++ b/atest/testdata/misc/multiple_suites/suiTe_8.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/suite 6.robot b/atest/testdata/misc/multiple_suites/suite 6.robot index c2c211c394a..6e03cf98c3b 100644 --- a/atest/testdata/misc/multiple_suites/suite 6.robot +++ b/atest/testdata/misc/multiple_suites/suite 6.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Force Tags some -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/multiple_suites/suite_9_name.robot b/atest/testdata/misc/multiple_suites/suite_9_name.robot index a19c972b00c..158b987a684 100644 --- a/atest/testdata/misc/multiple_suites/suite_9_name.robot +++ b/atest/testdata/misc/multiple_suites/suite_9_name.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** test1 No Operation diff --git a/atest/testdata/misc/normal.robot b/atest/testdata/misc/normal.robot index 061c67e9cce..25be8588d8b 100644 --- a/atest/testdata/misc/normal.robot +++ b/atest/testdata/misc/normal.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation Normal test cases Force Tags f1 Default Tags d1 d_2 @@ -7,7 +7,7 @@ Metadata Something My Value *** Variables *** ${DELAY} 0.01 # Make sure elapsed time > 0 -*** Test Case *** +*** Test Cases *** First One [Tags] t1 t2 Log Test 1 @@ -22,7 +22,7 @@ Second One Nested keyword Nested keyword 2 -*** Keyword *** +*** Keywords *** logs on trace [Timeout] 1 hour [Tags] kw tags diff --git a/atest/testdata/misc/pass_and_fail.robot b/atest/testdata/misc/pass_and_fail.robot index 4bfc4880246..332467edc6d 100644 --- a/atest/testdata/misc/pass_and_fail.robot +++ b/atest/testdata/misc/pass_and_fail.robot @@ -1,14 +1,14 @@ -*** Setting *** +*** Settings *** Documentation Some tests here Suite Setup My Keyword Suite Setup Force Tags force Library String -*** Variable *** +*** Variables *** ${LEVEL1} INFO ${LEVEL2} DEBUG -*** Test Case *** +*** Test Cases *** Pass [Tags] pass # I am a comment. Please ignore me. @@ -20,7 +20,7 @@ Fail My Keyword Fail Fail Expected failure -*** Keyword *** +*** Keywords *** My Keyword [Arguments] ${who} [Tags] keyword tags force diff --git a/atest/testdata/misc/suites/subsuites/sub1.robot b/atest/testdata/misc/suites/subsuites/sub1.robot index 4a547950a25..986c8218982 100644 --- a/atest/testdata/misc/suites/subsuites/sub1.robot +++ b/atest/testdata/misc/suites/subsuites/sub1.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation Normal test cases Force Tags f1 Default Tags d1 d2 @@ -6,7 +6,7 @@ Metadata Something My Value Suite Setup ${SETUP} Suite Teardown ${TEARDOWN} -*** Variable *** +*** Variables *** ${SLEEP} 0.1 ${FAIL} NO ${MESSAGE} Original message @@ -14,7 +14,7 @@ ${LEVEL} INFO ${SETUP} Setup ${TEARDOWN} No Operation -*** Test Case *** +*** Test Cases *** SubSuite1 First [Tags] t1 Log ${MESSAGE} ${LEVEL} diff --git a/atest/testdata/misc/suites/subsuites/sub2.robot b/atest/testdata/misc/suites/subsuites/sub2.robot index 6c3c74031d2..048d1dd777d 100644 --- a/atest/testdata/misc/suites/subsuites/sub2.robot +++ b/atest/testdata/misc/suites/subsuites/sub2.robot @@ -1,13 +1,13 @@ -*** Setting *** +*** Settings *** Documentation Normal test cases Force Tags f1 Default Tags d1 d2 Metadata Something My Value -*** Variable *** +*** Variables *** ${SLEEP} 0.1 -*** Test Case *** +*** Test Cases *** SubSuite2 First [Tags] Log SubSuite2_First diff --git a/atest/testdata/misc/suites/subsuites2/subsuite3.robot b/atest/testdata/misc/suites/subsuites2/subsuite3.robot index 60f11da9034..9049df50dfa 100644 --- a/atest/testdata/misc/suites/subsuites2/subsuite3.robot +++ b/atest/testdata/misc/suites/subsuites2/subsuite3.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Name Custom name for 📜 'subsuite3.robot' Documentation Normal test cases Force Tags f1 Default Tags d1 d2 Metadata Something My Value -*** Test Case *** +*** Test Cases *** SubSuite3 First [Tags] t1 sub3 Log SubSuite3_First diff --git a/atest/testdata/output/names_needing_escaping.robot b/atest/testdata/output/names_needing_escaping.robot index bf53892f5ba..34ed0b4f1b5 100644 --- a/atest/testdata/output/names_needing_escaping.robot +++ b/atest/testdata/output/names_needing_escaping.robot @@ -1,7 +1,7 @@ *** Variables *** ${var} value -*** Test Case *** +*** Test Cases *** "Quotes" "Quotes" @@ -23,7 +23,7 @@ Escaped \\\${var} Newline \\n and Tab \\t Newline \n and Tab \t -*** Keyword *** +*** Keywords *** "Quotes" No Operation diff --git a/atest/testdata/parsing/data_formats/mixed_data/TSV.tsv b/atest/testdata/parsing/data_formats/mixed_data/TSV.tsv index df9b244d956..206c4e040e2 100644 --- a/atest/testdata/parsing/data_formats/mixed_data/TSV.tsv +++ b/atest/testdata/parsing/data_formats/mixed_data/TSV.tsv @@ -5,7 +5,7 @@ Resource ../resources/txt_resource.txt *Variable ${msg} *ERROR* -*Test Case +*Test Cases TSV Passing No operation TSV Failing [Documentation] FAIL **ERROR** Failing *${msg}* diff --git a/atest/testdata/parsing/data_formats/resources/rest_directive_resource.rst b/atest/testdata/parsing/data_formats/resources/rest_directive_resource.rst index 54897f6dbb2..657bbcabc7b 100644 --- a/atest/testdata/parsing/data_formats/resources/rest_directive_resource.rst +++ b/atest/testdata/parsing/data_formats/resources/rest_directive_resource.rst @@ -1,9 +1,9 @@ .. code:: robotframework - *** Setting *** + *** Settings *** Resource rest_directive_resource2.rest - *** Variable *** + *** Variables *** ${rest_resource_var} ReST Resource Variable diff --git a/atest/testdata/parsing/data_formats/resources/rest_directive_resource2.rest b/atest/testdata/parsing/data_formats/resources/rest_directive_resource2.rest index 13354bc9f90..524d743af65 100644 --- a/atest/testdata/parsing/data_formats/resources/rest_directive_resource2.rest +++ b/atest/testdata/parsing/data_formats/resources/rest_directive_resource2.rest @@ -1,7 +1,7 @@ .. sourcecode:: robotframework - * Variable + * Variables ${rest_resource_var2} ReST Resource Variable From Recursive Resource - * Keyword + * Keywords Keyword from ReST resource 2 No Operation diff --git a/atest/testdata/parsing/data_formats/resources/robot_resource.robot b/atest/testdata/parsing/data_formats/resources/robot_resource.robot index 6d59a8f5a1a..6f418284a1a 100644 --- a/atest/testdata/parsing/data_formats/resources/robot_resource.robot +++ b/atest/testdata/parsing/data_formats/resources/robot_resource.robot @@ -1,9 +1,9 @@ -*Setting* Value Value Value Value +*Settings* Value Value Value Value Resource robot_resource2.robot -*Variable* Value Value Value Value +*Variables* Value Value Value Value ${ROBOT RESOURCE VAR} ROBOT Resource Variable diff --git a/atest/testdata/parsing/data_formats/resources/robot_resource2.robot b/atest/testdata/parsing/data_formats/resources/robot_resource2.robot index a681741e37a..6f564b83e8b 100644 --- a/atest/testdata/parsing/data_formats/resources/robot_resource2.robot +++ b/atest/testdata/parsing/data_formats/resources/robot_resource2.robot @@ -1,6 +1,6 @@ -| *Variable | Value | Value | Value | Value | +| *Variables | Value | Value | Value | Value | | ${ROBOT RESOURCE VAR 2} | ROBOT Resource Variable From Recursive Resource | -| *Keyword | Action | Argument | Argument | Argument | +| *Keywords | Action | Argument | Argument | Argument | | Keyword from ROBOT resource 2 | No operation | diff --git a/atest/testdata/parsing/data_formats/resources/tsv_resource.tsv b/atest/testdata/parsing/data_formats/resources/tsv_resource.tsv index 27992b63260..58f49b09b9f 100644 --- a/atest/testdata/parsing/data_formats/resources/tsv_resource.tsv +++ b/atest/testdata/parsing/data_formats/resources/tsv_resource.tsv @@ -1,9 +1,9 @@ -*Setting* Value Value Value Value +*Settings* Value Value Value Value Resource tsv_resource2.tsv -*Variable* Value Value Value Value +*Variables* Value Value Value Value ${tsv_resource_var} TSV Resource Variable diff --git a/atest/testdata/parsing/data_formats/resources/tsv_resource2.tsv b/atest/testdata/parsing/data_formats/resources/tsv_resource2.tsv index 5f907995abc..4fc7650b318 100644 --- a/atest/testdata/parsing/data_formats/resources/tsv_resource2.tsv +++ b/atest/testdata/parsing/data_formats/resources/tsv_resource2.tsv @@ -1,6 +1,6 @@ -*Variable Value Value Value Value +*Variables Value Value Value Value ${tsv_resource_var2} TSV Resource Variable From Recursive Resource -*Keyword Action Argument Argument Argument +*Keywords Action Argument Argument Argument Keyword from TSV resource 2 No operation diff --git a/atest/testdata/parsing/data_formats/resources/txt_resource.txt b/atest/testdata/parsing/data_formats/resources/txt_resource.txt index 5ea5a1dfe4c..5df21d5cdc7 100644 --- a/atest/testdata/parsing/data_formats/resources/txt_resource.txt +++ b/atest/testdata/parsing/data_formats/resources/txt_resource.txt @@ -1,9 +1,9 @@ -*Setting* Value Value Value Value +*Settings* Value Value Value Value Resource txt_resource2.txt -*Variable* Value Value Value Value +*Variables* Value Value Value Value ${txt_resource_var} TXT Resource Variable diff --git a/atest/testdata/parsing/data_formats/resources/txt_resource2.txt b/atest/testdata/parsing/data_formats/resources/txt_resource2.txt index 821a93b9c09..5df96606ed8 100644 --- a/atest/testdata/parsing/data_formats/resources/txt_resource2.txt +++ b/atest/testdata/parsing/data_formats/resources/txt_resource2.txt @@ -1,6 +1,6 @@ -| *Variable | Value | Value | Value | Value | +| *Variables | Value | Value | Value | Value | | ${txt_resource_var2} | TXT Resource Variable From Recursive Resource | -| *Keyword | Action | Argument | Argument | Argument | +| *Keywords | Action | Argument | Argument | Argument | | Keyword from TXT resource 2 | No operation | diff --git a/atest/testdata/parsing/data_formats/rest/include.rst b/atest/testdata/parsing/data_formats/rest/include.rst index eeead5aa609..e3bb95a09b0 100644 --- a/atest/testdata/parsing/data_formats/rest/include.rst +++ b/atest/testdata/parsing/data_formats/rest/include.rst @@ -2,7 +2,7 @@ Included file with some more test data. .. code:: robotframework - *** Setting *** + *** Settings *** Default Tags default1 diff --git a/atest/testdata/parsing/data_formats/rest/sample.rst b/atest/testdata/parsing/data_formats/rest/sample.rst index 679bf4a2c1a..36159b81181 100644 --- a/atest/testdata/parsing/data_formats/rest/sample.rst +++ b/atest/testdata/parsing/data_formats/rest/sample.rst @@ -16,7 +16,7 @@ We have a devious plan to rule the world with robots. .. code:: robotframework - *Setting* *Value* + *Settings* *Value* Documentation A complex testdata file in rst format. # Default Tags are in include.rst @@ -65,7 +65,7 @@ def ignore_me_or_die(): .. code:: robotframework - * Variable + * Variables ${table_var} foo @{table_listvar} bar ${table_var} @@ -76,7 +76,7 @@ We support also `code-block` and `sourcecode` directives as alias for `code`. .. code-block:: robotframework - ***Test Case*** + ***Test Cases*** Passing Log Passing test case. diff --git a/atest/testdata/parsing/data_formats/rest/with_init/sub_suite1.RST b/atest/testdata/parsing/data_formats/rest/with_init/sub_suite1.RST index e5c2501e545..aa4974f2785 100644 --- a/atest/testdata/parsing/data_formats/rest/with_init/sub_suite1.RST +++ b/atest/testdata/parsing/data_formats/rest/with_init/sub_suite1.RST @@ -1,5 +1,5 @@ .. code:: robotframework - *Test Case* Whatever + *Test Cases* Whatever Suite1 Test No Operation diff --git a/atest/testdata/parsing/data_formats/robot/sample.robot b/atest/testdata/parsing/data_formats/robot/sample.robot index 412be48dd40..39cb78ff4b6 100644 --- a/atest/testdata/parsing/data_formats/robot/sample.robot +++ b/atest/testdata/parsing/data_formats/robot/sample.robot @@ -1,7 +1,7 @@ This text should be ignored, even though it's no a comment. We have a devious plan to rule the world with robots. -*Setting* *Value* +*Settings* *Value* Documentation A complex testdata file in robot format. Default Tags default1 @@ -20,7 +20,7 @@ Library OperatingSystem # as # well -* Variable # comment +* Variables # comment ${table_var} foo @{table_listvar} bar ${table_var} @@ -28,7 +28,7 @@ ${quoted} """this has """"many "" quotes """"" ${single_quoted} s'ingle'qu'ot'es'' -***Test Case*** +***Test Cases*** Passing Log Passing test case. diff --git a/atest/testdata/parsing/data_formats/robot/with_init/__init__.robot b/atest/testdata/parsing/data_formats/robot/with_init/__init__.robot index 6c51a2d9050..97abed6fa4e 100644 --- a/atest/testdata/parsing/data_formats/robot/with_init/__init__.robot +++ b/atest/testdata/parsing/data_formats/robot/with_init/__init__.robot @@ -1,4 +1,4 @@ -*Setting Value +*Settings Value Suite Setup Suite Setup Documentation Testing suite init file diff --git a/atest/testdata/parsing/data_formats/robot/with_init/sub_suite1.ROBOT b/atest/testdata/parsing/data_formats/robot/with_init/sub_suite1.ROBOT index bb475d87ba9..91902499a29 100644 --- a/atest/testdata/parsing/data_formats/robot/with_init/sub_suite1.ROBOT +++ b/atest/testdata/parsing/data_formats/robot/with_init/sub_suite1.ROBOT @@ -19,5 +19,5 @@ -*Test Case* Whatever +*Test Cases* Whatever Suite1 Test No Operation diff --git a/atest/testdata/parsing/data_formats/tsv/sample.tsv b/atest/testdata/parsing/data_formats/tsv/sample.tsv index 923e3b7133b..d575da24b2a 100644 --- a/atest/testdata/parsing/data_formats/tsv/sample.tsv +++ b/atest/testdata/parsing/data_formats/tsv/sample.tsv @@ -1,6 +1,6 @@ "This text should be ignored, even though it's not a comment." We have a devious plan to rule the world with robots. -*Setting* *Value* *Value* *Value* *Value* +*Settings* *Value* *Value* *Value* *Value* Documentation A complex testdata file in tsv format. Default Tags default1 @@ -13,14 +13,14 @@ Variables ../resources/variables.py Library OperatingSystem -*Variable* *Value* *Value* *Value* *Value* +*Variables* *Value* *Value* *Value* *Value* ${table_var} foo @{table_listvar} bar ${table_var} ${quoted} """this has """"many "" quotes """"" ${single_quoted} s'ingle'qu'ot'es'' -*Test Case* *Action* *Argument* *Argument* *Argument* +*Test Cases* *Action* *Argument* *Argument* *Argument* Passing Log Passing test case. diff --git a/atest/testdata/parsing/data_formats/tsv/with_init/__init__.tsv b/atest/testdata/parsing/data_formats/tsv/with_init/__init__.tsv index 75b6a748c5d..19beab2fd9e 100644 --- a/atest/testdata/parsing/data_formats/tsv/with_init/__init__.tsv +++ b/atest/testdata/parsing/data_formats/tsv/with_init/__init__.tsv @@ -1,8 +1,8 @@ -*Setting Value Value Value Value +*Settings Value Value Value Value Suite Setup Suite Setup Documentation Testing suite init file -*Variable Value Value Value Value +*Variables Value Value Value Value ${msg} Running suite setup *Keywords Action Argument Argument Argument diff --git a/atest/testdata/parsing/data_formats/tsv/with_init/sub_suite1.TSV b/atest/testdata/parsing/data_formats/tsv/with_init/sub_suite1.TSV index 8e63e5d2f6f..280c2acb634 100644 --- a/atest/testdata/parsing/data_formats/tsv/with_init/sub_suite1.TSV +++ b/atest/testdata/parsing/data_formats/tsv/with_init/sub_suite1.TSV @@ -19,5 +19,5 @@ -*Test Case* Whatever +*Test Cases* Whatever Suite1 Test No operation \ No newline at end of file diff --git a/atest/testdata/parsing/data_formats/txt/sample.txt b/atest/testdata/parsing/data_formats/txt/sample.txt index 07fb3afa368..02581aa3cb6 100644 --- a/atest/testdata/parsing/data_formats/txt/sample.txt +++ b/atest/testdata/parsing/data_formats/txt/sample.txt @@ -1,7 +1,7 @@ This text should be ignored, even though it's no a comment. We have a devious plan to rule the world with robots. -*Setting* *Value* +*Settings* *Value* Documentation A complex testdata file in txt format. Default Tags default1 @@ -22,7 +22,7 @@ ${quoted} """this has """"many "" quotes """"" ${single_quoted} s'ingle'qu'ot'es'' -***Test Case*** +***Test Cases*** Passing Log Passing test case. diff --git a/atest/testdata/parsing/data_formats/txt/with_init/__init__.txt b/atest/testdata/parsing/data_formats/txt/with_init/__init__.txt index 6c51a2d9050..97abed6fa4e 100644 --- a/atest/testdata/parsing/data_formats/txt/with_init/__init__.txt +++ b/atest/testdata/parsing/data_formats/txt/with_init/__init__.txt @@ -1,4 +1,4 @@ -*Setting Value +*Settings Value Suite Setup Suite Setup Documentation Testing suite init file diff --git a/atest/testdata/parsing/data_formats/txt/with_init/sub_suite1.TXT b/atest/testdata/parsing/data_formats/txt/with_init/sub_suite1.TXT index bb475d87ba9..91902499a29 100644 --- a/atest/testdata/parsing/data_formats/txt/with_init/sub_suite1.TXT +++ b/atest/testdata/parsing/data_formats/txt/with_init/sub_suite1.TXT @@ -19,5 +19,5 @@ -*Test Case* Whatever +*Test Cases* Whatever Suite1 Test No Operation diff --git a/atest/testdata/parsing/escaping.robot b/atest/testdata/parsing/escaping.robot index f5460f81079..ef616008774 100644 --- a/atest/testdata/parsing/escaping.robot +++ b/atest/testdata/parsing/escaping.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Variables escaping_variables.py -*** Variable *** +*** Variables *** ${MY SPACE} \ \ ${TWO SPACES} ${MY SPACE}${MY SPACE} ${FOUR SPACES} \ \ \ \ \ @@ -18,7 +18,7 @@ ${NOT VAR 2} ${NOT VAR} @{LIST} \ \ c:\\temp\\ \n \${xxx} ${NON STRING} ${None} -*** Test Case *** +*** Test Cases *** Spaces In Variable Table Should Be Equal ${MY SPACE} ${SP} Should Be Equal ${MY SPACE}${MY SPACE} ${SP}${SP} @@ -174,7 +174,7 @@ Pipe | | Should Be Equal | \| | ${PIPE} | | | Should Be Equal | \||| | ${PIPE * 3} | -*** Keyword *** +*** Keywords *** User keyword [Arguments] ${a1} ${a2} Should Contain ${a1} ${a2} diff --git a/atest/testdata/parsing/invalid_tables_resource.robot b/atest/testdata/parsing/invalid_tables_resource.robot index 47c8afa9deb..64abd9d36b6 100644 --- a/atest/testdata/parsing/invalid_tables_resource.robot +++ b/atest/testdata/parsing/invalid_tables_resource.robot @@ -14,5 +14,5 @@ This stuff should be ignored Library OperatingSystem -*** Variable *** +*** Variables *** ${DIR} ${CURDIR} diff --git a/atest/testdata/parsing/library_caching/file1.robot b/atest/testdata/parsing/library_caching/file1.robot index 5c7188f798f..750c5f56725 100644 --- a/atest/testdata/parsing/library_caching/file1.robot +++ b/atest/testdata/parsing/library_caching/file1.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Library OperatingSystem Resource resource.robot -*** Test Case *** +*** Test Cases *** Test 1.1 No Operation Directory Should Exist ${CURDIR} diff --git a/atest/testdata/parsing/library_caching/file2.robot b/atest/testdata/parsing/library_caching/file2.robot index 5a174beabb9..db1a784df11 100644 --- a/atest/testdata/parsing/library_caching/file2.robot +++ b/atest/testdata/parsing/library_caching/file2.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Library OperatingSystem Resource resource.robot -*** Test Case *** +*** Test Cases *** Test 2.1 No Operation Directory Should Exist ${CURDIR} diff --git a/atest/testdata/parsing/library_caching/resource.robot b/atest/testdata/parsing/library_caching/resource.robot index 1a4ab142ac2..a35c777cc2d 100644 --- a/atest/testdata/parsing/library_caching/resource.robot +++ b/atest/testdata/parsing/library_caching/resource.robot @@ -1,6 +1,6 @@ -*** Setting *** +*** Settings *** Library OperatingSystem -*** Keyword *** +*** Keywords *** Resource KW Directory Should Exist ${CURDIR} diff --git a/atest/testdata/parsing/resource_parsing/01_tests.robot b/atest/testdata/parsing/resource_parsing/01_tests.robot index c243ab2b3ab..6493977fec0 100644 --- a/atest/testdata/parsing/resource_parsing/01_tests.robot +++ b/atest/testdata/parsing/resource_parsing/01_tests.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Resource 02_resource.robot -*** Test Case *** +*** Test Cases *** Test 1.1 Keyword From 02 Resource Log ${var_from_02_resource} diff --git a/atest/testdata/parsing/resource_parsing/02_resource.robot b/atest/testdata/parsing/resource_parsing/02_resource.robot index 605c91045fa..2ec41051a2d 100644 --- a/atest/testdata/parsing/resource_parsing/02_resource.robot +++ b/atest/testdata/parsing/resource_parsing/02_resource.robot @@ -1,6 +1,6 @@ -*** Variable *** +*** Variables *** ${var_from_02_resource} variable value from 02 resource -*** Keyword *** +*** Keywords *** Keyword From 02 Resource Log ${var_from_02_resource} diff --git a/atest/testdata/parsing/resource_parsing/03_resource.robot b/atest/testdata/parsing/resource_parsing/03_resource.robot index ecbe5480fbc..d08908a580f 100644 --- a/atest/testdata/parsing/resource_parsing/03_resource.robot +++ b/atest/testdata/parsing/resource_parsing/03_resource.robot @@ -1,10 +1,10 @@ -*** Setting *** +*** Settings *** Resource 02_resource.robot -*** Variable *** +*** Variables *** ${var_from_03_resource} variable value from 03 resource -*** Keyword *** +*** Keywords *** Keyword From 03 Resource Log ${var_from_03_resource} Log ${var_from_02_resource} diff --git a/atest/testdata/parsing/resource_parsing/04_tests.robot b/atest/testdata/parsing/resource_parsing/04_tests.robot index 639bb660100..550d60d07b6 100644 --- a/atest/testdata/parsing/resource_parsing/04_tests.robot +++ b/atest/testdata/parsing/resource_parsing/04_tests.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Resource 03_resource.robot Resource 02_resource.robot -*** Test Case *** +*** Test Cases *** Test 4.1 Keyword From 02 Resource Log ${var_from_02_resource} diff --git a/atest/testdata/parsing/utf8_data.robot b/atest/testdata/parsing/utf8_data.robot index 45163f0e363..90e6b30c331 100644 --- a/atest/testdata/parsing/utf8_data.robot +++ b/atest/testdata/parsing/utf8_data.robot @@ -1,16 +1,16 @@ U T F 8 D A T A -***Setting*** +*** Settings *** Documentation Testing that reading and writing of Unicode (äöå §½€ etc.) works properly. Default Tags tag-äöå Force Tags tag-§ MetaData Ä § -| *Variable* | *Value* | +| * Variables * | *Value* | | ${UNICODE} | äöå §½€ | | ${UTF NAME öäå §½€} | value | -**Test Cases*** +*** Test Cases *** UTF-8 [Documentation] äöå §½€ [Setup] Log äöå [Tags] tag-€ diff --git a/atest/testdata/parsing/utf8_data.tsv b/atest/testdata/parsing/utf8_data.tsv index cc454be1758..376863da763 100644 --- a/atest/testdata/parsing/utf8_data.tsv +++ b/atest/testdata/parsing/utf8_data.tsv @@ -1,18 +1,18 @@ U T F 8 D A T A -*Setting* *Value* *Value* *Value* *Value* *Value* *Value* *Value* +*Settings* *Value* *Value* *Value* *Value* *Value* *Value* *Value* Documentation Testing that reading and writing of Unicode (äöå §½€ etc.) works properly. Default Tags tag-äöå Force Tags tag-§ Metadata Ä § -*Variable* *Value* *Value* *Value* *Value* *Value* *Value* *Value* +*Variables* *Value* *Value* *Value* *Value* *Value* *Value* *Value* ${UNICODE} äöå §½€ ${UTF NAME öäå §½€} value -*Test Case* *Action* *Argument* *Argument* *Argument* *Argument* *Argument* *Argument* +*Test Cases* *Action* *Argument* *Argument* *Argument* *Argument* *Argument* *Argument* UTF-8 [Documentation] äöå §½€ [Setup] Log äöå [Tags] tag-€ @@ -29,7 +29,7 @@ UTF-8 Name Äöå §½€" [Documentation] Quote is actually plain ASCII but the Log ${UTF NAME öäå §½€} Fail Virheessäkin on ääkkösiä: Äöå §½€" -*Keyword* *Action* *Argument* *Argument* *Argument* *Argument* *Argument* *Argument* +*Keywords* *Action* *Argument* *Argument* *Argument* *Argument* *Argument* *Argument* Logging Keyword [Arguments] ${value} Log ${value} Log ${UNICODE} diff --git a/atest/testdata/rebot/merge_html.robot b/atest/testdata/rebot/merge_html.robot index bb3786204a8..98d05aa89f2 100644 --- a/atest/testdata/rebot/merge_html.robot +++ b/atest/testdata/rebot/merge_html.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation Merge test cases for test doc HTML formatting *** Variables *** @@ -6,7 +6,7 @@ ${USE_HTML} ${false} ${TEXT MESSAGE} Test message ${HTML MESSAGE} *HTML* <b>Test</b> message -*** Test Case *** +*** Test Cases *** Html1 Set Test Documentation FAIL ${TEXT MESSAGE} Fail ${TEXT MESSAGE} diff --git a/atest/testdata/running/duplicate_test_name.robot b/atest/testdata/running/duplicate_test_name.robot index 2e5bf4807c8..503aa78ebc4 100644 --- a/atest/testdata/running/duplicate_test_name.robot +++ b/atest/testdata/running/duplicate_test_name.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** Same Test Multiple Times No Operation diff --git a/atest/testdata/running/for/continue_for_loop.robot b/atest/testdata/running/for/continue_for_loop.robot index 4f770be5c47..7a41042e023 100644 --- a/atest/testdata/running/for/continue_for_loop.robot +++ b/atest/testdata/running/for/continue_for_loop.robot @@ -120,7 +120,7 @@ With Continuable Failure In User Keyword Should Be Equal ${var} ö Fail The End -*** Keyword *** +*** Keywords *** With Loop FOR ${var} IN one two Continue For Loop diff --git a/atest/testdata/running/for/exit_for_loop.robot b/atest/testdata/running/for/exit_for_loop.robot index 4870c3e74eb..f55ceb05a1e 100644 --- a/atest/testdata/running/for/exit_for_loop.robot +++ b/atest/testdata/running/for/exit_for_loop.robot @@ -121,7 +121,7 @@ With Continuable Failure In User Keyword Should Be Equal ${var} ä Fail The End -*** Keyword *** +*** Keywords *** With Loop FOR ${var} IN one two Exit For Loop diff --git a/atest/testdata/running/if/inline_if_else.robot b/atest/testdata/running/if/inline_if_else.robot index 5a3de9397af..1b8878de95a 100644 --- a/atest/testdata/running/if/inline_if_else.robot +++ b/atest/testdata/running/if/inline_if_else.robot @@ -1,4 +1,4 @@ -*** Variable *** +*** Variables *** &{dict} *** Test Cases *** diff --git a/atest/testdata/running/stopping_with_signal/keyword_timeout.robot b/atest/testdata/running/stopping_with_signal/keyword_timeout.robot index 16aa0e3fc45..5531ca062aa 100644 --- a/atest/testdata/running/stopping_with_signal/keyword_timeout.robot +++ b/atest/testdata/running/stopping_with_signal/keyword_timeout.robot @@ -3,7 +3,7 @@ Suite Teardown Sleep ${TEARDOWN SLEEP} Library Library.py Library OperatingSystem -*** Test Case *** +*** Test Cases *** Test [Documentation] FAIL Execution terminated by signal Create File ${TESTSIGNALFILE} diff --git a/atest/testdata/running/stopping_with_signal/run_keyword.robot b/atest/testdata/running/stopping_with_signal/run_keyword.robot index 4ac64e2240c..5d30976ef43 100644 --- a/atest/testdata/running/stopping_with_signal/run_keyword.robot +++ b/atest/testdata/running/stopping_with_signal/run_keyword.robot @@ -3,7 +3,7 @@ Suite Teardown Sleep ${TEARDOWN SLEEP} Library Library.py Library OperatingSystem -*** Test Case *** +*** Test Cases *** Test [Documentation] FAIL Execution terminated by signal Create File ${TESTSIGNALFILE} diff --git a/atest/testdata/running/stopping_with_signal/swallow_exception.robot b/atest/testdata/running/stopping_with_signal/swallow_exception.robot index 3d5a9a212c3..286c4abcb02 100644 --- a/atest/testdata/running/stopping_with_signal/swallow_exception.robot +++ b/atest/testdata/running/stopping_with_signal/swallow_exception.robot @@ -3,7 +3,7 @@ Suite Teardown Sleep ${TEARDOWN SLEEP} Library Library.py Library OperatingSystem -*** Test Case *** +*** Test Cases *** Test [Documentation] FAIL Execution terminated by signal Create File ${TESTSIGNALFILE} diff --git a/atest/testdata/running/stopping_with_signal/test_timeout.robot b/atest/testdata/running/stopping_with_signal/test_timeout.robot index f23dcbb51e2..716d882b031 100644 --- a/atest/testdata/running/stopping_with_signal/test_timeout.robot +++ b/atest/testdata/running/stopping_with_signal/test_timeout.robot @@ -3,7 +3,7 @@ Suite Teardown Sleep ${TEARDOWN SLEEP} Library Library.py Library OperatingSystem -*** Test Case *** +*** Test Cases *** Test [Documentation] FAIL Execution terminated by signal [Timeout] 3 Seconds diff --git a/atest/testdata/running/stopping_with_signal/with_teardown.robot b/atest/testdata/running/stopping_with_signal/with_teardown.robot index e920c62fbe9..9abb430493e 100644 --- a/atest/testdata/running/stopping_with_signal/with_teardown.robot +++ b/atest/testdata/running/stopping_with_signal/with_teardown.robot @@ -3,7 +3,7 @@ Suite Teardown My Suite Teardown Library Library.py Library OperatingSystem -*** Test Case *** +*** Test Cases *** Test [Documentation] FAIL Execution terminated by signal Create File ${TESTSIGNALFILE} diff --git a/atest/testdata/running/stopping_with_signal/without_any_timeout.robot b/atest/testdata/running/stopping_with_signal/without_any_timeout.robot index 7b54727e7cb..4fbec2e2e4c 100644 --- a/atest/testdata/running/stopping_with_signal/without_any_timeout.robot +++ b/atest/testdata/running/stopping_with_signal/without_any_timeout.robot @@ -3,7 +3,7 @@ Suite Teardown Sleep ${TEARDOWN SLEEP} Library Library.py Library OperatingSystem -*** Test Case *** +*** Test Cases *** Test [Documentation] FAIL Execution terminated by signal Create File ${TESTSIGNALFILE} diff --git a/atest/testdata/running/test_case_status.robot b/atest/testdata/running/test_case_status.robot index 48a7355a801..3bcfa2cc993 100644 --- a/atest/testdata/running/test_case_status.robot +++ b/atest/testdata/running/test_case_status.robot @@ -1,7 +1,7 @@ *** Settings *** Library StandardExceptions.py -*** Test Case *** +*** Test Cases *** Test Passes [Documentation] PASS No Operation @@ -95,6 +95,6 @@ robot.api.Error with HTML message [Documentation] FAIL *HTML* <b>BANG!</b> Error <b>BANG!</b> True -*** Keyword *** +*** Keywords *** Do Nothing No operation diff --git a/atest/testdata/standard_libraries/builtin/log_variables.robot b/atest/testdata/standard_libraries/builtin/log_variables.robot index 77b8c898ac9..7e18cd02cf6 100644 --- a/atest/testdata/standard_libraries/builtin/log_variables.robot +++ b/atest/testdata/standard_libraries/builtin/log_variables.robot @@ -1,12 +1,12 @@ -*** Setting *** +*** Settings *** Suite Setup My Suite Setup -*** Variable *** +*** Variables *** @{LIST} Hello world ${SCALAR} Hi tellus &{DICT} key=value two=${2} -*** Test Case *** +*** Test Cases *** Previous Test No Operation @@ -25,7 +25,7 @@ List and dict variables failing during iteration Log Variables Log Many ${BROKEN ITERABLE} ${BROKEN SEQUENCE} ${BROKEN MAPPING} -*** Keyword *** +*** Keywords *** My Suite Setup ${suite_setup_local_var} = Set Variable Variable available only locally in suite setup Set Suite Variable $suite_setup_suite_var Suite var set in suite setup diff --git a/atest/testdata/standard_libraries/builtin/replace_variables.robot b/atest/testdata/standard_libraries/builtin/replace_variables.robot index 97d9eeb2917..deb7ad7c1dd 100644 --- a/atest/testdata/standard_libraries/builtin/replace_variables.robot +++ b/atest/testdata/standard_libraries/builtin/replace_variables.robot @@ -1,10 +1,10 @@ -*** Setting *** +*** Settings *** Library OperatingSystem -*** Variable *** +*** Variables *** @{LIST} Hello world -*** Test Case *** +*** Test Cases *** Replace Variables ${template} = Get File ${CURDIR}${/}template.txt Replace Variables And Verify Content ${template} Pekka fine morning @@ -49,7 +49,7 @@ Replace Variables With List Variable Should Be Equal ${replaced}[2] xxx Should Be Equal ${replaced}[3] ${LIST} -*** Keyword *** +*** Keywords *** Replace Variables And Verify Content [Arguments] ${template} ${name} @{occasion} ${replaced} = Replace Variables ${template} diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_critical_tests_passed_when_critical_fails.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_critical_tests_passed_when_critical_fails.robot index 6775671f848..47edcc46ddb 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_critical_tests_passed_when_critical_fails.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_critical_tests_passed_when_critical_fails.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Teardown Run Keyword If All Critical Tests Passed Fail ${NON EXISTING} #Should not be executed nor evaluated Default Tags critical -*** Test Case *** +*** Test Cases *** Run Keyword If All Critical Tests Passed Is not executed when Critcal Test Fails [Documentation] FAIL Expected failure Fail Expected failure diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_critical_tests_passed_when_criticals_pass.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_critical_tests_passed_when_criticals_pass.robot index d4cd7a5d268..f92a798bb48 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_critical_tests_passed_when_criticals_pass.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_critical_tests_passed_when_criticals_pass.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Suite Teardown Run Keyword If All Critical Tests Passed My Teardown Default Tags critical -*** Variable *** +*** Variables *** ${MESSAGE} Suite teardown message -*** Test Case *** +*** Test Cases *** Passing Critical Test No Operation @@ -16,6 +16,6 @@ Failing non-critical Test [Tags] non-critical Fail -*** Keyword *** +*** Keywords *** My Teardown Log ${MESSAGE} diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_tests_passed_when_all_pass.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_tests_passed_when_all_pass.robot index 4a667c13156..79f738afa9e 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_tests_passed_when_all_pass.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_tests_passed_when_all_pass.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Suite Teardown Run Keyword If All Tests Passed My Teardown Default Tags critical -*** Variable *** +*** Variables *** ${MESSAGE} Suite teardown message -*** Test Case *** +*** Test Cases *** Passing Critical No Operation @@ -13,6 +13,6 @@ Passing Non-critical [Tags] non-critical No Operation -*** Keyword *** +*** Keywords *** My Teardown Log ${MESSAGE} diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_tests_passed_when_test_fails.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_tests_passed_when_test_fails.robot index f667188c8a1..54c1f75028c 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_tests_passed_when_test_fails.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_all_tests_passed_when_test_fails.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Suite Teardown Run Keyword If All Tests Passed Fail ${NON EXISTING} #Should not be executed nor evaluated -*** Test Case *** +*** Test Cases *** Run Keyword If All tests Passed Is not Executed When Any Test Fails [Documentation] FAIL Expected failure Fail Expected failure diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_critical_tests_failed_when_critical_fails.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_critical_tests_failed_when_critical_fails.robot index 8ece0f01bb1..ca07866ab85 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_critical_tests_failed_when_critical_fails.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_critical_tests_failed_when_critical_fails.robot @@ -1,14 +1,14 @@ -*** Setting *** +*** Settings *** Suite Teardown Run Keyword If Any Critical Tests Failed My Teardown Default Tags critical -*** Variable *** +*** Variables *** ${MESSAGE} Suite teardown message -*** Test Case *** +*** Test Cases *** Failing Critical test Fail Expected failure -*** Keyword *** +*** Keywords *** My Teardown Log ${MESSAGE} diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_critical_tests_failed_when_criticals_pass.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_critical_tests_failed_when_criticals_pass.robot index 533e103b56a..e1a3ae934a9 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_critical_tests_failed_when_criticals_pass.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_critical_tests_failed_when_criticals_pass.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Suite Teardown Run Keyword If Any Critical Tests Failed Fail ${NON EXISTING} #Should not be executed nor evaluated Default Tags critical -*** Test Case *** +*** Test Cases *** Run Keyword If Any Critical Tests failed Is not executed when All Critcal Tests Pass No Operation diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_tests_failed_when_all_pass.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_tests_failed_when_all_pass.robot index 9093851dd42..f875784b037 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_tests_failed_when_all_pass.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_tests_failed_when_all_pass.robot @@ -1,6 +1,6 @@ -*** Setting *** +*** Settings *** Suite Teardown Run Keyword If Any Tests Failed Fail ${NON EXISTING} #Should not be executed nor evaluated -*** Test Case *** +*** Test Cases *** Run Keyword If Any Tests failed Is not executed when All Tests Pass No Operation diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_tests_failed_when_test_fails.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_tests_failed_when_test_fails.robot index 8fdfb63b76c..dc5cbd30a9d 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_tests_failed_when_test_fails.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_keyword_if_any_tests_failed_when_test_fails.robot @@ -1,13 +1,13 @@ -*** Setting *** +*** Settings *** Suite Teardown Run Keyword If Any Tests Failed My Teardown -*** Variable *** +*** Variables *** ${MESSAGE} Suite teardown message -*** Test Case *** +*** Test Cases *** Failing Non Critical test Fail Expected failure -*** Keyword *** +*** Keywords *** My Teardown Log ${MESSAGE} diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_kw_variants_used_outside_suite_teardown.robot b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_kw_variants_used_outside_suite_teardown.robot index a077311e8b0..fc3931a3a31 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_kw_variants_used_outside_suite_teardown.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_based_on_suite_stats/run_kw_variants_used_outside_suite_teardown.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** Run Keyword If All Critical Tests Passed Can't be Used In Test [Documentation] FAIL Keyword 'Run Keyword If All Critical Tests Passed' can only be used in suite teardown. Run Keyword If All Critical Tests Passed Fail ${NON EXISTING} #Should not be executed nor evaluated diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_failed_in_suite_fixtures.robot b/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_failed_in_suite_fixtures.robot index 9211ad13b14..d91db17aea0 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_failed_in_suite_fixtures.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_failed_in_suite_fixtures.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Keyword If Test Failed Fail ${NON EXISTING} Suite Teardown Run Keyword If Test Failed Fail ${NON EXISTING} -*** Test Case *** +*** Test Cases *** Run Keyword If test Failed Can't Be Used In Suite Setup or Teardown [Documentation] FAIL Parent suite setup failed: ... Keyword 'Run Keyword If Test Failed' can only be used in test teardown. diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_passed_failed.robot b/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_passed_failed.robot index 85687787a1a..557fefe0e04 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_passed_failed.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_passed_failed.robot @@ -1,8 +1,8 @@ -*** Variable *** +*** Variables *** ${EXPECTED FAILURE} Expected failure ${TEARDOWN MESSAGE} Teardown message -*** Test Case *** +*** Test Cases *** Run Keyword If Test Failed when test fails [Documentation] FAIL Expected failure Fail ${EXPECTED FAILURE} @@ -144,7 +144,7 @@ Continuable Failure In Teardown No Operation [Teardown] Continuable Failure In Teardown -*** Keyword *** +*** Keywords *** Run Keyword If Test Failed in user keyword Log Want to have some keyword before Run Keyword If Test Failed Run Keyword If Test Failed Fail Apparently test failed! diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_passed_in_suite_fixtures.robot b/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_passed_in_suite_fixtures.robot index 7173815da52..178753eb5ef 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_passed_in_suite_fixtures.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_if_test_passed_failed/run_keyword_if_test_passed_in_suite_fixtures.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Run Keyword If Test Passed Fail ${NON EXISTING} Suite Teardown Run Keyword If Test Passed Fail ${NON EXISTING} -*** Test Case *** +*** Test Cases *** Run Keyword If test Passed Can't Be Used In Suite Setup or Teardown [Documentation] FAIL ... Parent suite setup failed: diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_if_unless.robot b/atest/testdata/standard_libraries/builtin/run_keyword_if_unless.robot index 2430ed0c663..c293e318c9c 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_if_unless.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_if_unless.robot @@ -1,4 +1,4 @@ -*** Variable *** +*** Variables *** ${EXECUTED} This is executed @{ARGS WITH ELSE} ELSE ${EXECUTED} @{ARGS WITH ELSE IF} ELSE IF ${EXECUTED} @@ -8,7 +8,7 @@ ${FAIL} Fail @{EXPR AND CATENATE} True Catenate \${foo} ELSE IF zig ELSE bar -*** Test Case *** +*** Test Cases *** Run Keyword If With True Expression Run Keyword If ${True} Log ${EXECUTED} @@ -177,7 +177,7 @@ Run Keyword Unless With True Expression Run Keyword Unless ${0} == ${0} Log ${NON EXISTING} -*** Keyword *** +*** Keywords *** Conditional User Keyword [Arguments] ${status} ${message} Run Keyword If '${status}' == 'PASS' Log ${message} diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_variants_registering.robot b/atest/testdata/standard_libraries/builtin/run_keyword_variants_registering.robot index 25337d75a38..99d0e399042 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_variants_registering.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_variants_registering.robot @@ -1,16 +1,16 @@ -*** Setting *** +*** Settings *** Library RegisteringLibrary.py Library NotRegisteringLibrary.py Library RegisteringLibrary.py WITH NAME lib Library RegisteredClass.py Library DynamicRegisteredLibrary.py -*** Variable *** +*** Variables *** ${VARIABLE} \${not variable} ${HELLO} Hello @{KEYWORD AND ARG} \\Log Many ${VARIABLE} -*** Test Case *** +*** Test Cases *** Not registered Keyword Fails With Content That Should Not Be Evaluated Twice [Documentation] FAIL STARTS: Variable '\${not variable}' not found. ${var} = Set Variable \${not variable} @@ -37,7 +37,7 @@ Registered Keyword With With Name Registered Keyword From Dynamic Library Dynamic Run Keyword @{KEYWORD AND ARG} -*** Keyword *** +*** Keywords *** \Log Many [Arguments] @{args} Log Many @{args} diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_variants_variable_handling.robot b/atest/testdata/standard_libraries/builtin/run_keyword_variants_variable_handling.robot index c6fa21cecf7..f665e174ac0 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_variants_variable_handling.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_variants_variable_handling.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Library RegisteringLibrary.py Variables variable.py -*** Variable *** +*** Variables *** @{NEEDS ESCAPING} c:\\temp\\foo \${notvar} @{KEYWORD AND ARG WHICH NEEDS ESCAPING} \\Log Many \${notvar} @{KEYWORD AND ARGS WHICH NEEDS ESCAPING} \\Log Many @{NEEDS ESCAPING} @@ -11,7 +11,7 @@ Variables variable.py ${KEYWORD} \\Log Many @{KEYWORD LIST} ${KEYWORD} -*** Test Case *** +*** Test Cases *** Variable Values Should Not Be Visible As Keyword's Arguments Run Keyword My UK Log ${OBJECT} @@ -55,7 +55,7 @@ Run Keyword If With List And Two Arguments That needs to Be Processed Run Keyword If With List And One Argument That needs to Be Processed Run Keyword If @{EXPRESSION} \\Log Many @{ARGS} -*** Keyword *** +*** Keywords *** My UK [Arguments] ${name} @{args} Run Keyword ${name} @{args} diff --git a/atest/testdata/standard_libraries/builtin/setting_variables/variables.robot b/atest/testdata/standard_libraries/builtin/setting_variables/variables.robot index 8dc622d13dd..b274233b400 100644 --- a/atest/testdata/standard_libraries/builtin/setting_variables/variables.robot +++ b/atest/testdata/standard_libraries/builtin/setting_variables/variables.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Documentation See also variables2.robot Suite Setup My Suite Setup Suite Teardown My Suite Teardown Library OperatingSystem Library Collections -*** Variable *** +*** Variables *** ${SCALAR} Hi tellus @{LIST} Hello world &{DICT} key=value foo=bar @@ -14,7 +14,7 @@ ${SCALAR LIST ERROR} ... Setting list value to scalar variable '\${SCALAR}' is not ... supported anymore. Create list variable '\@{SCALAR}' instead. -*** Test Case *** +*** Test Cases *** Set Variable ${var} = Set Variable Hello Should Be Equal ${var} Hello @@ -539,7 +539,7 @@ Setting scalar global variable with list value is not possible 2 [Documentation] FAIL ${SCALAR LIST ERROR} Set Global Variable ${SCALAR} @{EMPTY} -*** Keyword *** +*** Keywords *** My Suite Setup ${suite_setup_local_var} = Set Variable Variable available only locally in suite setup Set Suite Variable $suite_setup_suite_var Suite var set in suite setup diff --git a/atest/testdata/standard_libraries/builtin/setting_variables/variables2.robot b/atest/testdata/standard_libraries/builtin/setting_variables/variables2.robot index 2dac850b1d3..a3a327233f1 100644 --- a/atest/testdata/standard_libraries/builtin/setting_variables/variables2.robot +++ b/atest/testdata/standard_libraries/builtin/setting_variables/variables2.robot @@ -3,7 +3,7 @@ ${VARIABLE TABLE IN VARIABLES 2 (1)} Initial value ${VARIABLE TABLE IN VARIABLES 2 (2)} Initial value ${VARIABLE TABLE IN VARIABLES 2 (3)} Initial value -*** Test Case *** +*** Test Cases *** Test Variables Set In One Suite Are Not Available In Another [Documentation] Also checks that variables created in the variable table of the other suite are not available here. Variable Should Not Exist $new_var diff --git a/atest/testdata/standard_libraries/builtin/tags/__init__.robot b/atest/testdata/standard_libraries/builtin/tags/__init__.robot index a80112262af..3fbd08c5eda 100644 --- a/atest/testdata/standard_libraries/builtin/tags/__init__.robot +++ b/atest/testdata/standard_libraries/builtin/tags/__init__.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Suite Setup Set And Remove Tags Force Tags force-init remove-me-please -*** Keyword *** +*** Keywords *** Set And Remove Tags Set Tags set-init remove-me-too Remove Tags remove-me-* diff --git a/atest/testdata/standard_libraries/builtin/tags/sub1.robot b/atest/testdata/standard_libraries/builtin/tags/sub1.robot index 298cc0af0e5..3daaa8f3471 100644 --- a/atest/testdata/standard_libraries/builtin/tags/sub1.robot +++ b/atest/testdata/standard_libraries/builtin/tags/sub1.robot @@ -1,13 +1,13 @@ -*** Setting *** +*** Settings *** Suite Setup Set And Remove Tags Force Tags force-remove-please force Default Tags default-remove-also default Library Collections -*** Variable *** +*** Variables *** @{SUITE_TAGS} default force force-init set set-init -*** Test Case *** +*** Test Cases *** Set And Remove Tags In Suite Level Should Be Equal ${TEST_TAGS} ${SUITE_TAGS} @@ -62,7 +62,7 @@ Set And Remove Tags In A User Keyword Set And Remove Tags In UK Should Be True ${TEST_TAGS} == ['tc','uk','uk2'] -*** Keyword *** +*** Keywords *** Set And Remove Tags Set Tags set set-REMOVE-this take this out too Remove Tags non-existing *-remove-* Take this out TOO diff --git a/atest/testdata/standard_libraries/builtin/tags/sub2.robot b/atest/testdata/standard_libraries/builtin/tags/sub2.robot index c29e3323eba..bbc86cacbe1 100644 --- a/atest/testdata/standard_libraries/builtin/tags/sub2.robot +++ b/atest/testdata/standard_libraries/builtin/tags/sub2.robot @@ -1,11 +1,11 @@ -*** Setting *** +*** Settings *** Suite Teardown Set Tags this-should-fail Library Collections -*** Variable *** +*** Variables *** ${ERROR} FAIL Parent suite teardown failed:\n'Set Tags' cannot be used in suite teardown. -*** Test Case *** +*** Test Cases *** Set Tags In Test Setup [Documentation] ${ERROR} [Tags] tag @@ -29,7 +29,7 @@ Modifying ${TEST TAGS} after removing them has no affect on tags test has Append To List ${TEST TAGS} not really added Should Be True ${TEST TAGS} == ['not really added'] -*** Keyword *** +*** Keywords *** Set And Remove Tags [Arguments] @{set} Set Tags @{set} diff --git a/atest/testdata/standard_libraries/dialogs/dialogs.robot b/atest/testdata/standard_libraries/dialogs/dialogs.robot index d667fa5ef0e..c25713fb49b 100644 --- a/atest/testdata/standard_libraries/dialogs/dialogs.robot +++ b/atest/testdata/standard_libraries/dialogs/dialogs.robot @@ -3,7 +3,7 @@ Library Dialogs Library Collections Test Tags manual no-ci -*** Variable *** +*** Variables *** ${FILLER} = Wräp < & シ${SPACE} *** Test Cases *** diff --git a/atest/testdata/standard_libraries/operating_system/env_vars.robot b/atest/testdata/standard_libraries/operating_system/env_vars.robot index d164b60d2be..adb57908290 100644 --- a/atest/testdata/standard_libraries/operating_system/env_vars.robot +++ b/atest/testdata/standard_libraries/operating_system/env_vars.robot @@ -1,15 +1,15 @@ -*** Setting *** +*** Settings *** Suite Setup Remove Environment Variable ${NAME} Test Teardown Remove Environment Variable ${NAME} Library OperatingSystem Library files/HelperLib.py -*** Variable *** +*** Variables *** ${NAME} EXAMPLE_ENV_VAR_32FDHT ${NON STRING} ${2138791} ${NON ASCII} HYVÄÄ_YÖTÄ -*** Test Case *** +*** Test Cases *** Get Environment Variable [Documentation] FAIL Environment variable 'non_existing_2' does not exist. ${var} = Get Environment Variable PATH diff --git a/atest/testdata/standard_libraries/reserved.robot b/atest/testdata/standard_libraries/reserved.robot index 5696282997e..e6e83083019 100644 --- a/atest/testdata/standard_libraries/reserved.robot +++ b/atest/testdata/standard_libraries/reserved.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** Markers should get note about case 1 [Documentation] FAIL 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. For ${var} IN some items @@ -37,6 +37,6 @@ Reserved in user keyword [Documentation] FAIL 'While' is a reserved keyword. User keyword with reserved keyword -*** Keyword *** +*** Keywords *** User keyword with reserved keyword While diff --git a/atest/testdata/standard_libraries/telnet/configuration.robot b/atest/testdata/standard_libraries/telnet/configuration.robot index d4deca94819..b545ba0f921 100644 --- a/atest/testdata/standard_libraries/telnet/configuration.robot +++ b/atest/testdata/standard_libraries/telnet/configuration.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Test Setup Open Connection ${HOST} Test Teardown Close All Connections Library Telnet 3.142 CRLF $ False ASCII strict DeBuG window_size=95x95 terminal_emulation=NO diff --git a/atest/testdata/standard_libraries/telnet/connections.robot b/atest/testdata/standard_libraries/telnet/connections.robot index d8876d1880a..1247b135aa0 100644 --- a/atest/testdata/standard_libraries/telnet/connections.robot +++ b/atest/testdata/standard_libraries/telnet/connections.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Suite Teardown Close All Connections Library Telnet Resource telnet_resource.robot -*** Test Case *** +*** Test Cases *** Open Connection ${index} = Open Connection ${HOST} prompt=xxx Should Be Equal ${index} ${1} @@ -46,7 +46,7 @@ Switch Connection Current Directory Should Be /tmp -*** Keyword *** +*** Keywords *** Current Directory Should Be [Arguments] ${expected} ${dir} = Execute Command pwd diff --git a/atest/testdata/standard_libraries/telnet/login.robot b/atest/testdata/standard_libraries/telnet/login.robot index c171e107f89..e1a8c6d2280 100644 --- a/atest/testdata/standard_libraries/telnet/login.robot +++ b/atest/testdata/standard_libraries/telnet/login.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Test Teardown Close All Connections Library Telnet Resource telnet_resource.robot -*** Test Case *** +*** Test Cases *** Successful login without prompt Open Connection ${HOST} diff --git a/atest/testdata/standard_libraries/telnet/read_and_write.robot b/atest/testdata/standard_libraries/telnet/read_and_write.robot index a1c6707cf31..182ba6d4848 100644 --- a/atest/testdata/standard_libraries/telnet/read_and_write.robot +++ b/atest/testdata/standard_libraries/telnet/read_and_write.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Test Setup Login And Set Prompt Test Teardown Close All Connections Library Telnet newline=CRLF @@ -8,7 +8,7 @@ Resource telnet_resource.robot *** Variables *** ${TIMEOUT} 300 milliseconds -*** Test Case *** +*** Test Cases *** Write & Read ${text} = Write pwd Should Be Equal ${text} pwd\r\n diff --git a/atest/testdata/standard_libraries/telnet/telnet_resource.robot b/atest/testdata/standard_libraries/telnet/telnet_resource.robot index 81437021355..e97b8030a18 100644 --- a/atest/testdata/standard_libraries/telnet/telnet_resource.robot +++ b/atest/testdata/standard_libraries/telnet/telnet_resource.robot @@ -1,7 +1,7 @@ *** Settings *** Variables telnet_variables.py -*** Keyword *** +*** Keywords *** Login and set prompt [Arguments] ${alias}=${NONE} ${encoding}=${NONE} ${terminal_emulation}=${NONE} ${window_size}=${NONE} ${terminal_type}=${NONE} ${index} = Open Connection ${HOST} prompt=${PROMPT} diff --git a/atest/testdata/standard_libraries/telnet/terminal_emulation.robot b/atest/testdata/standard_libraries/telnet/terminal_emulation.robot index e990ddd91ac..60753bc19df 100644 --- a/atest/testdata/standard_libraries/telnet/terminal_emulation.robot +++ b/atest/testdata/standard_libraries/telnet/terminal_emulation.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Test Setup Login and set prompt Test Teardown Close All Connections Library Telnet 3.142 CRLF $ REGEXP ASCII strict DeBuG terminal_emulation=yes terminal_type=vt100 diff --git a/atest/testdata/tags/default_and_force_tags.robot b/atest/testdata/tags/default_and_force_tags.robot index a6d19686dc9..2dfdac0b0ac 100644 --- a/atest/testdata/tags/default_and_force_tags.robot +++ b/atest/testdata/tags/default_and_force_tags.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Force Tags 01 ${EMPTY} 02 @{EMPTY} Default Tags @{DEFAULTS} @@ -6,7 +6,7 @@ Default Tags @{DEFAULTS} @{DEFAULTS} 03 ${EMPTY} four -*** Test Case *** +*** Test Cases *** No Own Tags No Operation diff --git a/atest/testdata/tags/default_tags.robot b/atest/testdata/tags/default_tags.robot index 76b36d7139b..f12d407a0e7 100644 --- a/atest/testdata/tags/default_tags.robot +++ b/atest/testdata/tags/default_tags.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Default Tags 03 four -*** Test Case *** +*** Test Cases *** No Own Tags With Default Tags No Operation diff --git a/atest/testdata/tags/force_tags.robot b/atest/testdata/tags/force_tags.robot index 56df1d738d6..6104d60fe8a 100644 --- a/atest/testdata/tags/force_tags.robot +++ b/atest/testdata/tags/force_tags.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Force Tags 01 02 -*** Test Case *** +*** Test Cases *** No Own Tags With Force Tags No Operation diff --git a/atest/testdata/tags/no_force_no_default_tags.robot b/atest/testdata/tags/no_force_no_default_tags.robot index 1f481359447..081b14e09c9 100644 --- a/atest/testdata/tags/no_force_no_default_tags.robot +++ b/atest/testdata/tags/no_force_no_default_tags.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** No Own Tags No Force Nor Default No Operation diff --git a/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot b/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot index c27ecfc2c3b..c1cf2bfb5d4 100644 --- a/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot +++ b/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Suite Setup Keyword Library AvoidProperties.py Test Template Attribute value should be -*** Test Case *** +*** Test Cases *** Property normal_property 1 diff --git a/atest/testdata/test_libraries/deprecated_keywords.robot b/atest/testdata/test_libraries/deprecated_keywords.robot index 0e3408475f6..f336928d4d0 100644 --- a/atest/testdata/test_libraries/deprecated_keywords.robot +++ b/atest/testdata/test_libraries/deprecated_keywords.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Library DeprecatedKeywords.py -*** Test Case *** +*** Test Cases *** Deprecated keywords Deprecated Library Keyword Deprecated User Keyword @@ -30,7 +30,7 @@ Not deprecated keywords Not Deprecated User Keyword Without Documentation Not Deprecated User Keyword With `*Deprecated` Prefix -*** Keyword *** +*** Keywords *** Deprecated User Keyword [Documentation] *DEPRECATED* Use keyword `Not Deprecated User Keyword` instead. No Operation diff --git a/atest/testdata/test_libraries/error_msg_and_details.robot b/atest/testdata/test_libraries/error_msg_and_details.robot index f24c44a18af..7c260d5be3e 100644 --- a/atest/testdata/test_libraries/error_msg_and_details.robot +++ b/atest/testdata/test_libraries/error_msg_and_details.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Library ExampleLibrary Library nön_äscii_dïr/valid.py -*** Test Case *** +*** Test Cases *** Generic Failure [Documentation] FAIL foo != bar Exception AssertionError foo != bar diff --git a/atest/testdata/test_libraries/internal_modules_not_importable.robot b/atest/testdata/test_libraries/internal_modules_not_importable.robot index 970a2af888f..64047775c6d 100644 --- a/atest/testdata/test_libraries/internal_modules_not_importable.robot +++ b/atest/testdata/test_libraries/internal_modules_not_importable.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Library ImportRobotModuleTestLibrary.py Library OperatingSystem -*** Test Case *** +*** Test Cases *** Internal modules cannot be imported directly Importing Robot Module Directly Fails diff --git a/atest/testdata/test_libraries/library_import_from_archive.robot b/atest/testdata/test_libraries/library_import_from_archive.robot index 1141202811c..52016159b5f 100644 --- a/atest/testdata/test_libraries/library_import_from_archive.robot +++ b/atest/testdata/test_libraries/library_import_from_archive.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Library ZipLib -*** Test Case *** +*** Test Cases *** Python Library From a Zip File ${ret} = Kw From Zip ${4} Should Be Equal ${ret} ${8} diff --git a/atest/testdata/test_libraries/library_scope/01_tests.robot b/atest/testdata/test_libraries/library_scope/01_tests.robot index 8c76c0d9143..492b8021bcb 100644 --- a/atest/testdata/test_libraries/library_scope/01_tests.robot +++ b/atest/testdata/test_libraries/library_scope/01_tests.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Suite Setup My Setup Suite Teardown My Teardown Resource resource.robot -*** Test Case *** +*** Test Cases *** Test 1.1 Register All Test 1.1 libraryscope.Global.Should Be Registered Suite 0 Suite 1 Test 1.1 @@ -18,7 +18,7 @@ Test 1.2 libraryscope.Test.Should Be Registered Test 1.2 Invalids Should Have Registered Test 1.2 -*** Keyword *** +*** Keywords *** My Setup Register All Suite 1 libraryscope.Global.Should Be Registered Suite 0 Suite 1 diff --git a/atest/testdata/test_libraries/library_scope/02_tests.robot b/atest/testdata/test_libraries/library_scope/02_tests.robot index b6e40f393e4..72f18345750 100644 --- a/atest/testdata/test_libraries/library_scope/02_tests.robot +++ b/atest/testdata/test_libraries/library_scope/02_tests.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Suite Setup My Setup Suite Teardown My Teardown Resource resource.robot -*** Test Case *** +*** Test Cases *** Test 2.1 Register All Test 2.1 libraryscope.Global.Should Be Registered Suite 0 Suite 1 Test 1.1 Test 1.2 Suite 2 Test 2.1 @@ -19,7 +19,7 @@ Test 2.2 libraryscope.Test.Should Be Registered Test 2.2 Invalids Should Have Registered Test 2.2 -*** Keyword *** +*** Keywords *** My Setup Register All Suite 2 libraryscope.Global.Should Be Registered Suite 0 Suite 1 Test 1.1 Test 1.2 Suite 2 diff --git a/atest/testdata/test_libraries/library_scope/__init__.robot b/atest/testdata/test_libraries/library_scope/__init__.robot index 1adbca6b9bf..537236838eb 100644 --- a/atest/testdata/test_libraries/library_scope/__init__.robot +++ b/atest/testdata/test_libraries/library_scope/__init__.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Suite Setup My Setup Suite Teardown My Teardown Resource resource.robot -*** Keyword *** +*** Keywords *** My Setup Register All Suite 0 libraryscope.Global.Should Be Registered Suite 0 diff --git a/atest/testdata/test_libraries/library_scope/resource.robot b/atest/testdata/test_libraries/library_scope/resource.robot index b183cd8516d..3eefc0ddcba 100644 --- a/atest/testdata/test_libraries/library_scope/resource.robot +++ b/atest/testdata/test_libraries/library_scope/resource.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Library libraryscope.Global Library libraryscope.Suite Library libraryscope.Test @@ -7,7 +7,7 @@ Library libraryscope.InvalidEmpty Library libraryscope.InvalidMethod Library libraryscope.InvalidNone -*** Keyword *** +*** Keywords *** Register All [Arguments] ${name} libraryscope.Global.Register ${name} diff --git a/atest/testdata/test_libraries/library_version.robot b/atest/testdata/test_libraries/library_version.robot index 53282804a57..d2a1bb5df8d 100644 --- a/atest/testdata/test_libraries/library_version.robot +++ b/atest/testdata/test_libraries/library_version.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Documentation This test data exists solely to test library version information in syslog messages of library imports Library classes.VersionLibrary Library classes.NameLibrary Library module_library -*** Test Case *** +*** Test Cases *** Test No Operation diff --git a/atest/testdata/test_libraries/new_style_classes.robot b/atest/testdata/test_libraries/new_style_classes.robot index 6391a2f8fc6..8b629257837 100644 --- a/atest/testdata/test_libraries/new_style_classes.robot +++ b/atest/testdata/test_libraries/new_style_classes.robot @@ -1,8 +1,8 @@ -*** Setting *** +*** Settings *** Library newstyleclasses.NewStyleClassLibrary Library newstyleclasses.MetaClassLibrary -*** Test Case *** +*** Test Cases *** Keyword From New Style Class Library ${ret} = Mirror Hello Should Be Equal ${ret} olleH diff --git a/atest/testdata/test_libraries/non_main_threads_logging.robot b/atest/testdata/test_libraries/non_main_threads_logging.robot index ab6714a0a81..02bf5b1af91 100644 --- a/atest/testdata/test_libraries/non_main_threads_logging.robot +++ b/atest/testdata/test_libraries/non_main_threads_logging.robot @@ -1,7 +1,7 @@ *** Settings *** Library ThreadLoggingLib.py -*** Test Case *** +*** Test Cases *** Log messages from non-main threads should be ignored Log using robot api in thread Log using logging module in thread diff --git a/atest/testdata/test_libraries/variables_for_library_import.robot b/atest/testdata/test_libraries/variables_for_library_import.robot index 59461edd506..e4556e6c524 100644 --- a/atest/testdata/test_libraries/variables_for_library_import.robot +++ b/atest/testdata/test_libraries/variables_for_library_import.robot @@ -1,4 +1,4 @@ -*** Variable *** +*** Variables *** ${OSLIB} OperatingSystem ${PARAM} Parameter @{ARGS} myhost 1000 diff --git a/atest/testdata/variables/automatic_variables/auto1.robot b/atest/testdata/variables/automatic_variables/auto1.robot index b5eae7c9c2b..1870863b7a5 100644 --- a/atest/testdata/variables/automatic_variables/auto1.robot +++ b/atest/testdata/variables/automatic_variables/auto1.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Documentation This is suite documentation. With ${VARIABLE}. Metadata MeTa1 Value Metadata meta2 ${VARIABLE} @@ -12,7 +12,7 @@ Library Collections Library HelperLib.py ${SUITE NAME} ${SUITE DOCUMENTATION} ... ${SUITE METADATA} ${SUITE SOURCE} ${OPTIONS} -*** Variable *** +*** Variables *** ${VARIABLE} variable value ${EXP_SUITE_NAME} Automatic Variables.Auto1 ${EXP_SUITE_DOC} This is suite documentation. With ${VARIABLE}. @@ -20,7 +20,7 @@ ${EXP_SUITE_META} {'MeTa1': 'Value', 'meta2': '${VARIABLE}'} ${EXP_SUITE_STATS} 17 tests, 15 passed, 2 failed @{LAST_TEST} \&{OPTIONS} PASS -*** Test Case *** +*** Test Cases *** Previous Test Variables Should Have Default Values Check Previous Test Variables diff --git a/atest/testdata/variables/automatic_variables/auto2.robot b/atest/testdata/variables/automatic_variables/auto2.robot index 90e3f475f68..4c9e90d1370 100644 --- a/atest/testdata/variables/automatic_variables/auto2.robot +++ b/atest/testdata/variables/automatic_variables/auto2.robot @@ -1,4 +1,4 @@ -*** Setting *** +*** Settings *** Suite Setup Check Variables In Suite Setup Automatic Variables.Auto2 ... ${EMPTY} {} @{PREV_TEST} Suite Teardown Check Variables In Suite Teardown Automatic Variables.Auto2 FAIL @@ -7,11 +7,11 @@ Suite Teardown Check Variables In Suite Teardown Automatic Variables.Auto2 Force Tags include this test Resource resource.robot -*** Variable *** +*** Variables *** @{PREV_TEST} \&{OPTIONS} PASS @{LAST_TEST} Previous Test Variables Should Have Default Values From Previous Suite FAIL Expected failure -*** Test Case *** +*** Test Cases *** Previous Test Variables Should Have Default Values From Previous Suite [Documentation] FAIL Expected failure Check Previous Test Variables @{PREV_TEST} diff --git a/atest/testdata/variables/automatic_variables/resource.robot b/atest/testdata/variables/automatic_variables/resource.robot index b5ec2152e12..21b239fb6f6 100644 --- a/atest/testdata/variables/automatic_variables/resource.robot +++ b/atest/testdata/variables/automatic_variables/resource.robot @@ -1,7 +1,7 @@ *** Settings *** Library Collections -*** Keyword *** +*** Keywords *** Check Variables In Suite Setup [Arguments] ${name} ${doc} ${meta} @{prev_test} Check Test Variables Do Not Exist diff --git a/atest/testdata/variables/commandline_variables.robot b/atest/testdata/variables/commandline_variables.robot index 018807084f1..fd6afaf8ac4 100644 --- a/atest/testdata/variables/commandline_variables.robot +++ b/atest/testdata/variables/commandline_variables.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** Normal Text Should Be Equal ${NORMAL TEXT} Hello diff --git a/atest/testdata/variables/reserved_syntax.robot b/atest/testdata/variables/reserved_syntax.robot index 7dbbc0ec4bd..860533d865b 100644 --- a/atest/testdata/variables/reserved_syntax.robot +++ b/atest/testdata/variables/reserved_syntax.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** Reserved Syntax *{var} ${*} = Set Variable * Should Be Equal *{this_causes_warning} ${*}{this_causes_warning} diff --git a/atest/testdata/variables/resvarfiles/resource.robot b/atest/testdata/variables/resvarfiles/resource.robot index db4843e23b2..1f935cb579b 100644 --- a/atest/testdata/variables/resvarfiles/resource.robot +++ b/atest/testdata/variables/resvarfiles/resource.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Resource resource_2.robot -*** Variable *** +*** Variables *** ${STRING} Hello world! ${INTEGER} ${42} ${FLOAT} ${-1.2} diff --git a/atest/testdata/variables/resvarfiles/resource_2.robot b/atest/testdata/variables/resvarfiles/resource_2.robot index 1cde5db3b22..9b15a29218b 100644 --- a/atest/testdata/variables/resvarfiles/resource_2.robot +++ b/atest/testdata/variables/resvarfiles/resource_2.robot @@ -1,4 +1,4 @@ -*** Variable *** +*** Variables *** ${PRIORITIES_1} Second Resource File ${PRIORITIES_2} Second Resource File ${PRIORITIES_3} Second Resource File diff --git a/atest/testdata/variables/resvarfiles/resource_3.robot b/atest/testdata/variables/resvarfiles/resource_3.robot index 42f0749d9bf..8eac324cc2c 100644 --- a/atest/testdata/variables/resvarfiles/resource_3.robot +++ b/atest/testdata/variables/resvarfiles/resource_3.robot @@ -1,13 +1,9 @@ -*** Setting *** - -*** Variable *** -${PRIORITIES_1} Third Resource File -${PRIORITIES_2} Third Resource File -${PRIORITIES_3} Third Resource File -${PRIORITIES_4} Third Resource File +*** Variables *** +${PRIORITIES_1} Third Resource File +${PRIORITIES_2} Third Resource File +${PRIORITIES_3} Third Resource File +${PRIORITIES_4} Third Resource File ${PRIORITIES_4B} Third Resource File ${PRIORITIES_4C} Third Resource File ${PRIORITIES_4D} Third Resource File -${PRIORITIES_5} Third Resource File - -*** Keyword *** +${PRIORITIES_5} Third Resource File diff --git a/atest/testdata/variables/variable_priorities.robot b/atest/testdata/variables/variable_priorities.robot index 69244f4fbc7..4311d148403 100644 --- a/atest/testdata/variables/variable_priorities.robot +++ b/atest/testdata/variables/variable_priorities.robot @@ -1,16 +1,16 @@ -*** Setting *** +*** Settings *** Documentation Some of these tests are testing same features as tests under core/resource_and_variable_imports.html. These tests should all be gone through and all tests moved under variables/. Resource resvarfiles/resource.robot Variables resvarfiles/variables.py Variables resvarfiles/variables_2.py Resource resvarfiles/resource_3.robot -*** Variable *** +*** Variables *** ${PRIORITIES_1} Variable Table in Test Case File ${PRIORITIES_2} Variable Table in Test Case File ${PRIORITIES_3} Variable Table in Test Case File -*** Test Case *** +*** Test Cases *** Individual CLI Variables Override All Other Variables Should Be Equal ${PRIORITIES_1} CLI @@ -48,7 +48,7 @@ Variables Set During Test Execution Override All Variables In Their Scope Should Be Equal ${PRIORITIES_4} Set during execution Set Variables In User Keyword -*** Keyword *** +*** Keywords *** Check Variables In User Keyword Should Be Equal ${PRIORITIES_1} CLI Should Be Equal ${PRIORITIES_2} Variable File from CLI diff --git a/atest/testdata/variables/variable_scopes.robot b/atest/testdata/variables/variable_scopes.robot index 03647dde1eb..486453a8a5f 100644 --- a/atest/testdata/variables/variable_scopes.robot +++ b/atest/testdata/variables/variable_scopes.robot @@ -1,4 +1,4 @@ -*** Test Case *** +*** Test Cases *** Variables Set In One Test Are Not Visible In Another 1 ${test_var} = Set Variable Variable in test level Set Test Variable ${test_var_2} Variable in test level @@ -25,7 +25,7 @@ Set test variable Should be equal ${test} kw2 Should be equal ${kw} kw2 -*** Keyword *** +*** Keywords *** Keyword should not see local variables Variable should not exist ${test} ${kw}= Set variable local diff --git a/atest/testdata/variables/variable_table.robot b/atest/testdata/variables/variable_table.robot index 9dbd84cd197..84ee5c9c154 100644 --- a/atest/testdata/variables/variable_table.robot +++ b/atest/testdata/variables/variable_table.robot @@ -39,7 +39,7 @@ ${NONEX 3} This ${NON EXISTING VARIABLE} is used in imports. Resource ${NONEX 3} Library ${NONEX 3} -*** Test Case *** +*** Test Cases *** Scalar String Should Be Equal ${STRING} Hello world! Should Be Equal I said: "${STRING}" I said: "Hello world!" diff --git a/atest/testdata/variables/variable_table_in_resource_file.robot b/atest/testdata/variables/variable_table_in_resource_file.robot index f1c80eb1ca2..0d81aac1b09 100644 --- a/atest/testdata/variables/variable_table_in_resource_file.robot +++ b/atest/testdata/variables/variable_table_in_resource_file.robot @@ -1,7 +1,7 @@ *** Settings *** Resource resource_for_variable_table_in_resource_file.robot -*** Test Case *** +*** Test Cases *** Scalar String Should Be Equal ${STRING} Hello world! Should Be Equal I said: "${STRING}" I said: "Hello world!" diff --git a/atest/testdata/variables/variables_from_resource_files.robot b/atest/testdata/variables/variables_from_resource_files.robot index db3479a817d..443d00e0e05 100644 --- a/atest/testdata/variables/variables_from_resource_files.robot +++ b/atest/testdata/variables/variables_from_resource_files.robot @@ -1,7 +1,7 @@ -*** Setting *** +*** Settings *** Resource resvarfiles/resource.robot -*** Variable *** +*** Variables *** ${DEFINITION IN RESOURCE (1)} ${STRING} ${DEFINITION IN RESOURCE (2)} ${LIST[0]}! ${ONE ITEM[0]} ${DEFINITION IN RESOURCE (3)} ${LIST WITH ESCAPES} @@ -9,7 +9,7 @@ ${DEFINITION IN RESOURCE (3)} ${LIST WITH ESCAPES} ${ORIGINAL DEFINITION IN SECOND RESOURCE} ${DEFINITION IN SECOND RESOURCE} ${DEFINITION IN SECOND RESOURCE (local)} ${PRIORITIES 5} -*** Test Case *** +*** Test Cases *** Scalar String Should Be Equal ${STRING} Hello world! Should Be Equal I said: "${STRING}" I said: "Hello world!" diff --git a/atest/testdata/variables/variables_from_variable_files.robot b/atest/testdata/variables/variables_from_variable_files.robot index af17af20b05..1bb31ac8d0a 100644 --- a/atest/testdata/variables/variables_from_variable_files.robot +++ b/atest/testdata/variables/variables_from_variable_files.robot @@ -1,16 +1,16 @@ -*** Setting *** +*** Settings *** Variables resvarfiles/variables.py Variables pythonpath_varfile.py imported by path Variables pythonpath_varfile imported as module Variables package.submodule -*** Variable *** +*** Variables *** ${DEFINITION IN VARIABLE FILE 1} ${STRING} ${DEFINITION IN VARIABLE FILE 2} ${LIST[0]}! ${ONE ITEM[0]} ${DEFINITION IN VARIABLE FILE 3} ${LIST WITH ESCAPES} @{DEFINITION IN VARIABLE FILE 4} @{LIST WITH ESCAPES 2} -*** Test Case *** +*** Test Cases *** Scalar String Should Be Equal ${STRING} Hello world! Should Be Equal I said: "${STRING}" I said: "Hello world!" diff --git a/atest/testdata/variables/variables_in_import_settings/common_resource.robot b/atest/testdata/variables/variables_in_import_settings/common_resource.robot index 4852a6e5511..4ac1af048d9 100644 --- a/atest/testdata/variables/variables_in_import_settings/common_resource.robot +++ b/atest/testdata/variables/variables_in_import_settings/common_resource.robot @@ -1,3 +1,3 @@ -*** Setting *** +*** Settings *** Resource resource${RESOURCE_INDEX}.robot Variables variables${RESOURCE_INDEX}.py diff --git a/atest/testdata/variables/variables_in_import_settings/resource1.robot b/atest/testdata/variables/variables_in_import_settings/resource1.robot index f4685413810..3bf9fefbad3 100644 --- a/atest/testdata/variables/variables_in_import_settings/resource1.robot +++ b/atest/testdata/variables/variables_in_import_settings/resource1.robot @@ -1,4 +1,4 @@ -*** Keyword *** +*** Keywords *** UK From Resource 1 [Arguments] ${msg} Log ${msg} diff --git a/atest/testdata/variables/variables_in_import_settings/resource2.robot b/atest/testdata/variables/variables_in_import_settings/resource2.robot index e49aef23720..a75a6dbe540 100644 --- a/atest/testdata/variables/variables_in_import_settings/resource2.robot +++ b/atest/testdata/variables/variables_in_import_settings/resource2.robot @@ -1,4 +1,4 @@ -*** Keyword *** +*** Keywords *** UK From Resource 2 [Arguments] ${msg} Log ${msg} diff --git a/atest/testdata/variables/variables_in_import_settings/test_cases1.robot b/atest/testdata/variables/variables_in_import_settings/test_cases1.robot index a3592d5d8be..2700bb5dc78 100644 --- a/atest/testdata/variables/variables_in_import_settings/test_cases1.robot +++ b/atest/testdata/variables/variables_in_import_settings/test_cases1.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Resource common_resource.robot -*** Variable *** +*** Variables *** ${RESOURCE_INDEX} 1 -*** Test Case *** +*** Test Cases *** Test 1 UK From Resource 1 ${GREETINGS} diff --git a/atest/testdata/variables/variables_in_import_settings/test_cases2.robot b/atest/testdata/variables/variables_in_import_settings/test_cases2.robot index de071fc7bb6..9de7ed35d9a 100644 --- a/atest/testdata/variables/variables_in_import_settings/test_cases2.robot +++ b/atest/testdata/variables/variables_in_import_settings/test_cases2.robot @@ -1,9 +1,9 @@ -*** Setting *** +*** Settings *** Resource common_resource.robot -*** Variable *** +*** Variables *** ${RESOURCE_INDEX} 2 -*** Test Case *** +*** Test Cases *** Test 2 UK From Resource 2 ${GREETINGS} diff --git a/atest/testresources/res_and_var_files/resource_in_pythonpath.robot b/atest/testresources/res_and_var_files/resource_in_pythonpath.robot index 67a0aba3e98..3b41f730219 100644 --- a/atest/testresources/res_and_var_files/resource_in_pythonpath.robot +++ b/atest/testresources/res_and_var_files/resource_in_pythonpath.robot @@ -1,8 +1,6 @@ -*** Setting *** - -*** Variable *** +*** Variables *** ${PPATH_RESFILE} Variable from resource file in PYTHONPATH -*** Keyword *** +*** Keywords *** PPATH KW Log Keyword from resource in PYTHONPATH diff --git a/atest/testresources/res_and_var_files/resvar_subdir/resource_in_pythonpath_2.robot b/atest/testresources/res_and_var_files/resvar_subdir/resource_in_pythonpath_2.robot index 55a40aa68a0..5f4483e09fa 100644 --- a/atest/testresources/res_and_var_files/resvar_subdir/resource_in_pythonpath_2.robot +++ b/atest/testresources/res_and_var_files/resvar_subdir/resource_in_pythonpath_2.robot @@ -1,8 +1,6 @@ -*** Setting *** - -*** Variable *** +*** Variables *** ${PPATH_RESFILE_2} Variable from resource file in PYTHONPATH (version 2) -*** Keyword *** +*** Keywords *** PPATH KW 2 Log Keyword from resource in PYTHONPATH (version 2) diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 9fa965c71f8..95fe4fc01ca 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -671,35 +671,35 @@ class TestSectionHeaders(unittest.TestCase): def test_headers_allowed_everywhere(self): data = '''\ *** Settings *** -*** Setting *** +*** SETTINGS *** ***variables*** -*VARIABLE* ARGS ARGH +*VARIABLES* ARGS ARGH *Keywords *** ... ... *** -*** Keyword *** # Comment +*** Keywords *** # Comment *** Comments *** -*** Comment *** 1 2 +*** CommentS *** 1 2 ... 3 4 ... 5 ''' expected = [ (T.SETTING_HEADER, '*** Settings ***', 1, 0), (T.EOS, '', 1, 16), - (T.SETTING_HEADER, '*** Setting ***', 2, 0), - (T.EOS, '', 2, 15), + (T.SETTING_HEADER, '*** SETTINGS ***', 2, 0), + (T.EOS, '', 2, 16), (T.VARIABLE_HEADER, '***variables***', 3, 0), (T.EOS, '', 3, 15), - (T.VARIABLE_HEADER, '*VARIABLE*', 4, 0), - (T.VARIABLE_HEADER, 'ARGS', 4, 14), - (T.VARIABLE_HEADER, 'ARGH', 4, 22), - (T.EOS, '', 4, 26), + (T.VARIABLE_HEADER, '*VARIABLES*', 4, 0), + (T.VARIABLE_HEADER, 'ARGS', 4, 15), + (T.VARIABLE_HEADER, 'ARGH', 4, 23), + (T.EOS, '', 4, 27), (T.KEYWORD_HEADER, '*Keywords', 5, 0), (T.KEYWORD_HEADER, '***', 5, 14), (T.KEYWORD_HEADER, '...', 5, 21), (T.KEYWORD_HEADER, '***', 6, 14), (T.EOS, '', 6, 17), - (T.KEYWORD_HEADER, '*** Keyword ***', 7, 0), - (T.EOS, '', 7, 15) + (T.KEYWORD_HEADER, '*** Keywords ***', 7, 0), + (T.EOS, '', 7, 16) ] assert_tokens(data, expected, get_tokens, data_only=True) assert_tokens(data, expected, get_init_tokens, data_only=True) @@ -1633,16 +1633,16 @@ def _verify(self, source, data_only=False): class TestGetResourceTokensSourceFormats(TestGetTokensSourceFormats): data = '''\ -*** Variable *** +*** Variables *** ${VAR} Value -*** KEYWORD *** +*** KEYWORDS *** NOOP No Operation ''' tokens = [ - (T.VARIABLE_HEADER, '*** Variable ***', 1, 0), - (T.EOL, '\n', 1, 16), - (T.EOS, '', 1, 17), + (T.VARIABLE_HEADER, '*** Variables ***', 1, 0), + (T.EOL, '\n', 1, 17), + (T.EOS, '', 1, 18), (T.VARIABLE, '${VAR}', 2, 0), (T.SEPARATOR, ' ', 2, 6), (T.ARGUMENT, 'Value', 2, 10), @@ -1650,9 +1650,9 @@ class TestGetResourceTokensSourceFormats(TestGetTokensSourceFormats): (T.EOS, '', 2, 16), (T.EOL, '\n', 3, 0), (T.EOS, '', 3, 1), - (T.KEYWORD_HEADER, '*** KEYWORD ***', 4, 0), - (T.EOL, '\n', 4, 15), - (T.EOS, '', 4, 16), + (T.KEYWORD_HEADER, '*** KEYWORDS ***', 4, 0), + (T.EOL, '\n', 4, 16), + (T.EOS, '', 4, 17), (T.KEYWORD_NAME, 'NOOP', 5, 0), (T.EOS, '', 5, 4), (T.SEPARATOR, ' ', 5, 4), @@ -1661,13 +1661,13 @@ class TestGetResourceTokensSourceFormats(TestGetTokensSourceFormats): (T.EOS, '', 5, 21) ] data_tokens = [ - (T.VARIABLE_HEADER, '*** Variable ***', 1, 0), - (T.EOS, '', 1, 16), + (T.VARIABLE_HEADER, '*** Variables ***', 1, 0), + (T.EOS, '', 1, 17), (T.VARIABLE, '${VAR}', 2, 0), (T.ARGUMENT, 'Value', 2, 10), (T.EOS, '', 2, 15), - (T.KEYWORD_HEADER, '*** KEYWORD ***', 4, 0), - (T.EOS, '', 4, 15), + (T.KEYWORD_HEADER, '*** KEYWORDS ***', 4, 0), + (T.EOS, '', 4, 16), (T.KEYWORD_NAME, 'NOOP', 5, 0), (T.EOS, '', 5, 4), (T.KEYWORD, 'No Operation', 5, 8), From d5fe5f45e2be1e239612b947cf590d8b122fdf98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 7 Sep 2023 10:44:09 +0300 Subject: [PATCH 0671/1592] Deprecate singular section headers loundly. Fixes #4432. --- atest/robot/parsing/table_names.robot | 40 +++++++++----- atest/testdata/parsing/table_names.robot | 45 +++++++++------ .../src/CreatingTestData/TestDataSyntax.rst | 8 +-- src/robot/conf/languages.py | 9 --- src/robot/parsing/lexer/context.py | 14 ++++- src/robot/parsing/lexer/lexer.py | 2 +- src/robot/running/builder/transformers.py | 16 ++++-- utest/parsing/test_lexer.py | 55 +++++++++++++++++-- 8 files changed, 136 insertions(+), 53 deletions(-) diff --git a/atest/robot/parsing/table_names.robot b/atest/robot/parsing/table_names.robot index 4030f284bdf..fcab2d919a1 100644 --- a/atest/robot/parsing/table_names.robot +++ b/atest/robot/parsing/table_names.robot @@ -3,31 +3,40 @@ Suite Setup Run Tests ${EMPTY} parsing/table_names.robot Resource atest_resource.robot *** Test Cases *** -Setting Table - Should Be Equal ${SUITE.doc} Testing different ways to write "Setting(s)". +Settings section + Should Be Equal ${SUITE.doc} Testing different ways to write "Settings". Check Test Tags Test Case Settings -Variable Table - Check First Log Entry Test Case Variable - Check First Log Entry Test Cases Variables +Variables section + Check First Log Entry Test Case Variables + Check First Log Entry Test Cases VARIABLES -Test Case Table +Test Cases section Check Test Case Test Case Check Test Case Test Cases -Keyword Table +Keywords section ${tc} = Check Test Case Test Case Check Log Message ${tc.kws[1].kws[0].kws[0].msgs[0]} "Keywords" was executed -Comment Table - Check Test Case Comment tables exist - Length Should Be ${ERRORS} 1 +Comments section + Check Test Case Comment section exist + Length Should Be ${ERRORS} 6 -Section Names Are Space Sensitive +Section names are space sensitive ${path} = Normalize Path ${DATADIR}/parsing/table_names.robot Invalid Section Error 0 table_names.robot 43 * * * K e y w o r d * * * -Invalid Tables +Singular headers are deprecated + Should Be Equal ${SUITE.metadata['Singular headers']} Deprecated + Check Test Case Singular headers are deprecated + Deprecated Section Warning 1 table_names.robot 47 *** Setting *** *** Settings *** + Deprecated Section Warning 2 table_names.robot 49 *** variable*** *** Variables *** + Deprecated Section Warning 3 table_names.robot 51 ***TEST CASE*** *** Test Cases *** + Deprecated Section Warning 4 table_names.robot 54 *keyword *** Keywords *** + Deprecated Section Warning 5 table_names.robot 57 *** Comment *** *** Comments *** + +Invalid sections [Setup] Run Tests ${EMPTY} parsing/invalid_table_names.robot ${tc} = Check Test Case Test in valid table ${path} = Normalize Path ${DATADIR}/parsing/invalid_tables_resource.robot @@ -38,7 +47,6 @@ Invalid Tables Invalid Section Error 2 invalid_table_names.robot 18 *one more table cause an error Error In File 3 parsing/invalid_table_names.robot 6 Error in file '${path}' on line 1: Unrecognized section header '*** ***'. Valid sections: 'Settings', 'Variables', 'Keywords' and 'Comments'. - *** Keywords *** Check First Log Entry [Arguments] ${test case name} ${expected} @@ -51,3 +59,9 @@ Invalid Section Error ... Unrecognized section header '${header}'. ... Valid sections: 'Settings', 'Variables'${test and task}, ... 'Keywords' and 'Comments'. + +Deprecated Section Warning + [Arguments] ${index} ${file} ${lineno} ${used} ${expected} + Error In File ${index} parsing/${file} ${lineno} + ... Singular section headers like '${used}' are deprecated. Use plural format like '${expected}' instead. + ... level=WARN diff --git a/atest/testdata/parsing/table_names.robot b/atest/testdata/parsing/table_names.robot index b4b23ce5a42..beea3955c2f 100644 --- a/atest/testdata/parsing/table_names.robot +++ b/atest/testdata/parsing/table_names.robot @@ -1,38 +1,38 @@ -*** Setting *** -Documentation Testing different ways to write "Setting(s)". +*** Settings *** +Documentation Testing different ways to write "Settings". -*** Comment *** +*** Comments *** This table is accepted and data here ignored. ***SETTINGS*** -Default Tags Settings +Test Tags Settings Library OperatingSystem -***Variable*** -${VARIABLE} Variable +***Variables*** +${V1} Variables *** VARIABLES *** -${VARIABLES} Variables +${V2} VARIABLES -***Test Case*** +***Test Cases*** Test Case - Log ${VARIABLE} + Log ${V1} Keyword ***COMMENTS*** -Comment tables are case (and space) insensitive like any other table and -both singular and plural formats are fine. +Comment headers are case insensitive like all other headers. ***COMMENTS*** -*** Test Cases *** +*** Test CASES *** Test Cases - Log ${VARIABLES} + Log ${V2} -Comment tables exist +Comment section exist ${content} = Get File ${CURDIR}/table_names.robot - Should Contain ${content} \n*** Comment ***\n + Should Contain ${content} \n*** Comments ***\n + Should Contain ${content} \n***COMMENTS***\n -*** Keyword *** +*** Keywords *** Keyword Keywords @@ -43,3 +43,16 @@ Keywords * * * K e y w o r d * * * Keyword Fail Should not be executed (or even parsed) + +*** Setting *** +Metadata Singular headers Deprecated +*** variable *** +${V3} Deprecated +*** TEST CASE *** +Singular headers are deprecated + Singular headers are deprecated +*keyword +Singular headers are deprecated + Should Be Equal ${V3} Deprecated +*** Comment *** +Yes, singular headers are deprecated. diff --git a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst index 490068e596d..ed9e54969c1 100644 --- a/doc/userguide/src/CreatingTestData/TestDataSyntax.rst +++ b/doc/userguide/src/CreatingTestData/TestDataSyntax.rst @@ -80,10 +80,10 @@ surrounding spaces are optional, and the number of asterisk characters can vary as long as there is at least one asterisk in the beginning. For example, also `*settings` would be recognized as a section header. -Robot Framework also supports the singular form with headers like -`*** Setting ***,` but that support is deprecated. There are no visible -deprecation warnings yet, but warnings will emitted in the future and -singular headers will eventually not be supported at all. +Robot Framework supports also singular headers like `*** Setting ***,` but that +support was deprecated in Robot Framework 6.0. There is a visible deprecation +warning starting from Robot Framework 7.0 and singular headers will eventually +not be supported at all. The header row can contain also other data than the actual section header. The extra data must be separated from the section header using the data diff --git a/src/robot/conf/languages.py b/src/robot/conf/languages.py index befcb01f93b..10c6b8a3513 100644 --- a/src/robot/conf/languages.py +++ b/src/robot/conf/languages.py @@ -130,15 +130,6 @@ def _resolve_languages(self, languages, add_english=True): languages = [languages] if add_english: languages.append(En()) - # The English singular forms are added for backwards compatibility - self.headers = { - 'Setting': 'Settings', - 'Variable': 'Variables', - 'Test Case': 'Test Cases', - 'Task': 'Tasks', - 'Keyword': 'Keywords', - 'Comment': 'Comments' - } return languages def _get_available_languages(self) -> 'dict[str, type[Language]]': diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 794c34eda9a..df0df7f5087 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -76,8 +76,18 @@ def _get_invalid_section_error(self, header: str) -> str: def _handles_section(self, statement: StatementTokens, header: str) -> bool: marker = statement[0].value - return bool(marker and marker[0] == '*' and - self.languages.headers.get(self._normalize(marker)) == header) + if not marker or marker[0] != '*': + return False + normalized = self._normalize(marker) + if self.languages.headers.get(normalized) == header: + return True + if normalized == header[:-1]: + statement[0].error = ( + f"Singular section headers like '{marker}' are deprecated. " + f"Use plural format like '*** {header} ***' instead." + ) + return True + return False def _normalize(self, marker: str) -> str: return normalize_whitespace(marker).strip('* ').title() diff --git a/src/robot/parsing/lexer/lexer.py b/src/robot/parsing/lexer/lexer.py index f9428abcb96..4e87a8a9a78 100644 --- a/src/robot/parsing/lexer/lexer.py +++ b/src/robot/parsing/lexer/lexer.py @@ -128,7 +128,7 @@ def get_tokens(self) -> 'Iterator[Token]': def _get_tokens(self, statements: 'Iterable[list[Token]]') -> 'Iterator[Token]': if self.data_only: - ignored_types = {None, Token.COMMENT_HEADER, Token.COMMENT} + ignored_types = {None, Token.COMMENT} else: ignored_types = {None} inline_if_type = Token.INLINE_IF diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index cc79d7fc092..cbfe0345133 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -627,12 +627,20 @@ def visit_Keyword(self, node): pass def visit_SectionHeader(self, node): - token = node.get_token(Token.INVALID_HEADER) - if token: + token = node.get_token(*Token.HEADER_TOKENS) + if not token.error: + return + message = self._format_message(token) + if token.type == Token.INVALID_HEADER: if self.raise_on_invalid_header: - raise DataError(self._format_message(token)) + raise DataError(message) else: - LOGGER.error(self._format_message(token)) + LOGGER.error(message) + else: + # Errors, other than totally invalid headers, can occur only with + # deprecated singular headers, and we want to report them as warnings. + # A more generic solution for separating errors and warnings would be good. + LOGGER.warn(self._format_message(token)) def visit_Error(self, node): for error in node.get_tokens(Token.ERROR): diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 95fe4fc01ca..a565cbbf48a 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -678,9 +678,9 @@ def test_headers_allowed_everywhere(self): ... *** *** Keywords *** # Comment *** Comments *** -*** CommentS *** 1 2 -... 3 4 -... 5 +Hello, I'm a comment! +*** COMMENTS *** 1 2 +... 3 ''' expected = [ (T.SETTING_HEADER, '*** Settings ***', 1, 0), @@ -699,7 +699,14 @@ def test_headers_allowed_everywhere(self): (T.KEYWORD_HEADER, '***', 6, 14), (T.EOS, '', 6, 17), (T.KEYWORD_HEADER, '*** Keywords ***', 7, 0), - (T.EOS, '', 7, 16) + (T.EOS, '', 7, 16), + (T.COMMENT_HEADER, '*** Comments ***', 8, 0), + (T.EOS, '', 8, 16), + (T.COMMENT_HEADER, '*** COMMENTS ***', 10, 0), + (T.COMMENT_HEADER, '1', 10, 20), + (T.COMMENT_HEADER, '2', 10, 25), + (T.COMMENT_HEADER, '3', 11, 7), + (T.EOS, '', 11, 8) ] assert_tokens(data, expected, get_tokens, data_only=True) assert_tokens(data, expected, get_init_tokens, data_only=True) @@ -749,6 +756,46 @@ def test_invalid_section_in_resource_file(self): (T.EOS, '', 1, 1), ], get_resource_tokens, data_only=True) + def test_singular_headers_are_deprecated(self): + data = '''\ +*** Setting *** +***variable*** +*Keyword +*** Comment *** +''' + expected = [ + (T.SETTING_HEADER, '*** Setting ***', 1, 0, + "Singular section headers like '*** Setting ***' are deprecated. " + "Use plural format like '*** Settings ***' instead."), + (T.EOL, '\n', 1, 15), + (T.EOS, '', 1, 16), + (T.VARIABLE_HEADER, '***variable***', 2, 0, + "Singular section headers like '***variable***' are deprecated. " + "Use plural format like '*** Variables ***' instead."), + (T.EOL, '\n', 2, 14), + (T.EOS, '', 2, 15), + (T.KEYWORD_HEADER, '*Keyword', 3, 0, + "Singular section headers like '*Keyword' are deprecated. " + "Use plural format like '*** Keywords ***' instead."), + (T.EOL, '\n', 3, 8), + (T.EOS, '', 3, 9), + (T.COMMENT_HEADER, '*** Comment ***', 4, 0, + "Singular section headers like '*** Comment ***' are deprecated. " + "Use plural format like '*** Comments ***' instead."), + (T.EOL, '\n', 4, 15), + (T.EOS, '', 4, 16) + ] + assert_tokens(data, expected, get_tokens) + assert_tokens(data, expected, get_init_tokens) + assert_tokens(data, expected, get_resource_tokens) + assert_tokens('*** Test Case ***', [ + (T.TESTCASE_HEADER, '*** Test Case ***', 1, 0, + "Singular section headers like '*** Test Case ***' are deprecated. " + "Use plural format like '*** Test Cases ***' instead."), + (T.EOL, '', 1, 17), + (T.EOS, '', 1, 17), + ]) + class TestName(unittest.TestCase): From e0467fee416812935cd9dadaac00bb51ea8b2e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 7 Sep 2023 11:45:50 +0300 Subject: [PATCH 0672/1592] Fix reporting parsing error source with reST files. Fixes #4859. --- atest/robot/parsing/data_formats/rest.robot | 19 ++++++++++++++++++- .../resources/rest_directive_resource.rst | 1 + .../parsing/data_formats/rest/sample.rst | 1 + .../data_formats/rest/with_init/__init__.rst | 1 + src/robot/running/builder/parsers.py | 18 ++++++++---------- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/atest/robot/parsing/data_formats/rest.robot b/atest/robot/parsing/data_formats/rest.robot index 7134e09b14b..6b13fb1bb58 100644 --- a/atest/robot/parsing/data_formats/rest.robot +++ b/atest/robot/parsing/data_formats/rest.robot @@ -5,12 +5,19 @@ Resource formats_resource.robot *** Test Cases *** One reST using code-directive Run sample file and check tests ${EMPTY} ${RESTDIR}/sample.rst - Stderr Should Be Empty ReST With reST Resource Previous Run Should Have Been Successful Check Test Case Resource File +Parsing errors have correct source + Previous Run Should Have Been Successful + Error in file 0 ${RESTDIR}/sample.rst 14 + ... Non-existing setting 'Invalid'. + Error in file 1 ${RESTDIR}/../resources/rest_directive_resource.rst 3 + ... Non-existing setting 'Invalid Resource'. + Length should be ${ERRORS} 2 + ReST Directory Run Suite Dir And Check Results -F rst:rest ${RESTDIR} @@ -18,6 +25,16 @@ Directory With reST Init Previous Run Should Have Been Successful Check Suite With Init ${SUITE.suites[1]} +Parsing errors in init file have correct source + Previous Run Should Have Been Successful + Error in file 0 ${RESTDIR}/sample.rst 14 + ... Non-existing setting 'Invalid'. + Error in file 1 ${RESTDIR}/with_init/__init__.rst 4 + ... Non-existing setting 'Invalid Init'. + Error in file 2 ${RESTDIR}/../resources/rest_directive_resource.rst 3 + ... Non-existing setting 'Invalid Resource'. + Length should be ${ERRORS} 3 + '.robot.rst' files are parsed automatically Run Tests ${EMPTY} ${RESTDIR}/with_init Should Be Equal ${SUITE.name} With Init diff --git a/atest/testdata/parsing/data_formats/resources/rest_directive_resource.rst b/atest/testdata/parsing/data_formats/resources/rest_directive_resource.rst index 657bbcabc7b..091813f3ed0 100644 --- a/atest/testdata/parsing/data_formats/resources/rest_directive_resource.rst +++ b/atest/testdata/parsing/data_formats/resources/rest_directive_resource.rst @@ -2,6 +2,7 @@ *** Settings *** Resource rest_directive_resource2.rest + Invalid Resource Setting *** Variables *** ${rest_resource_var} ReST Resource Variable diff --git a/atest/testdata/parsing/data_formats/rest/sample.rst b/atest/testdata/parsing/data_formats/rest/sample.rst index 36159b81181..a1fd189921e 100644 --- a/atest/testdata/parsing/data_formats/rest/sample.rst +++ b/atest/testdata/parsing/data_formats/rest/sample.rst @@ -27,6 +27,7 @@ We have a devious plan to rule the world with robots. Resource ../resources/rest_directive_resource.rst | Variables | ../resources/variables.py | Library | OperatingSystem | | | | | | | | | | | | | | | | + Invalid Setting .. csv-table:: cannot and should not be parsed :file: not/a/real/path.csv diff --git a/atest/testdata/parsing/data_formats/rest/with_init/__init__.rst b/atest/testdata/parsing/data_formats/rest/with_init/__init__.rst index 7947f3cd0da..5c45e0c8f88 100644 --- a/atest/testdata/parsing/data_formats/rest/with_init/__init__.rst +++ b/atest/testdata/parsing/data_formats/rest/with_init/__init__.rst @@ -3,6 +3,7 @@ ** Settings ** Suite Setup Suite Setup Documentation Testing suite init file + Invalid Init Setting ** Variables ** ${msg} = Running suite setup diff --git a/src/robot/running/builder/parsers.py b/src/robot/running/builder/parsers.py index 8e0bb5e3f8a..a909b50ec52 100644 --- a/src/robot/running/builder/parsers.py +++ b/src/robot/running/builder/parsers.py @@ -53,23 +53,21 @@ def __init__(self, lang: LanguagesLike = None, process_curdir: bool = True): def parse_suite_file(self, source: Path, defaults: TestDefaults) -> TestSuite: model = get_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) - suite = TestSuite(name=TestSuite.name_from_source(source, self.extensions), - source=source) - SuiteBuilder(suite, FileSettings(defaults)).build(model) - return suite + model.source = source + return self.parse_model(model, defaults) def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: model = get_init_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) - directory = source.parent - suite = TestSuite(name=TestSuite.name_from_source(directory), - source=directory, rpa=None) + model.source = source + suite = TestSuite(name=TestSuite.name_from_source(source.parent), + source=source.parent, rpa=None) SuiteBuilder(suite, InitFileSettings(defaults)).build(model) return suite def parse_model(self, model: File, defaults: 'TestDefaults|None' = None) -> TestSuite: - source = model.source - suite = TestSuite(name=TestSuite.name_from_source(source), source=source) + name = TestSuite.name_from_source(model.source, self.extensions) + suite = TestSuite(name=name, source=model.source) SuiteBuilder(suite, FileSettings(defaults)).build(model) return suite @@ -82,8 +80,8 @@ def _get_source(self, source: Path) -> 'Path|str': def parse_resource_file(self, source: Path) -> ResourceFile: model = get_resource_model(self._get_source(source), data_only=True, curdir=self._get_curdir(source), lang=self.lang) + model.source = source resource = self.parse_resource_model(model) - resource.source = source return resource def parse_resource_model(self, model: File) -> ResourceFile: From 822f82d8ec79e6acddadbb34704e722c0067efec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Thu, 7 Sep 2023 21:35:36 +0300 Subject: [PATCH 0673/1592] Create timestamps during execution using `datetime.now()`. This is the first step of using `datatime` for timestamps (#4258). Result objects now get their timestamps using `datetime.now()` and they are stored to `start_time` and `end_time` attributes. Old `starttime` and `endtime` are propertyes that return same string representation as earlier. Timestamps are still stored to output.xml in the old format. Moving to ISO 8601 format for performance and standard compatibility is the next step. --- atest/resources/atest_variables.py | 5 +- .../rebot/start_and_endtime_from_cli.robot | 78 ++-- src/robot/model/filter.py | 4 +- src/robot/model/message.py | 2 +- src/robot/model/stats.py | 4 +- src/robot/model/testcase.py | 2 +- src/robot/model/visitor.py | 2 +- src/robot/reporting/jsmodelbuilders.py | 2 +- src/robot/reporting/xunitwriter.py | 34 +- src/robot/result/configurer.py | 18 +- src/robot/result/merger.py | 2 +- src/robot/result/model.py | 383 ++++++++++++------ src/robot/running/bodyrunner.py | 25 +- src/robot/running/statusreporter.py | 8 +- src/robot/running/suiterunner.py | 10 +- utest/model/test_statistics.py | 26 +- utest/reporting/test_jsmodelbuilders.py | 28 +- utest/result/test_resultmodel.py | 150 ++++++- 18 files changed, 510 insertions(+), 273 deletions(-) diff --git a/atest/resources/atest_variables.py b/atest/resources/atest_variables.py index 449bb457c73..e3a2d93be47 100644 --- a/atest/resources/atest_variables.py +++ b/atest/resources/atest_variables.py @@ -1,13 +1,14 @@ -from os.path import abspath, dirname, join, normpath import locale import os import subprocess +from datetime import datetime, timedelta +from os.path import abspath, dirname, join, normpath import robot __all__ = ['ROBOTPATH', 'ROBOT_VERSION', 'DATADIR', 'SYSTEM_ENCODING', - 'CONSOLE_ENCODING'] + 'CONSOLE_ENCODING', 'datetime', 'timedelta'] ROBOTPATH = dirname(abspath(robot.__file__)) diff --git a/atest/robot/rebot/start_and_endtime_from_cli.robot b/atest/robot/rebot/start_and_endtime_from_cli.robot index 6f2f424f7a3..2aa42f3bcc1 100644 --- a/atest/robot/rebot/start_and_endtime_from_cli.robot +++ b/atest/robot/rebot/start_and_endtime_from_cli.robot @@ -9,67 +9,67 @@ ${INPUT2} %{TEMPDIR}${/}rebot-test-b.xml ${COMBINED} %{TEMPDIR}${/}combined.xml *** Test Cases *** -Combine With Both Starttime and endtime should Set Correct Elapsed Time +Combine with both start time and end time Log Many ${INPUT1} ${INPUT2} Run Rebot --starttime 2007:09:25:21:51 --endtime 2007:09:26:01:12:30.200 ${INPUT1} ${INPUT2} - Should Be Equal ${SUITE.starttime} 20070925 21:51:00.000 - Should Be Equal ${SUITE.endtime} 20070926 01:12:30.200 - Should Be True ${SUITE.elapsedtime} == (3*60*60 + 21*60 + 30) * 1000 + 200 + Should Be Equal ${SUITE.start_time} ${datetime(2007, 9, 25, 21, 51)} + Should Be Equal ${SUITE.end_time} ${datetime(2007, 9, 26, 1, 12, 30, 200000)} + Should Be Equal ${SUITE.elapsed_time} ${timedelta(seconds=3*60*60 + 21*60 + 30.2)} -Combine With Only Starttime Should Only Affect Starttime +Combine with only start time Run Rebot --starttime 20070925-2151 ${INPUT1} ${INPUT2} - Should Be Equal ${SUITE.starttime} 20070925 21:51:00.000 - Should Be Equal ${SUITE.endtime} ${ORIG_END} - Should Be Equal ${SUITE.elapsedtime} ${ORIG_ELAPSED} + Should Be Equal ${SUITE.start_time} ${datetime(2007, 9, 25, 21, 51)} + Should Be Equal ${SUITE.end_time} ${{datetime.datetime(2007, 9, 25, 21, 51) + $ORIG_ELAPSED}} + Should Be Equal ${SUITE.elapsed_time} ${ORIG_ELAPSED} -Combine With Only Endtime Should Only Affect Endtime +Combine with only end time Run Rebot --endtime 2010_01.01:12-33 ${INPUT1} ${INPUT2} - Should Be Equal ${SUITE.starttime} ${ORIG_START} - Should Be Equal ${SUITE.endtime} 20100101 12:33:00.000 - Should Be Equal ${SUITE.elapsedtime} ${ORIG_ELAPSED} + Should Be Equal ${SUITE.start_time} ${{datetime.datetime(2010, 1, 1, 12, 33) - $ORIG_ELAPSED}} + Should Be Equal ${SUITE.end_time} ${datetime(2010, 1, 1, 12, 33)} + Should Be Equal ${SUITE.elapsed_time} ${ORIG_ELAPSED} -Recombining Should Work +Recombining ${options} = Catenate ... --starttime 2007:09:25:21:51 ... --endtime 2007:09:26:01:12:30:200 ... --output ${COMBINED} Run Rebot Without Processing Output ${options} ${INPUT1} ${INPUT2} Run Rebot ${EMPTY} ${INPUT1} ${INPUT2} ${COMBINED} - Should Be True '${SUITE.elapsedtime}' > '03:21:30.200' + Should Be True $SUITE.elapsed_time > datetime.timedelta(hours=3, minutes=21, seconds=30.2) -It should Be possible to Omit Time Altogether +Omit time part altogether Run Rebot --starttime 2007-10-01 --endtime 20071006 ${INPUT1} ${INPUT2} - Should Be Equal ${SUITE.starttime} 20071001 00:00:00.000 - Should Be Equal ${SUITE.endtime} 20071006 00:00:00.000 - Should Be True ${SUITE.elapsedtime} == 120*60*60 * 1000 + Should Be Equal ${SUITE.start_time} ${datetime(2007, 10, 1)} + Should Be Equal ${SUITE.end_time} ${datetime(2007, 10, 6)} + Should Be Equal ${SUITE.elapsed_time} ${timedelta(days=5)} -Use Starttime With Single Output - Run Rebot --starttime 20070925-2151 ${INPUT1} - Should Be Equal ${SUITE.starttime} 20070925 21:51:00.000 - Should Be Equal ${SUITE.endtime} ${SINGLE_SUITE_ORIG_END} - Should Be True ${SUITE.elapsedtime} > ${SINGLE SUITE ORIG ELAPSED} +Start time and end time with single output + Run Rebot --starttime 20070925-2151 --endtime 20070925-2252 ${INPUT1} + Should Be Equal ${SUITE.start_time} ${datetime(2007, 9, 25, 21, 51)} + Should Be Equal ${SUITE.end_time} ${datetime(2007, 9, 25, 22, 52)} + Should Be Equal ${SUITE.elapsed_time} ${timedelta(hours=1, minutes=1)} -Use Endtime With Single Output - Run Rebot --endtime 20070925-2151 ${INPUT1} - Should Be Equal ${SUITE.starttime} ${SINGLE_SUITE_ORIG_START} - Should Be Equal ${SUITE.endtime} 20070925 21:51:00.000 - Should Be True ${SUITE.elapsedtime} < ${SINGLE SUITE ORIG ELAPSED} +Start time with single output + Run Rebot --starttime 20070925-2151 ${INPUT1} + Should Be Equal ${SUITE.start_time} ${datetime(2007, 9, 25, 21, 51)} + Should Be Equal ${SUITE.end_time} ${SINGLE_SUITE_ORIG_END} + Should Be True $SUITE.elapsed_time > $SINGLE_SUITE_ORIG_ELAPSED -Use Starttime And Endtime With Single Output - Run Rebot --starttime 20070925-2151 --endtime 20070925-2252 ${INPUT1} - Should Be Equal ${SUITE.starttime} 20070925 21:51:00.000 - Should Be Equal ${SUITE.endtime} 20070925 22:52:00.000 - Should Be Equal ${SUITE.elapsedtime} ${3660000} +End time with single output + Run Rebot --endtime '2023-09-07 19:31:01.234' ${INPUT1} + Should Be Equal ${SUITE.start_time} ${SINGLE_SUITE_ORIG_START} + Should Be Equal ${SUITE.end_time} ${datetime(2023, 9, 7, 19, 31, 1, 234000)} + Should Be True $SUITE.elapsed_time < $SINGLE_SUITE_ORIG_ELAPSED *** Keywords *** Create Input Files Create Output With Robot ${INPUT1} ${EMPTY} misc/normal.robot Create Output With Robot ${INPUT2} ${EMPTY} misc/suites/tsuite1.robot Run Rebot ${EMPTY} ${INPUT1} ${INPUT2} - Set Suite Variable $ORIG_START ${SUITE.starttime} - Set Suite Variable $ORIG_END ${SUITE.endtime} - Set Suite Variable $ORIG_ELAPSED ${SUITE.elapsedtime} + Set Suite Variable $ORIG_START ${SUITE.start_time} + Set Suite Variable $ORIG_END ${SUITE.end_time} + Set Suite Variable $ORIG_ELAPSED ${SUITE.elapsed_time} Run Rebot ${EMPTY} ${INPUT1} - Set Suite Variable $SINGLE_SUITE_ORIG_START ${SUITE.starttime} - Set Suite Variable $SINGLE_SUITE_ORIG_END ${SUITE.endtime} - Set Suite Variable $SINGLE_SUITE_ORIG_ELAPSED ${SUITE.elapsedtime} + Set Suite Variable $SINGLE_SUITE_ORIG_START ${SUITE.start_time} + Set Suite Variable $SINGLE_SUITE_ORIG_END ${SUITE.end_time} + Set Suite Variable $SINGLE_SUITE_ORIG_ELAPSED ${SUITE.elapsed_time} diff --git a/src/robot/model/filter.py b/src/robot/model/filter.py index 760cfeda1ea..f4b749ce425 100644 --- a/src/robot/model/filter.py +++ b/src/robot/model/filter.py @@ -80,8 +80,8 @@ def _patterns_or_none(self, items, pattern_class): def start_suite(self, suite: 'TestSuite'): if not self: return False - if hasattr(suite, 'starttime'): - suite.starttime = suite.endtime = None + if hasattr(suite, 'start_time'): + suite.start_time = suite.end_time = None if self.include_suites is not None: if self.include_suites.match(suite.name, suite.longname): suite.visit(Filter(include_tests=self.include_tests, diff --git a/src/robot/model/message.py b/src/robot/model/message.py index 3516a81b1ff..d59521c1313 100644 --- a/src/robot/model/message.py +++ b/src/robot/model/message.py @@ -39,7 +39,7 @@ def __init__(self, message='', level='INFO', html=False, timestamp=None, parent= #: ``True`` if the content is in HTML, ``False`` otherwise. self.html = html #: Timestamp in format ``%Y%m%d %H:%M:%S.%f``. - self.timestamp = timestamp + self.timestamp = timestamp # FIXME: Change to datetime! #: The object this message was triggered by. self.parent = parent diff --git a/src/robot/model/stats.py b/src/robot/model/stats.py index 6cef9b24ddc..836d90d27b5 100644 --- a/src/robot/model/stats.py +++ b/src/robot/model/stats.py @@ -83,7 +83,7 @@ def _update_stats(self, test): self.failed += 1 def _update_elapsed(self, test): - self.elapsed += test.elapsedtime + self.elapsed += test.elapsedtime # TODO: Use `test.elapsed_time` instead. @property def _sort_key(self): @@ -111,7 +111,7 @@ def __init__(self, suite): self.id = suite.id #: Number of milliseconds it took to execute this suite, #: including sub-suites. - self.elapsed = suite.elapsedtime + self.elapsed = suite.elapsedtime # TODO: Use `suite.elapsed_time` instead. self._name = suite.name def _get_custom_attrs(self): diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index ebbfef1c191..aacd2ffee02 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -120,7 +120,7 @@ def has_setup(self) -> bool: return bool(self._setup) @property - def teardown(self) -> Keyword: + def teardown(self) -> KW: """Test teardown as a :class:`~.model.keyword.Keyword` object. See :attr:`setup` for more information. diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index cafe68a4fef..34abec88224 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -21,7 +21,7 @@ the visitor methods are slightly different depending on the model they are used with. The main differences are that on the execution side keywords do not have child keywords nor messages, and that only the result objects have -status related attributes like :attr:`status` and :attr:`starttime`. +status related attributes like :attr:`status` and :attr:`start_time`. This module contains :class:`SuiteVisitor` that implements the core logic to visit a test suite structure, and the :mod:`~robot.result` package contains diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index acfffe7e36e..521f4bffc3d 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -57,7 +57,7 @@ def __init__(self, context): def _get_status(self, item): model = (STATUSES[item.status], - self._timestamp(item.starttime), + self._timestamp(item.starttime), # TODO: Use `start_time` instead. item.elapsedtime) msg = getattr(item, 'message', '') if not msg: diff --git a/src/robot/reporting/xunitwriter.py b/src/robot/reporting/xunitwriter.py index 48346122a4a..a8a48369995 100644 --- a/src/robot/reporting/xunitwriter.py +++ b/src/robot/reporting/xunitwriter.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.result import ResultVisitor +from robot.result import ResultVisitor, TestCase, TestSuite from robot.utils import XmlWriter @@ -38,32 +38,34 @@ class XUnitFileWriter(ResultVisitor): def __init__(self, xml_writer): self._writer = xml_writer - def start_suite(self, suite): + def start_suite(self, suite: TestSuite): stats = suite.statistics # Accessing property only once. attrs = {'name': suite.name, - 'tests': f'{stats.total}', + 'tests': str(stats.total), 'errors': '0', - 'failures': f'{stats.failed}', - 'skipped': f'{stats.skipped}', - 'time': self._time_as_seconds(suite.elapsedtime), - 'timestamp' : self._starttime_to_isoformat(suite.starttime)} + 'failures': str(stats.failed), + 'skipped': str(stats.skipped), + 'time': format(suite.elapsed_time.total_seconds(), '.3f'), + 'timestamp': suite.start_time.isoformat() if suite.start_time else None} self._writer.start('testsuite', attrs) - def end_suite(self, suite): + def end_suite(self, suite: TestSuite): if suite.metadata or suite.doc: self._writer.start('properties') if suite.doc: - self._writer.element('property', attrs={'name': 'Documentation', 'value': suite.doc}) + self._writer.element('property', attrs={'name': 'Documentation', + 'value': suite.doc}) for meta_name, meta_value in suite.metadata.items(): - self._writer.element('property', attrs={'name': meta_name, 'value': meta_value}) + self._writer.element('property', attrs={'name': meta_name, + 'value': meta_value}) self._writer.end('properties') self._writer.end('testsuite') - def visit_test(self, test): + def visit_test(self, test: TestCase): self._writer.start('testcase', {'classname': test.parent.longname, 'name': test.name, - 'time': self._time_as_seconds(test.elapsedtime)}) + 'time': format(test.elapsed_time.total_seconds(), '.3f')}) if test.failed: self._writer.element('failure', attrs={'message': test.message, 'type': 'AssertionError'}) @@ -72,9 +74,6 @@ def visit_test(self, test): 'type': 'SkipExecution'}) self._writer.end('testcase') - def _time_as_seconds(self, millis): - return format(millis / 1000, '.3f') - def visit_keyword(self, kw): pass @@ -86,8 +85,3 @@ def visit_errors(self, errors): def end_result(self, result): self._writer.close() - - def _starttime_to_isoformat(self, stime): - if not stime: - return None - return f'{stime[:4]}-{stime[4:6]}-{stime[6:8]}T{stime[9:22]}000' diff --git a/src/robot/result/configurer.py b/src/robot/result/configurer.py index 9cba4b65902..809fc1c3719 100644 --- a/src/robot/result/configurer.py +++ b/src/robot/result/configurer.py @@ -13,8 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime + from robot import model -from robot.utils import is_string, secs_to_timestamp, timestamp_to_secs +from robot.utils import is_string, timestamp_to_secs class SuiteConfigurer(model.SuiteConfigurer): @@ -32,11 +34,11 @@ class SuiteConfigurer(model.SuiteConfigurer): def __init__(self, remove_keywords=None, log_level=None, start_time=None, end_time=None, **base_config): - model.SuiteConfigurer.__init__(self, **base_config) + super().__init__(**base_config) self.remove_keywords = self._get_remove_keywords(remove_keywords) self.log_level = log_level - self.start_time = self._get_time(start_time) - self.end_time = self._get_time(end_time) + self.start_time = self._to_datetime(start_time) + self.end_time = self._to_datetime(end_time) def _get_remove_keywords(self, value): if value is None: @@ -45,14 +47,14 @@ def _get_remove_keywords(self, value): return [value] return value - def _get_time(self, timestamp): + def _to_datetime(self, timestamp): if not timestamp: return None try: secs = timestamp_to_secs(timestamp, seps=' :.-_') except ValueError: return None - return secs_to_timestamp(secs, millis=True) + return datetime.fromtimestamp(secs) def visit_suite(self, suite): model.SuiteConfigurer.visit_suite(self, suite) @@ -66,6 +68,6 @@ def _remove_keywords(self, suite): def _set_times(self, suite): if self.start_time: - suite.starttime = self.start_time + suite.start_time = self.start_time if self.end_time: - suite.endtime = self.end_time + suite.end_time = self.end_time diff --git a/src/robot/result/merger.py b/src/robot/result/merger.py index 2f5fa453fa1..490108a2f82 100644 --- a/src/robot/result/merger.py +++ b/src/robot/result/merger.py @@ -36,7 +36,7 @@ def start_suite(self, suite): else: old = self._find(self.current.suites, suite.name) if old is not None: - old.starttime = old.endtime = None + old.start_time = old.end_time = None old.doc = suite.doc old.metadata.update(suite.metadata) old.setup = suite.setup diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 593eae5d8f0..4a4b2db6a59 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -46,7 +46,7 @@ from robot.model import (BodyItem, create_fixture, DataDict, Keywords, Tags, SuiteVisitor, TotalStatistics, TotalStatisticsBuilder, TestSuites) -from robot.utils import copy_signature, get_elapsed_time, KnownAtRuntime, setter +from robot.utils import copy_signature, KnownAtRuntime, setter from .configurer import SuiteConfigurer from .messagefilter import MessageFilter @@ -105,65 +105,143 @@ class StatusMixin: SKIP = 'SKIP' NOT_RUN = 'NOT RUN' NOT_SET = 'NOT SET' - starttime: 'str|None' - endtime: 'str|None' __slots__ = () @property - def elapsedtime(self) -> int: - """Total execution time in milliseconds. + def start_time(self) -> 'datetime|None': + """Execution start time as a ``datetime`` or as a ``None`` if not set. + + If start time is not set, it is calculated based :attr:`end_time` + and :attr:`elapsed_time` if possible. + + Can be set either directly as a ``datetime`` or as a string in ISO 8601 + format. + + New in Robot Framework 6.1. Heavily enhanced in Robot Framework 7.0. + """ + if self._start_time: + return self._start_time + if self._end_time: + return self._end_time - self.elapsed_time + return None + + @start_time.setter + def start_time(self, start_time: 'datetime|str|None'): + if isinstance(start_time, str): + start_time = datetime.fromisoformat(start_time) + self._start_time = start_time - This attribute will be replaced by :attr:`elapsed_time` in the future. + @property + def end_time(self) -> 'datetime|None': + """Execution end time as a ``datetime`` or as a ``None`` if not set. + + If end time is not set, it is calculated based :attr:`start_time` + and :attr:`elapsed_time` if possible. + + Can be set either directly as a ``datetime`` or as a string in ISO 8601 + format. + + New in Robot Framework 6.1. Heavily enhanced in Robot Framework 7.0. """ - return get_elapsed_time(self.starttime, self.endtime) + if self._end_time: + return self._end_time + if self._start_time: + return self._start_time + self.elapsed_time + return None + + @end_time.setter + def end_time(self, end_time: 'datetime|str|None'): + if isinstance(end_time, str): + end_time = datetime.fromisoformat(end_time) + self._end_time = end_time @property def elapsed_time(self) -> timedelta: """Total execution time as a ``timedelta``. - This attribute will replace :attr:`elapsedtime` in the future. + If not set, calculated based on :attr:`start_time` and :attr:`end_time` + if possible. If that fails, calculated based on the elapsed time of + child items. - New in Robot Framework 6.1. + Can be set either directly as a ``timedelta`` or as an integer or a float + representing seconds. + + New in Robot Framework 6.1. Heavily enhanced in Robot Framework 7.0. """ - return timedelta(milliseconds=self.elapsedtime) + if self._elapsed_time is not None: + return self._elapsed_time + if self._start_time and self._end_time: + return self._end_time - self._start_time + return self._elapsed_time_from_children() + + def _elapsed_time_from_children(self) -> timedelta: + elapsed = timedelta() + for child in self.body: + if hasattr(child, 'elapsed_time'): + elapsed += child.elapsed_time + return elapsed + + @elapsed_time.setter + def elapsed_time(self, elapsed_time: 'timedelta|int|float|None'): + if isinstance(elapsed_time, (int, float)): + elapsed_time = timedelta(seconds=elapsed_time) + self._elapsed_time = elapsed_time @property - def start_time(self) -> 'datetime|None': - """Execution start time as a ``datetime`` or as ``None`` if not set. + def starttime(self) -> 'str|None': + """Execution start time as a string or as a ``None`` if not set. - This attribute will replace :attr:`starttime` in the future. + The string format is ``%Y%m%d %H:%M:%S.%f``. - New in Robot Framework 6.1. + Considered deprecated starting from Robot Framework 7.0. + :attr:`start_time` should be used instead. """ - return self._timestr_to_datetime(self.starttime) if self.starttime else None + return self._datetime_to_timestr(self.start_time) - @start_time.setter - def start_time(self, start_time: 'datetime|None'): - self.starttime = self._datetime_to_timestr(start_time) if start_time else None + @starttime.setter + def starttime(self, starttime: 'str|None'): + self.start_time = self._timestr_to_datetime(starttime) @property - def end_time(self) -> 'datetime|None': - """Execution end time as a ``datetime`` or as ``None`` if not set. + def endtime(self) -> 'str|None': + """Execution end time as a string or as a ``None`` if not set. - This attribute will replace :attr:`endtime` in the future. + The string format is ``%Y%m%d %H:%M:%S.%f``. - New in Robot Framework 6.1. + Considered deprecated starting from Robot Framework 7.0. + :attr:`end_time` should be used instead. """ - return self._timestr_to_datetime(self.endtime) if self.endtime else None + return self._datetime_to_timestr(self.end_time) - @end_time.setter - def end_time(self, end_time: 'datetime|None'): - self.endtime = self._datetime_to_timestr(end_time) if end_time else None + @endtime.setter + def endtime(self, endtime: 'str|None'): + self.end_time = self._timestr_to_datetime(endtime) - def _timestr_to_datetime(self, ts: str) -> datetime: - micro = int(ts[18:]) * 1000 - return datetime(int(ts[:4]), int(ts[4:6]), int(ts[6:8]), - int(ts[9:11]), int(ts[12:14]), int(ts[15:17]), micro) + @property + def elapsedtime(self) -> int: + """Total execution time in milliseconds. + + Considered deprecated starting from Robot Framework 7.0. + :attr:`elapsed_time` should be used instead. + """ + return int(round(self.elapsed_time.total_seconds() * 1000)) - def _datetime_to_timestr(self, dt: datetime) -> str: - millis = int(round(dt.microsecond, -3) / 1000) + def _timestr_to_datetime(self, ts: 'str|None') -> 'datetime|None': + if not ts: + return None + ts = ts.ljust(24, '0') + return datetime(int(ts[:4]), int(ts[4:6]), int(ts[6:8]), + int(ts[9:11]), int(ts[12:14]), int(ts[15:17]), int(ts[18:24])) + + def _datetime_to_timestr(self, dt: 'datetime|None') -> 'str|None': + if not dt: + return None + millis = round(dt.microsecond, -3) // 1000 + if millis > 999: + dt = dt.replace(microsecond=0) + timedelta(seconds=1) + millis = 0 return (f'{dt.year}{dt.month:02}{dt.day:02} ' - f'{dt.hour:02}:{dt.minute:02}.{dt.second:02}.{millis}') + f'{dt.hour:02}:{dt.minute:02}:{dt.second:02}.{millis:03}') @property def passed(self) -> bool: @@ -192,7 +270,7 @@ def skipped(self) -> bool: return self.status == self.SKIP @skipped.setter - def skipped(self, skipped: 'Literal[True]'): + def skipped(self, skipped: Literal[True]): if not skipped: raise ValueError(f"`skipped` value must be truthy, got '{skipped}'.") self.status = self.SKIP @@ -206,7 +284,7 @@ def not_run(self) -> bool: return self.status == self.NOT_RUN @not_run.setter - def not_run(self, not_run: 'Literal[True]'): + def not_run(self, not_run: Literal[True]): if not not_run: raise ValueError(f"`not_run` value must be truthy, got '{not_run}'.") self.status = self.NOT_RUN @@ -217,21 +295,23 @@ class ForIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): type = BodyItem.ITERATION body_class = Body repr_args = ('assign',) - __slots__ = ['assign', 'status', 'starttime', 'endtime', 'doc'] + __slots__ = ['assign', 'status', '_start_time', '_end_time', '_elapsed_time', 'doc'] def __init__(self, assign: 'Mapping[str, str]|None' = None, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, doc: str = '', parent: BodyItemParent = None): self.assign = OrderedDict(assign or ()) self.parent = parent self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.doc = doc - self.body = [] + self.body = () @property def variables(self) -> 'Mapping[str, str]': # TODO: Remove in RF 8.0. @@ -257,23 +337,25 @@ def name(self) -> str: class For(model.For, StatusMixin, DeprecatedAttributesMixin): iteration_class = ForIteration iterations_class = Iterations[iteration_class] - __slots__ = ['status', 'starttime', 'endtime', 'doc'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] def __init__(self, assign: Sequence[str] = (), - flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', + flavor: Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP'] = 'IN', values: Sequence[str] = (), start: 'str|None' = None, mode: 'str|None' = None, fill: 'str|None' = None, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, doc: str = '', parent: BodyItemParent = None): super().__init__(assign, flavor, values, start, mode, fill, parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.doc = doc @setter @@ -297,17 +379,19 @@ class WhileIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): """Represents one WHILE loop iteration.""" type = BodyItem.ITERATION body_class = Body - __slots__ = ['status', 'starttime', 'endtime', 'doc'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] def __init__(self, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, doc: str = '', parent: BodyItemParent = None): self.parent = parent self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.doc = doc self.body = () @@ -328,21 +412,23 @@ def name(self) -> str: class While(model.While, StatusMixin, DeprecatedAttributesMixin): iteration_class = WhileIteration iterations_class = Iterations[iteration_class] - __slots__ = ['status', 'starttime', 'endtime', 'doc'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] def __init__(self, condition: 'str|None' = None, limit: 'str|None' = None, on_limit: 'str|None' = None, on_limit_message: 'str|None' = None, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, doc: str = '', parent: BodyItemParent = None): super().__init__(condition, limit, on_limit, on_limit_message, parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.doc = doc @setter @@ -366,19 +452,21 @@ def name(self) -> str: class IfBranch(model.IfBranch, StatusMixin, DeprecatedAttributesMixin): body_class = Body - __slots__ = ['status', 'starttime', 'endtime', 'doc'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] def __init__(self, type: str = BodyItem.IF, condition: 'str|None' = None, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, doc: str = '', parent: BodyItemParent = None): super().__init__(type, condition, parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.doc = doc @property @@ -391,37 +479,41 @@ def name(self) -> str: class If(model.If, StatusMixin, DeprecatedAttributesMixin): branch_class = IfBranch branches_class = Branches[branch_class] - __slots__ = ['status', 'starttime', 'endtime', 'doc'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] def __init__(self, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, doc: str = '', parent: BodyItemParent = None): super().__init__(parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.doc = doc class TryBranch(model.TryBranch, StatusMixin, DeprecatedAttributesMixin): body_class = Body - __slots__ = ['status', 'starttime', 'endtime', 'doc'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] def __init__(self, type: str = BodyItem.TRY, patterns: Sequence[str] = (), pattern_type: 'str|None' = None, assign: 'str|None' = None, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, doc: str = '', parent: BodyItemParent = None): super().__init__(type, patterns, pattern_type, assign, parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.doc = doc @property @@ -442,34 +534,38 @@ def name(self) -> str: class Try(model.Try, StatusMixin, DeprecatedAttributesMixin): branch_class = TryBranch branches_class = Branches[branch_class] - __slots__ = ['status', 'starttime', 'endtime', 'doc'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] def __init__(self, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, doc: str = '', parent: BodyItemParent = None): super().__init__(parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.doc = doc @Body.register class Return(model.Return, StatusMixin, DeprecatedAttributesMixin): - __slots__ = ['status', 'starttime', 'endtime'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time'] body_class = Body def __init__(self, values: Sequence[str] = (), status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, parent: BodyItemParent = None): super().__init__(values, parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.body = () @setter @@ -495,17 +591,19 @@ def doc(self) -> str: @Body.register class Continue(model.Continue, StatusMixin, DeprecatedAttributesMixin): - __slots__ = ['status', 'starttime', 'endtime'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time'] body_class = Body def __init__(self, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, parent: BodyItemParent = None): super().__init__(parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.body = () @setter @@ -531,17 +629,19 @@ def doc(self) -> str: @Body.register class Break(model.Break, StatusMixin, DeprecatedAttributesMixin): - __slots__ = ['status', 'starttime', 'endtime'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time'] body_class = Body def __init__(self, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, parent: BodyItemParent = None): super().__init__(parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.body = () @setter @@ -567,18 +667,20 @@ def doc(self) -> str: @Body.register class Error(model.Error, StatusMixin, DeprecatedAttributesMixin): - __slots__ = ['status', 'starttime', 'endtime'] + __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time'] body_class = Body def __init__(self, values: Sequence[str] = (), status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, parent: BodyItemParent = None): super().__init__(values, parent) self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time self.body = () @setter @@ -612,7 +714,7 @@ class Keyword(model.Keyword, StatusMixin): """Represents an executed library or user keyword.""" body_class = Body __slots__ = ['kwname', 'libname', 'doc', 'timeout', 'status', '_teardown', - 'starttime', 'endtime', 'message', 'sourcename'] + '_start_time', '_end_time', '_elapsed_time', 'message', 'sourcename'] def __init__(self, kwname: str = '', libname: str = '', @@ -623,8 +725,9 @@ def __init__(self, kwname: str = '', timeout: 'str|None' = None, type: str = BodyItem.KEYWORD, status: str = 'FAIL', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, sourcename: 'str|None' = None, parent: BodyItemParent = None): super().__init__(None, args, assign, type, parent) @@ -636,8 +739,9 @@ def __init__(self, kwname: str = '', self.tags = tags self.timeout = timeout self.status = status - self.starttime = starttime - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time #: Keyword status message. Used only if suite teardowns fails. self.message = '' #: Original name of keyword with embedded arguments. @@ -645,6 +749,14 @@ def __init__(self, kwname: str = '', self._teardown = None self.body = () + def _elapsed_time_from_children(self) -> timedelta: + elapsed = super()._elapsed_time_from_children() + if self.has_setup: + elapsed += self.setup.elapsed_time + if self.has_teardown: + elapsed += self.teardown.elapsed_time + return elapsed + @setter def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Possible keyword body as a :class:`~.Body` object. @@ -655,7 +767,7 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property - def keywords(self) -> Keywords: + def keywords(self) -> Keywords: # FIXME: Remove in RF 7. """Deprecated since Robot Framework 4.0. Use :attr:`body` or :attr:`teardown` instead. @@ -679,7 +791,7 @@ def messages(self) -> 'list[Message]': return self.body.filter(messages=True) # type: ignore @property - def children(self) -> 'list[BodyItem]': + def children(self) -> 'list[BodyItem]': # FIXME: Remove in RF 7. """List of child keywords and messages in creation order. Deprecated since Robot Framework 4.0. Use :attr:`body` instead. @@ -743,6 +855,11 @@ def teardown(self) -> 'Keyword': self._teardown = create_fixture(self.__class__, None, self, self.TEARDOWN) return self._teardown + @property + def has_setup(self): + # Placeholder until keyword setup is added in RF 7. + return False + @teardown.setter def teardown(self, teardown: 'Keyword|DataDict|None'): self._teardown = create_fixture(self.__class__, teardown, self, self.TEARDOWN) @@ -772,7 +889,7 @@ class TestCase(model.TestCase[Keyword], StatusMixin): See the base class for documentation of attributes not documented here. """ - __slots__ = ['status', 'message', 'starttime', 'endtime'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] body_class = Body fixture_class = Keyword @@ -783,8 +900,9 @@ def __init__(self, name: str = '', lineno: 'int|None' = None, status: str = 'FAIL', message: str = '', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, parent: 'TestSuite|None' = None): super().__init__(name, doc, tags, timeout, lineno, parent) #: Status as a string ``PASS`` or ``FAIL``. See also :attr:`passed`. @@ -792,17 +910,24 @@ def __init__(self, name: str = '', #: Test message. Typically a failure message but can be set also when #: test passes. self.message = message - #: Test case execution start time in format ``%Y%m%d %H:%M:%S.%f``. - self.starttime = starttime - #: Test case execution end time in format ``%Y%m%d %H:%M:%S.%f``. - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time + + def _elapsed_time_from_children(self) -> timedelta: + elapsed = super()._elapsed_time_from_children() + if self.has_setup: + elapsed += self.setup.elapsed_time + if self.has_teardown: + elapsed += self.teardown.elapsed_time + return elapsed @property def not_run(self) -> bool: return False @property - def critical(self) -> bool: + def critical(self) -> bool: # FIXME: Remove in RF 7. warnings.warn("'TestCase.critical' is deprecated and always returns 'True'.") return True @@ -817,7 +942,7 @@ class TestSuite(model.TestSuite[Keyword, TestCase], StatusMixin): See the base class for documentation of attributes not documented here. """ - __slots__ = ['message', 'starttime', 'endtime'] + __slots__ = ['message', '_start_time', '_end_time', '_elapsed_time'] test_class = TestCase fixture_class = Keyword @@ -827,16 +952,26 @@ def __init__(self, name: str = '', source: 'Path|str|None' = None, rpa: bool = False, message: str = '', - starttime: 'str|None' = None, - endtime: 'str|None' = None, + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, parent: 'TestSuite|None' = None): super().__init__(name, doc, metadata, source, rpa, parent) #: Possible suite setup or teardown error message. self.message = message - #: Suite execution start time in format ``%Y%m%d %H:%M:%S.%f``. - self.starttime = starttime - #: Suite execution end time in format ``%Y%m%d %H:%M:%S.%f``. - self.endtime = endtime + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time + + def _elapsed_time_from_children(self) -> timedelta: + elapsed = timedelta() + if self.has_setup: + elapsed += self.setup.elapsed_time + if self.has_teardown: + elapsed += self.teardown.elapsed_time + for child in chain(self.suites, self.tests): + elapsed += child.elapsed_time + return elapsed @property def passed(self) -> bool: @@ -858,7 +993,7 @@ def not_run(self) -> bool: return False @property - def status(self) -> "Literal['PASS', 'SKIP', 'FAIL']": + def status(self) -> Literal['PASS', 'SKIP', 'FAIL']: """'PASS', 'FAIL' or 'SKIP' depending on test statuses. - If any test has failed, status is 'FAIL'. @@ -899,14 +1034,6 @@ def stat_message(self) -> str: """String representation of the :attr:`statistics`.""" return self.statistics.message - @property - def elapsedtime(self) -> int: - """Total execution time in milliseconds.""" - if self.starttime and self.endtime: - return get_elapsed_time(self.starttime, self.endtime) - return sum(child.elapsedtime for child in - chain(self.suites, self.tests, (self.setup, self.teardown))) - @setter def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> TestSuites['TestSuite']: return TestSuites['TestSuite'](self.__class__, self, suites) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 376d0be3b92..b886d029e62 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -13,11 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re +import time from collections import OrderedDict from contextlib import contextmanager +from datetime import datetime from itertools import zip_longest -import re -import time from robot.errors import (BreakLoop, ContinueLoop, DataError, ExecutionFailed, ExecutionFailures, ExecutionPassed, ExecutionStatus) @@ -25,9 +26,9 @@ IfBranch as IfBranchResult, Try as TryResult, TryBranch as TryBranchResult) from robot.output import librarylogger as logger -from robot.utils import (cut_assign_value, frange, get_error_message, get_timestamp, - is_list_like, is_number, plural_or_not as s, secs_to_timestr, - seq2str, split_from_equals, type_name, Matcher, timestr_to_secs) +from robot.utils import (cut_assign_value, frange, get_error_message, is_list_like, + is_number, plural_or_not as s, secs_to_timestr, seq2str, + split_from_equals, type_name, Matcher, timestr_to_secs) from robot.variables import is_dict_variable, evaluate_expression from .statusreporter import StatusReporter @@ -386,10 +387,12 @@ def run(self, data): error = None run = False limit = None - loop_result = WhileResult(data.condition, data.limit, - data.on_limit, data.on_limit_message, - starttime=get_timestamp()) - iter_result = loop_result.body.create_iteration(starttime=get_timestamp()) + loop_result = WhileResult(data.condition, + data.limit, + data.on_limit, + data.on_limit_message, + start_time=datetime.now()) + iter_result = loop_result.body.create_iteration(start_time=datetime.now()) if self._run: if data.error: error = DataError(data.error, syntax=True) @@ -431,7 +434,7 @@ def run(self, data): errors.extend(failed.get_errors()) if not failed.can_continue(ctx, self._templated): break - iter_result = loop_result.body.create_iteration(starttime=get_timestamp()) + iter_result = loop_result.body.create_iteration(start_time=datetime.now()) if not self._should_run(data.condition, ctx.variables): break if errors: @@ -491,7 +494,7 @@ def _dry_run_recursion_detection(self, data): def _run_if_branch(self, branch, recursive_dry_run=False, syntax_error=None): context = self._context - result = IfBranchResult(branch.type, branch.condition, starttime=get_timestamp()) + result = IfBranchResult(branch.type, branch.condition, start_time=datetime.now()) error = None if syntax_error: run_branch = False diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index c80121952ea..abc68dc756a 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime + from robot.errors import (ExecutionFailed, ExecutionStatus, DataError, HandlerExecutionFailed) from robot.utils import ErrorDetails, get_timestamp @@ -38,8 +40,8 @@ def __enter__(self): context = self.context result = self.result self.initial_test_status = context.test.status if context.test else None - if not result.starttime: - result.starttime = get_timestamp() + if not result.start_time: + result.start_time = datetime.now() context.start_keyword(ModelCombiner(self.data, result)) self._warn_if_deprecated(result.doc, result.name) return self @@ -61,7 +63,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): result.message = failure.message if self.initial_test_status == 'PASS': context.test.status = result.status - result.endtime = get_timestamp() + result.end_time = datetime.now() context.end_keyword(ModelCombiner(self.data, result)) if failure is not exc_val and not self.suppress: raise failure diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index 038bc5016fe..5f03889fcc2 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime + from robot.errors import ExecutionFailed, ExecutionStatus, DataError, PassExecution from robot.model import SuiteVisitor, TagPatterns from robot.result import TestSuite, Result @@ -54,7 +56,7 @@ def start_suite(self, suite): name=suite.name, doc=suite.doc, metadata=suite.metadata, - starttime=get_timestamp(), + start_time=datetime.now(), rpa=self._settings.rpa) if not self.result: self.result = Result(root_suite=result, rpa=self._settings.rpa) @@ -113,7 +115,7 @@ def end_suite(self, suite): self._suite.suite_teardown_skipped(str(failure)) else: self._suite.suite_teardown_failed(str(failure)) - self._suite.endtime = get_timestamp() + self._suite.end_time = datetime.now() self._suite.message = self._suite_status.message self._context.end_suite(ModelCombiner(suite, self._suite)) self._executed.pop() @@ -135,7 +137,7 @@ def visit_test(self, test): self._resolve_setting(test.tags), self._get_timeout(test), test.lineno, - starttime=get_timestamp()) + start_time=datetime.now()) self._context.start_test(result) self._output.start_test(ModelCombiner(test, result)) status = TestStatus(self._suite_status, result, settings.skip_on_failure, @@ -187,7 +189,7 @@ def visit_test(self, test): if status.skip_on_failure_after_tag_changes: result.message = status.message or result.message result.status = status.status - result.endtime = get_timestamp() + result.end_time = datetime.now() failed_before_listeners = result.failed self._output.end_test(ModelCombiner(test, result)) if result.failed and not failed_before_listeners: diff --git a/utest/model/test_statistics.py b/utest/model/test_statistics.py index 19a6c703650..21ac84f548c 100644 --- a/utest/model/test_statistics.py +++ b/utest/model/test_statistics.py @@ -162,18 +162,18 @@ def test_iter_also_sub_suites(self): class TestElapsedTime(unittest.TestCase): def setUp(self): - ts = '20120816 00:00:' - suite = TestSuite(starttime=ts+'00.000', endtime=ts+'59.999') + ts = '2012-08-16 00:00:' + suite = TestSuite(start_time=ts+'00.000', end_time=ts+'59.999') suite.suites = [ - TestSuite(starttime=ts+'00.000', endtime=ts+'30.000'), - TestSuite(starttime=ts+'30.000', endtime=ts+'42.042') + TestSuite(start_time=ts+'00.000', end_time=ts+'30.000'), + TestSuite(start_time=ts+'30.000', end_time=ts+'42.042') ] suite.suites[0].tests = [ - TestCase(starttime=ts+'00.000', endtime=ts+'00.001', tags=['t1']), - TestCase(starttime=ts+'00.001', endtime=ts+'01.001', tags=['t1', 't2']) + TestCase(start_time=ts+'00.000', end_time=ts+'00.001', tags=['t1']), + TestCase(start_time=ts+'00.001', end_time=ts+'01.001', tags=['t1', 't2']) ] suite.suites[1].tests = [ - TestCase(starttime=ts+'30.000', endtime=ts+'40.000', tags=['t1', 't2', 't3']) + TestCase(start_time=ts+'30.000', end_time=ts+'40.000', tags=['t1', 't2', 't3']) ] self.stats = Statistics(suite, tag_stat_combine=[('?2', 'combined')]) @@ -198,11 +198,11 @@ def test_suite_stats(self): def test_suite_stats_when_suite_has_no_times(self): suite = TestSuite() assert_equal(Statistics(suite).suite.stat.elapsed, 0) - ts = '20120816 00:00:' - suite.tests = [TestCase(starttime=ts+'00.000', endtime=ts+'00.001'), - TestCase(starttime=ts+'00.001', endtime=ts+'01.001')] + ts = '2012-08-16 00:00:' + suite.tests = [TestCase(start_time=ts+'00.000', end_time=ts+'00.001'), + TestCase(start_time=ts+'00.001', end_time=ts+'01.001')] assert_equal(Statistics(suite).suite.stat.elapsed, 1001) - suite.suites = [TestSuite(starttime=ts+'02.000', endtime=ts+'12.000'), + suite.suites = [TestSuite(start_time=ts+'02.000', end_time=ts+'12.000'), TestSuite()] assert_equal(Statistics(suite).suite.stat.elapsed, 11001) @@ -218,8 +218,8 @@ def test_elapsed_from_get_attributes(self): ('00:00:01.500', '00:00:02'), ('01:59:59:499', '01:59:59'), ('01:59:59:500', '02:00:00')]: - suite = TestSuite(starttime='20120817 00:00:00.000', - endtime='20120817 ' + time) + suite = TestSuite(start_time='2012-08-17 00:00:00.000', + end_time='2012-08-17 ' + time) stat = Statistics(suite).suite.stat elapsed = stat.get_attributes(include_elapsed=True)['elapsed'] assert_equal(elapsed, expected, time) diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index c070a8e5ae9..056115c13fd 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -42,7 +42,7 @@ def test_default_suite(self): def test_suite_with_values(self): suite = TestSuite('Name', 'Doc', {'m1': 'v1', 'M2': 'V2'}, None, False, 'Message', - '20111204 19:00:00.000', '20111204 19:00:42.001') + '2011-12-04 19:00:00.000', '2011-12-04 19:00:42.001') self._verify_suite(suite, 'Name', 'Doc', ('m1', '<p>v1</p>', 'M2', '<p>V2</p>'), message='Message', start=0, elapsed=42001) @@ -64,7 +64,7 @@ def test_default_test(self): def test_test_with_values(self): test = TestCase('Name', '*Doc*', ['t1', 't2'], '1 minute', 42, 'PASS', 'Msg', - '20111204 19:22:22.222', '20111204 19:22:22.333') + '2011-12-04 19:22:22.222', '2011-12-04 19:22:22.333') test.setup.config(kwname='setup') test.teardown.config(kwname='td') k1 = self._verify_keyword(test.setup, type=1, kwname='setup') @@ -86,7 +86,7 @@ def test_default_keyword(self): def test_keyword_with_values(self): kw = Keyword('KW Name', 'libname', 'http://doc', ('arg1', 'arg2'), ('${v1}', '${v2}'), ('tag1', 'tag2'), '1 second', 'SETUP', - 'PASS', '20111204 19:42:42.000', '20111204 19:42:42.042') + 'PASS', '2011-12-04 19:42:42.000', '2011-12-04 19:42:42.042') self._verify_keyword(kw, 1, 'KW Name', 'libname', '<a href="http://doc">http://doc</a>', 'arg1, arg2', '${v1}, ${v2}', 'tag1, tag2', @@ -151,11 +151,11 @@ def test_nested_structure(self): self._verify_min_message_level('TRACE') def test_timestamps(self): - suite = TestSuite(starttime='20111205 00:33:33.333') - suite.setup.config(kwname='s1', starttime='20111205 00:33:33.334') + suite = TestSuite(start_time='2011-12-05 00:33:33.333') + suite.setup.config(kwname='s1', start_time='2011-12-05 00:33:33.334') suite.setup.body.create_message('Message', timestamp='20111205 00:33:33.343') suite.setup.body.create_message(level='DEBUG', timestamp='20111205 00:33:33.344') - suite.tests.create(starttime='20111205 00:33:34.333') + suite.tests.create(start_time='2011-12-05 00:33:34.333') context = JsBuildingContext() model = SuiteBuilder(context).build(suite) self._verify_status(model[5], start=0) @@ -457,21 +457,21 @@ def _get_statistics(self): tag_stat_link=[('?2', 'url', '%1')]) def _get_suite(self): - ts = lambda s, ms=0: '20120816 16:09:%02d.%03d' % (s, ms) - suite = TestSuite(name='root', starttime=ts(0), endtime=ts(42)) - sub1 = TestSuite(name='sub1', starttime=ts(0), endtime=ts(10)) + ts = lambda s, ms=0: '2012-08-16 16:09:%02d.%03d' % (s, ms) + suite = TestSuite(name='root', start_time=ts(0), end_time=ts(42)) + sub1 = TestSuite(name='sub1', start_time=ts(0), end_time=ts(10)) sub2 = TestSuite(name='sub2') suite.suites = [sub1, sub2] sub1.tests = [ - TestCase(tags=['t1', 't2'], status='PASS', starttime=ts(0), endtime=ts(1, 500)), - TestCase(tags=['t1', 't3'], status='FAIL', starttime=ts(2), endtime=ts(3, 499)), - TestCase(tags=['t3'], status='SKIP', starttime=ts(3, 560), endtime=ts(3, 560)) + TestCase(tags=['t1', 't2'], status='PASS', start_time=ts(0), end_time=ts(1, 500)), + TestCase(tags=['t1', 't3'], status='FAIL', start_time=ts(2), end_time=ts(3, 499)), + TestCase(tags=['t3'], status='SKIP', start_time=ts(3, 560), end_time=ts(3, 560)) ] sub2.tests = [ - TestCase(tags=['t1', 't2'], status='PASS', starttime=ts(10), endtime=ts(30)) + TestCase(tags=['t1', 't2'], status='PASS', start_time=ts(10), end_time=ts(30)) ] sub2.suites.create(name='below suite stat level')\ - .tests.create(tags=['t1'], status='FAIL', starttime=ts(30), endtime=ts(40)) + .tests.create(tags=['t1'], status='FAIL', start_time=ts(30), end_time=ts(40)) return suite def _verify_stat(self, stat, pass_, fail, skip, label, elapsed, **attrs): diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index fdba55ca6cd..50ffe48b904 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -1,10 +1,10 @@ import unittest import warnings -from datetime import datetime +from datetime import datetime, timedelta from robot.model import Tags from robot.result import (Break, Continue, Error, For, If, IfBranch, Keyword, Message, - Return, TestCase, TestSuite, Try, While) + Return, TestCase, TestSuite, Try, TryBranch, While) from robot.utils.asserts import (assert_equal, assert_false, assert_raises, assert_raises_with_msg, assert_true) @@ -125,36 +125,142 @@ class TestTimes(unittest.TestCase): def test_suite_elapsed_time_when_start_and_end_given(self): suite = TestSuite() - suite.starttime = '20010101 10:00:00.000' - suite.endtime = '20010101 10:00:01.234' - self.assert_elapsed(suite, 1234) + suite.start_time = '2001-01-01 10:00:00.000' + suite.end_time = '2001-01-01 10:00:01.234' + self.assert_elapsed(suite, 1.234) def assert_elapsed(self, obj, expected): - assert_equal(obj.elapsedtime, expected) - assert_equal(obj.elapsed_time.total_seconds() * 1000, expected) + assert_equal(obj.elapsedtime, round(expected * 1000)) + assert_equal(obj.elapsed_time.total_seconds(), expected) def test_suite_elapsed_time_is_zero_by_default(self): self.assert_elapsed(TestSuite(), 0) - def test_suite_elapsed_time_is_got_from_childen_if_suite_does_not_have_times(self): + def test_suite_elapsed_time_is_got_from_children_if_suite_does_not_have_times(self): suite = TestSuite() - suite.tests.create(starttime='19991212 12:00:00.010', - endtime='19991212 12:00:00.011') + suite.tests.create(start_time='1999-12-12 12:00:00.010', + end_time='1999-12-12 12:00:00.011') + self.assert_elapsed(suite, 0.001) + suite.start_time = '1999-12-12 12:00:00.010' + suite.end_time = '1999-12-12 12:00:01.010' self.assert_elapsed(suite, 1) - assert_equal(suite.elapsedtime, 1) - suite.starttime = '19991212 12:00:00.010' - suite.endtime = '19991212 12:00:01.010' - self.assert_elapsed(suite, 1000) - - def test_forward_compatibility(self): - for cls in (TestSuite, TestCase, Keyword, If, IfBranch, Try, For, While, - Break, Continue, Return, Error): - obj = cls(starttime='20230512 16:40:00.001', endtime='20230512 16:40:01.001') + + def test_datetime_and_string(self): + for cls in (TestSuite, TestCase, Keyword, If, IfBranch, Try, TryBranch, + For, While, Break, Continue, Return, Error): + obj = cls(start_time='2023-05-12T16:40:00.001', + end_time='2023-05-12 16:40:01.123456') assert_equal(obj.starttime, '20230512 16:40:00.001') - assert_equal(obj.endtime, '20230512 16:40:01.001') + assert_equal(obj.endtime, '20230512 16:40:01.123') assert_equal(obj.start_time, datetime(2023, 5, 12, 16, 40, 0, 1000)) - assert_equal(obj.end_time, datetime(2023, 5, 12, 16, 40, 1, 1000)) - self.assert_elapsed(obj, 1000) + assert_equal(obj.end_time, datetime(2023, 5, 12, 16, 40, 1, 123456)) + self.assert_elapsed(obj, 1.122456) + obj.config(start_time='2023-09-07 20:33:44.444444', + end_time=datetime(2023, 9, 7, 20, 33, 44, 999999)) + assert_equal(obj.starttime, '20230907 20:33:44.444') + assert_equal(obj.endtime, '20230907 20:33:45.000') + assert_equal(obj.start_time, datetime(2023, 9, 7, 20, 33, 44, 444444)) + assert_equal(obj.end_time, datetime(2023, 9, 7, 20, 33, 44, 999999)) + self.assert_elapsed(obj, 0.555555) + obj.config(starttime='20230907 20:33:44.555555', + endtime='20230907 20:33:44.999999') + assert_equal(obj.starttime, '20230907 20:33:44.556') + assert_equal(obj.endtime, '20230907 20:33:45.000') + assert_equal(obj.start_time, datetime(2023, 9, 7, 20, 33, 44, 555555)) + assert_equal(obj.end_time, datetime(2023, 9, 7, 20, 33, 44, 999999)) + self.assert_elapsed(obj, 0.444444) + + def test_times_are_calculated_if_not_set(self): + for cls in (TestSuite, TestCase, Keyword, If, IfBranch, Try, TryBranch, + For, While, Break, Continue, Return, Error): + obj = cls() + assert_equal(obj.start_time, None) + assert_equal(obj.end_time, None) + assert_equal(obj.elapsed_time, timedelta()) + obj.config(start_time='2023-09-07 12:34:56', + end_time='2023-09-07T12:34:57', + elapsed_time=42) + assert_equal(obj.start_time, datetime(2023, 9, 7, 12, 34, 56)) + assert_equal(obj.end_time, datetime(2023, 9, 7, 12, 34, 57)) + assert_equal(obj.elapsed_time, timedelta(seconds=42)) + obj.config(elapsed_time=None) + assert_equal(obj.start_time, datetime(2023, 9, 7, 12, 34, 56)) + assert_equal(obj.end_time, datetime(2023, 9, 7, 12, 34, 57)) + assert_equal(obj.elapsed_time, timedelta(seconds=1)) + obj.config(elapsed_time=0) + assert_equal(obj.start_time, datetime(2023, 9, 7, 12, 34, 56)) + assert_equal(obj.end_time, datetime(2023, 9, 7, 12, 34, 57)) + assert_equal(obj.elapsed_time, timedelta(seconds=0)) + obj.config(end_time=None, + elapsed_time=timedelta(seconds=2)) + assert_equal(obj.start_time, datetime(2023, 9, 7, 12, 34, 56)) + assert_equal(obj.end_time, datetime(2023, 9, 7, 12, 34, 58)) + assert_equal(obj.elapsed_time, timedelta(seconds=2)) + obj.config(start_time=None, + end_time=obj.start_time, + elapsed_time=timedelta(seconds=10)) + assert_equal(obj.start_time, datetime(2023, 9, 7, 12, 34, 46)) + assert_equal(obj.end_time, datetime(2023, 9, 7, 12, 34, 56)) + assert_equal(obj.elapsed_time, timedelta(seconds=10)) + obj.config(start_time=None, + end_time=None) + assert_equal(obj.start_time, None) + assert_equal(obj.end_time, None) + assert_equal(obj.elapsed_time, timedelta(seconds=10)) + + def test_suite_elapsed_time(self): + suite = TestSuite() + suite.tests.create(elapsed_time=1) + suite.suites.create(elapsed_time=2) + assert_equal(suite.elapsed_time, timedelta(seconds=3)) + suite.setup.config(kwname='S', elapsed_time=0.1) + suite.teardown.config(kwname='T', elapsed_time=0.2) + assert_equal(suite.elapsed_time, timedelta(seconds=3.3)) + suite.config(start_time=datetime(2023, 9, 7, 20, 33, 44), + end_time=datetime(2023, 9, 7, 20, 33, 45),) + assert_equal(suite.elapsed_time, timedelta(seconds=1)) + suite.elapsed_time = 42 + assert_equal(suite.elapsed_time, timedelta(seconds=42)) + + def test_test_elapsed_time(self): + test = TestCase() + test.body.create_keyword(elapsed_time=1) + test.body.create_if(elapsed_time=2) + assert_equal(test.elapsed_time, timedelta(seconds=3)) + test.setup.config(kwname='S', elapsed_time=0.1) + test.teardown.config(kwname='T', elapsed_time=0.2) + assert_equal(test.elapsed_time, timedelta(seconds=3.3)) + test.config(start_time=datetime(2023, 9, 7, 20, 33, 44), + end_time=datetime(2023, 9, 7, 20, 33, 45),) + assert_equal(test.elapsed_time, timedelta(seconds=1)) + test.elapsed_time = 42 + assert_equal(test.elapsed_time, timedelta(seconds=42)) + + def test_keyword_elapsed_time(self): + kw = Keyword() + kw.body.create_keyword(elapsed_time=1) + kw.body.create_if(elapsed_time=2) + assert_equal(kw.elapsed_time, timedelta(seconds=3)) + kw.teardown.config(kwname='T', elapsed_time=0.2) + assert_equal(kw.elapsed_time, timedelta(seconds=3.2)) + kw.config(start_time=datetime(2023, 9, 7, 20, 33, 44), + end_time=datetime(2023, 9, 7, 20, 33, 45),) + assert_equal(kw.elapsed_time, timedelta(seconds=1)) + kw.elapsed_time = 42 + assert_equal(kw.elapsed_time, timedelta(seconds=42)) + + def test_control_structure_elapsed_time(self): + for cls in (If, IfBranch, Try, TryBranch, For, While, Break, Continue, + Return, Error): + obj = cls() + obj.body.create_keyword(elapsed_time=1) + obj.body.create_keyword(elapsed_time=2) + assert_equal(obj.elapsed_time, timedelta(seconds=3)) + obj.config(start_time=datetime(2023, 9, 7, 20, 33, 44), + end_time=datetime(2023, 9, 7, 20, 33, 45),) + assert_equal(obj.elapsed_time, timedelta(seconds=1)) + obj.elapsed_time = 42 + assert_equal(obj.elapsed_time, timedelta(seconds=42)) class TestSlots(unittest.TestCase): From 2e51ca6ca3a005d16c3d5b0b16fccc2ec7b0bd1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 8 Sep 2023 00:27:52 +0300 Subject: [PATCH 0674/1592] Save start/end time to output.xml in ISO format. Actually end time isn't saved at all, instead we save elapsed. Also XML attribute names are changed to shorter `start` and `elapsed` from `starttime` and `endtime`. This is the second part of #4258. Also output.xml generated time is now in ISO format. --- atest/robot/output/xunit.robot | 7 +- atest/robot/rebot/combine.robot | 10 --- atest/robot/rebot/xunit.robot | 2 +- doc/schema/robot.xsd | 51 ++++-------- src/robot/output/xmllogger.py | 13 +-- src/robot/result/configurer.py | 4 + src/robot/result/model.py | 9 +-- src/robot/result/xmlelementhandlers.py | 8 +- src/robot/running/suiterunner.py | 2 +- utest/result/golden.xml | 54 ++++++------- utest/result/goldenTwice.xml | 106 ++++++++++++------------- utest/result/test_resultmodel.py | 6 +- utest/result/test_resultserializer.py | 3 - 13 files changed, 122 insertions(+), 153 deletions(-) diff --git a/atest/robot/output/xunit.robot b/atest/robot/output/xunit.robot index 01d2afdae88..e0e19be7099 100644 --- a/atest/robot/output/xunit.robot +++ b/atest/robot/output/xunit.robot @@ -18,7 +18,7 @@ XUnit File Is Created File Structure Is Correct ${root} = Get Root Node - Suite Stats Should Be ${root} 8 3 1 ${SUITE.starttime} + Suite Stats Should Be ${root} 8 3 1 ${SUITE.start_time} ${tests} = Get XUnit Nodes testcase Length Should Be ${tests} 8 ${fails} = Get XUnit Nodes testcase/failure @@ -144,14 +144,13 @@ Get XUnit Nodes RETURN ${nodes} Suite Stats Should Be - [Arguments] ${elem} ${tests} ${failures} ${skipped} ${starttime} + [Arguments] ${elem} ${tests} ${failures} ${skipped} ${start_time} Element Attribute Should Be ${elem} tests ${tests} Element Attribute Should Be ${elem} failures ${failures} Element Attribute Should Be ${elem} skipped ${skipped} Element Attribute Should Match ${elem} time ?.??? Element Attribute Should Be ${elem} errors 0 - Element Attribute Should Be ${elem} timestamp - ... ${{datetime.datetime.strptime($starttime, '%Y%m%d %H:%M:%S.%f').strftime('%Y-%m-%dT%H:%M:%S.%f')}} + Element Attribute Should Be ${elem} timestamp ${start_time.isoformat()} Verify Outputs Stderr should be empty diff --git a/atest/robot/rebot/combine.robot b/atest/robot/rebot/combine.robot index 02612e85ab3..85b9e3d0d8c 100644 --- a/atest/robot/rebot/combine.robot +++ b/atest/robot/rebot/combine.robot @@ -115,16 +115,6 @@ Suite Times In Recombine Elapsed Time Should Be Valid ${SUITE4.suites[1].suites[1].elapsedtime} Should Be Equal ${SUITE4.suites[1].suites[1].elapsedtime} ${MILLIS2} -Elapsed Time Should Be Written To Output When Start And End Time Are Not Known - ${combined} = Get Element ${COMB OUT 1} suite/status - Element Attribute Should Be ${combined} starttime N/A - Element Attribute Should Be ${combined} endtime N/A - Should Be True int($combined.get('elapsedtime')) >= 0 - ${originals} = Get Elements ${COMB OUT 1} suite/suite/status - Element Attribute Should Match ${originals[0]} starttime 20?????? ??:??:??.??? - Element Attribute Should Match ${originals[0]} endtime 20?????? ??:??:??.??? - Element Should Not Have Attribute ${originals[0]} elapsedtime - Combined Suite Names Are Correct In Statistics ${suites} = Get Suite Stat Nodes ${COMB OUT 1} Should Be Equal ${suites[0].text} Pass And Fail & Normal diff --git a/atest/robot/rebot/xunit.robot b/atest/robot/rebot/xunit.robot index c3b2293a391..81567c6960e 100644 --- a/atest/robot/rebot/xunit.robot +++ b/atest/robot/rebot/xunit.robot @@ -135,7 +135,7 @@ Remove Temps Suite Stats Should Be [Arguments] ${tests} ${failures} ${skipped}=0 - ... ${time}=?.??? ${timestamp}=20??-??-??T??:??:??.???000 + ... ${time}=?.??? ${timestamp}=20??-??-??T??:??:??.?????? ... ${xpath}=. ${suite} = Get Element ${OUTDIR}/xunit.xml xpath=${xpath} Element Attribute Should Be ${suite} tests ${tests} diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index e534830910c..aa9d142116d 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -23,7 +23,7 @@ <xs:element name="errors" type="Errors" minOccurs="0" /> </xs:sequence> <xs:attribute name="generator" type="xs:string" /> - <xs:attribute name="generated" type="xs:string" /> + <xs:attribute name="generated" type="xs:dateTime" /> <!-- True when executing tasks, false (default) when executing tests. --> <xs:attribute name="rpa" type="xs:boolean" /> <!-- Version of the schema output.xml is compatible with. Must match @@ -100,7 +100,7 @@ <xs:element name="doc" type="xs:string" minOccurs="0" /> <xs:element name="tag" type="xs:string" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="timeout" type="Timeout" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> <xs:attribute name="name" type="xs:string" /> <xs:attribute name="library" type="xs:string" /> @@ -121,7 +121,7 @@ <xs:element name="kw" type="Keyword" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> <xs:attribute name="flavor" type="ForFlavor" /> <xs:attribute name="start" type="xs:string" /> <!-- Used if IN ENUMERATE has `start`. --> @@ -151,7 +151,7 @@ <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> </xs:complexType> <xs:complexType name="ForIterationVariable"> @@ -166,7 +166,7 @@ <xs:element name="branch" type="IfBranch" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> </xs:complexType> <xs:complexType name="IfBranch"> @@ -182,7 +182,7 @@ <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> <xs:attribute name="type" type="IfType" use="required" /> <xs:attribute name="condition" type="xs:string" /> @@ -199,7 +199,7 @@ <xs:element name="branch" type="TryBranch" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> </xs:complexType> <xs:complexType name="TryBranch"> @@ -216,7 +216,7 @@ <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> <xs:attribute name="type" type="TryType" use="required" /> <xs:attribute name="pattern_type" type="xs:string" /> @@ -236,7 +236,7 @@ <xs:element name="kw" type="Keyword" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> <xs:attribute name="condition" type="xs:string" /> <xs:attribute name="limit" type="xs:string" /> @@ -256,7 +256,7 @@ <xs:element name="error" type="Error" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="doc" type="xs:string" minOccurs="0" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> </xs:complexType> <xs:complexType name="Return"> @@ -264,21 +264,21 @@ <xs:element name="value" type="xs:string" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="kw" type="Keyword" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> </xs:complexType> <xs:complexType name="Break"> <xs:choice maxOccurs="unbounded"> <xs:element name="kw" type="Keyword" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> </xs:complexType> <xs:complexType name="Continue"> <xs:choice maxOccurs="unbounded"> <xs:element name="kw" type="Keyword" minOccurs="0" maxOccurs="unbounded" /> <xs:element name="msg" type="Message" minOccurs="0" maxOccurs="unbounded" /> - <xs:element name="status" type="BodyItemStatus" /> + <xs:element name="status" type="Status" /> </xs:choice> </xs:complexType> <xs:complexType name="Message"> @@ -305,10 +305,8 @@ <xs:simpleContent> <xs:extension base="xs:string"> <xs:attribute name="status" type="StatusValue" use="required" /> - <xs:attribute name="starttime" type="xs:string" /> - <xs:attribute name="endtime" type="xs:string" /> - <!-- Not set if both `starttime` and `endtime` are defined. --> - <xs:attribute name="elapsedtime" type="xs:string" /> + <xs:attribute name="start" type="xs:dateTime" /> + <xs:attribute name="elapsed" type="xs:float" /> </xs:extension> </xs:simpleContent> </xs:complexType> @@ -320,25 +318,6 @@ <xs:enumeration value="NOT RUN" /> </xs:restriction> </xs:simpleType> - <xs:complexType name="BodyItemStatus"> - <xs:simpleContent> - <xs:extension base="xs:string"> - <xs:attribute name="status" type="BodyItemStatusValue" use="required" /> - <xs:attribute name="starttime" type="xs:string" /> - <xs:attribute name="endtime" type="xs:string" /> - <!-- Not set if both `starttime` and `endtime` are defined. --> - <xs:attribute name="elapsedtime" type="xs:string" /> - </xs:extension> - </xs:simpleContent> - </xs:complexType> - <xs:simpleType name="BodyItemStatusValue"> - <xs:restriction base="xs:string"> - <xs:enumeration value="PASS" /> - <xs:enumeration value="FAIL" /> - <xs:enumeration value="SKIP" /> - <xs:enumeration value="NOT RUN" /> - </xs:restriction> - </xs:simpleType> <xs:complexType name="Timeout"> <xs:attribute name="value" type="xs:string" use="required" /> </xs:complexType> diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index c3f6cf2021c..40ee753085d 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -13,7 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import get_timestamp, NullMarkupWriter, safe_str, XmlWriter +from datetime import datetime + +from robot.utils import NullMarkupWriter, safe_str, XmlWriter from robot.version import get_full_version from robot.result.visitor import ResultVisitor @@ -33,7 +35,7 @@ def _get_writer(self, path, rpa, generator): return NullMarkupWriter() writer = XmlWriter(path, write_empty=False, usage='output') writer.start('robot', {'generator': get_full_version(generator), - 'generated': get_timestamp(), + 'generated': datetime.now().isoformat(), 'rpa': 'true' if rpa else 'false', 'schemaversion': '5'}) return writer @@ -263,10 +265,9 @@ def _write_list(self, tag, items): self._writer.element(tag, item) def _write_status(self, item): - attrs = {'status': item.status, 'starttime': item.starttime or 'N/A', - 'endtime': item.endtime or 'N/A'} - if not (item.starttime and item.endtime): - attrs['elapsedtime'] = str(item.elapsedtime) + attrs = {'status': item.status, + 'start': item.start_time.isoformat() if item.start_time else None, + 'elapsed': str(item.elapsed_time.total_seconds())} self._writer.element('status', item.message, attrs) diff --git a/src/robot/result/configurer.py b/src/robot/result/configurer.py index 809fc1c3719..071b4ab2753 100644 --- a/src/robot/result/configurer.py +++ b/src/robot/result/configurer.py @@ -68,6 +68,10 @@ def _remove_keywords(self, suite): def _set_times(self, suite): if self.start_time: + suite.end_time = suite.end_time # Preserve original value. + suite.elapsed_time = None # Force re-calculation. suite.start_time = self.start_time if self.end_time: + suite.start_time = suite.start_time + suite.elapsed_time = None suite.end_time = self.end_time diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 4a4b2db6a59..26db0951d18 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -224,7 +224,7 @@ def elapsedtime(self) -> int: Considered deprecated starting from Robot Framework 7.0. :attr:`elapsed_time` should be used instead. """ - return int(round(self.elapsed_time.total_seconds() * 1000)) + return round(self.elapsed_time.total_seconds() * 1000) def _timestr_to_datetime(self, ts: 'str|None') -> 'datetime|None': if not ts: @@ -236,12 +236,7 @@ def _timestr_to_datetime(self, ts: 'str|None') -> 'datetime|None': def _datetime_to_timestr(self, dt: 'datetime|None') -> 'str|None': if not dt: return None - millis = round(dt.microsecond, -3) // 1000 - if millis > 999: - dt = dt.replace(microsecond=0) + timedelta(seconds=1) - millis = 0 - return (f'{dt.year}{dt.month:02}{dt.day:02} ' - f'{dt.hour:02}:{dt.minute:02}:{dt.second:02}.{millis:03}') + return dt.isoformat(' ', timespec='milliseconds').replace('-', '') @property def passed(self) -> bool: diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 6f02d3497c2..22f2fd2d422 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -304,8 +304,12 @@ def __init__(self, set_status=True): def end(self, elem, result): if self.set_status: result.status = elem.get('status', 'FAIL') - result.starttime = self._timestamp(elem, 'starttime') - result.endtime = self._timestamp(elem, 'endtime') + if 'start' in elem.attrib: + result.start_time = elem.attrib['start'] + result.elapsed_time = float(elem.attrib['elapsed']) + else: # RF < 7.0 compatibility + result.starttime = self._timestamp(elem, 'starttime') + result.endtime = self._timestamp(elem, 'endtime') if elem.text: result.message = elem.text diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index 5f03889fcc2..51de7b13c9c 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -18,7 +18,7 @@ from robot.errors import ExecutionFailed, ExecutionStatus, DataError, PassExecution from robot.model import SuiteVisitor, TagPatterns from robot.result import TestSuite, Result -from robot.utils import get_timestamp, is_list_like, NormalizedDict, test_or_task +from robot.utils import is_list_like, NormalizedDict, test_or_task from robot.variables import VariableScopes from .bodyrunner import BodyRunner, KeywordRunner diff --git a/utest/result/golden.xml b/utest/result/golden.xml index f2e438019de..2bb59723aba 100644 --- a/utest/result/golden.xml +++ b/utest/result/golden.xml @@ -1,81 +1,81 @@ <?xml version="1.0" encoding="UTF-8"?> -<robot rpa="false" generated="20111024 13:41:20.873" generator="Robot trunk 20111007 (Python 2.7.2 on linux2)"> +<robot generator="Rebot 7.0.dev1 (Python 3.12.0rc2 on linux)" generated="20230908 00:06:39.213" rpa="false" schemaversion="5"> <suite id="s1" name="Normal" source="normal.html"> <kw name="my setup" type="SETUP"> <timeout value="1 year"/> -<status endtime="20111024 13:41:20.888" starttime="20111024 13:41:20.886" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.886000" elapsed="0.002"/> </kw> <test id="s1-t1" name="First One"> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>Test 1</arg> <doc>Logs the given message with the given level.</doc> -<msg level="INFO" timestamp="20111024 13:41:20.927">Test 1</msg> -<status endtime="20111024 13:41:20.928" starttime="20111024 13:41:20.926" status="PASS"/> +<msg timestamp="20111024 13:41:20.927" level="INFO">Test 1</msg> +<status status="PASS" start="2011-10-24T13:41:20.926000" elapsed="0.002"/> </kw> <kw name="logs on trace"> <var>${not really in source}</var> <tag>tag not in source</tag> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>Log on ${TEST NAME}</arg> <arg>TRACE</arg> <doc>Logs the given message with the given level.</doc> -<status endtime="20111024 13:41:20.932" starttime="20111024 13:41:20.931" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.931000" elapsed="0.001"/> </kw> -<status endtime="20111024 13:41:20.933" starttime="20111024 13:41:20.930" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.930000" elapsed="0.003"/> </kw> <for flavor="IN"> <var>${x}</var> <value>not in source</value> <iter> <var name="${x}">not in source</var> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>${x}</arg> <doc>Logs the given message with the given level.</doc> -<msg level="INFO" timestamp="20210329 17:05:45.267">not in source</msg> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<msg timestamp="20210329 17:05:45.267" level="INFO">not in source</msg> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </iter> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </for> <if> -<branch condition="'IF' == 'WRONG'" type="IF"> -<kw library="BuiltIn" name="Fail"> +<branch type="IF" condition="'IF' == 'WRONG'"> +<kw name="Fail" library="BuiltIn"> <arg>not going here</arg> <doc>Fails the test with the given message and optionally alters its tags.</doc> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="NOT RUN"/> +<status status="NOT RUN" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="NOT RUN"/> +<status status="NOT RUN" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </branch> <branch type="ELSE"> -<kw library="BuiltIn" name="No Operation"> +<kw name="No Operation" library="BuiltIn"> <doc>Not in source.</doc> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </branch> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </if> <doc>Test case documentation</doc> <tag>t1</tag> -<status endtime="20111024 13:41:20.934" starttime="20111024 13:41:20.925" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.925000" elapsed="0.009"/> </test> <doc>Normal test cases</doc> <meta name="Something">My Value</meta> -<status endtime="20111024 13:41:20.952" starttime="20111024 13:41:20.873" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.873000" elapsed="0.079"/> </suite> <statistics> <total> -<stat fail="0" pass="1" skip="0">All Tests</stat> +<stat pass="1" fail="0" skip="0">All Tests</stat> </total> <tag> -<stat fail="0" pass="1" skip="0">t1</stat> +<stat pass="1" fail="0" skip="0">t1</stat> </tag> <suite> -<stat fail="0" id="s1" name="Normal" pass="1" skip="0">Normal</stat> +<stat pass="1" fail="0" skip="0" id="s1" name="Normal">Normal</stat> </suite> </statistics> <errors> -<msg level="ERROR" timestamp="20111024 13:41:20.873">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> +<msg timestamp="20111024 13:41:20.873" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> </errors> </robot> diff --git a/utest/result/goldenTwice.xml b/utest/result/goldenTwice.xml index 324f240f58d..0a3aadfb74c 100644 --- a/utest/result/goldenTwice.xml +++ b/utest/result/goldenTwice.xml @@ -1,151 +1,151 @@ <?xml version="1.0" encoding="UTF-8"?> -<robot generated="20111027 10:11:57.563" generator="Rebot trunk 20111007 (Python 2.7.2+ on linux2)"> +<robot generator="Rebot 7.0.dev1 (Python 3.12.0rc2 on linux)" generated="20230908 00:09:51.199" rpa="false" schemaversion="5"> <suite id="s1" name="Normal & Normal"> <suite id="s1-s1" name="Normal" source="normal.html"> <kw name="my setup" type="SETUP"> <timeout value="1 year"/> -<status endtime="20111024 13:41:20.888" starttime="20111024 13:41:20.886" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.886000" elapsed="0.002"/> </kw> <test id="s1-s1-t1" name="First One"> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>Test 1</arg> <doc>Logs the given message with the given level.</doc> -<msg level="INFO" timestamp="20111024 13:41:20.927">Test 1</msg> -<status endtime="20111024 13:41:20.928" starttime="20111024 13:41:20.926" status="PASS"/> +<msg timestamp="20111024 13:41:20.927" level="INFO">Test 1</msg> +<status status="PASS" start="2011-10-24T13:41:20.926000" elapsed="0.002"/> </kw> <kw name="logs on trace"> <var>${not really in source}</var> <tag>tag not in source</tag> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>Log on ${TEST NAME}</arg> <arg>TRACE</arg> <doc>Logs the given message with the given level.</doc> -<status endtime="20111024 13:41:20.932" starttime="20111024 13:41:20.931" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.931000" elapsed="0.001"/> </kw> -<status endtime="20111024 13:41:20.933" starttime="20111024 13:41:20.930" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.930000" elapsed="0.003"/> </kw> <for flavor="IN"> <var>${x}</var> <value>not in source</value> <iter> <var name="${x}">not in source</var> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>${x}</arg> <doc>Logs the given message with the given level.</doc> -<msg level="INFO" timestamp="20210329 17:05:45.267">not in source</msg> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<msg timestamp="20210329 17:05:45.267" level="INFO">not in source</msg> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </iter> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </for> <if> -<branch condition="'IF' == 'WRONG'" type="IF"> -<kw library="BuiltIn" name="Fail"> +<branch type="IF" condition="'IF' == 'WRONG'"> +<kw name="Fail" library="BuiltIn"> <arg>not going here</arg> <doc>Fails the test with the given message and optionally alters its tags.</doc> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="NOT RUN"/> +<status status="NOT RUN" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="NOT RUN"/> +<status status="NOT RUN" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </branch> <branch type="ELSE"> -<kw library="BuiltIn" name="No Operation"> +<kw name="No Operation" library="BuiltIn"> <doc>Not in source.</doc> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </branch> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </if> <doc>Test case documentation</doc> <tag>t1</tag> -<status endtime="20111024 13:41:20.934" starttime="20111024 13:41:20.925" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.925000" elapsed="0.009"/> </test> <doc>Normal test cases</doc> <meta name="Something">My Value</meta> -<status endtime="20111024 13:41:20.952" starttime="20111024 13:41:20.873" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.873000" elapsed="0.079"/> </suite> <suite id="s1-s2" name="Normal" source="normal.html"> <kw name="my setup" type="SETUP"> <timeout value="1 year"/> -<status endtime="20111024 13:41:20.888" starttime="20111024 13:41:20.886" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.886000" elapsed="0.002"/> </kw> <test id="s1-s2-t1" name="First One"> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>Test 1</arg> <doc>Logs the given message with the given level.</doc> -<msg level="INFO" timestamp="20111024 13:41:20.927">Test 1</msg> -<status endtime="20111024 13:41:20.928" starttime="20111024 13:41:20.926" status="PASS"/> +<msg timestamp="20111024 13:41:20.927" level="INFO">Test 1</msg> +<status status="PASS" start="2011-10-24T13:41:20.926000" elapsed="0.002"/> </kw> <kw name="logs on trace"> <var>${not really in source}</var> <tag>tag not in source</tag> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>Log on ${TEST NAME}</arg> <arg>TRACE</arg> <doc>Logs the given message with the given level.</doc> -<status endtime="20111024 13:41:20.932" starttime="20111024 13:41:20.931" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.931000" elapsed="0.001"/> </kw> -<status endtime="20111024 13:41:20.933" starttime="20111024 13:41:20.930" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.930000" elapsed="0.003"/> </kw> <for flavor="IN"> <var>${x}</var> <value>not in source</value> <iter> <var name="${x}">not in source</var> -<kw library="BuiltIn" name="Log"> +<kw name="Log" library="BuiltIn"> <arg>${x}</arg> <doc>Logs the given message with the given level.</doc> -<msg level="INFO" timestamp="20210329 17:05:45.267">not in source</msg> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<msg timestamp="20210329 17:05:45.267" level="INFO">not in source</msg> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </iter> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </for> <if> -<branch condition="'IF' == 'WRONG'" type="IF"> -<kw library="BuiltIn" name="Fail"> +<branch type="IF" condition="'IF' == 'WRONG'"> +<kw name="Fail" library="BuiltIn"> <arg>not going here</arg> <doc>Fails the test with the given message and optionally alters its tags.</doc> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="NOT RUN"/> +<status status="NOT RUN" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="NOT RUN"/> +<status status="NOT RUN" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </branch> <branch type="ELSE"> -<kw library="BuiltIn" name="No Operation"> +<kw name="No Operation" library="BuiltIn"> <doc>Not in source.</doc> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </branch> -<status endtime="20210329 17:05:45.267" starttime="20210329 17:05:45.266" status="PASS"/> +<status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </if> <doc>Test case documentation</doc> <tag>t1</tag> -<status endtime="20111024 13:41:20.934" starttime="20111024 13:41:20.925" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.925000" elapsed="0.009"/> </test> <doc>Normal test cases</doc> <meta name="Something">My Value</meta> -<status endtime="20111024 13:41:20.952" starttime="20111024 13:41:20.873" status="PASS"/> +<status status="PASS" start="2011-10-24T13:41:20.873000" elapsed="0.079"/> </suite> -<status elapsedtime="158" endtime="N/A" starttime="N/A" status="PASS"/> +<status status="PASS" elapsed="0.158"/> </suite> <statistics> <total> -<stat fail="0" pass="2" skip="0">All Tests</stat> +<stat pass="2" fail="0" skip="0">All Tests</stat> </total> <tag> -<stat fail="0" pass="2" skip="0">t1</stat> +<stat pass="2" fail="0" skip="0">t1</stat> </tag> <suite> -<stat fail="0" id="s1" name="Normal & Normal" pass="2" skip="0">Normal & Normal</stat> -<stat fail="0" id="s1-s1" name="Normal" pass="1" skip="0">Normal & Normal.Normal</stat> -<stat fail="0" id="s1-s2" name="Normal" pass="1" skip="0">Normal & Normal.Normal</stat> +<stat pass="2" fail="0" skip="0" id="s1" name="Normal & Normal">Normal & Normal</stat> +<stat pass="1" fail="0" skip="0" id="s1-s1" name="Normal">Normal & Normal.Normal</stat> +<stat pass="1" fail="0" skip="0" id="s1-s2" name="Normal">Normal & Normal.Normal</stat> </suite> </statistics> <errors> -<msg level="ERROR" timestamp="20111024 13:41:20.873">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> -<msg level="ERROR" timestamp="20111024 13:41:20.873">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> +<msg timestamp="20111024 13:41:20.873" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> +<msg timestamp="20111024 13:41:20.873" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> </errors> </robot> diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 50ffe48b904..8d21460e81e 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -158,14 +158,14 @@ def test_datetime_and_string(self): obj.config(start_time='2023-09-07 20:33:44.444444', end_time=datetime(2023, 9, 7, 20, 33, 44, 999999)) assert_equal(obj.starttime, '20230907 20:33:44.444') - assert_equal(obj.endtime, '20230907 20:33:45.000') + assert_equal(obj.endtime, '20230907 20:33:44.999') assert_equal(obj.start_time, datetime(2023, 9, 7, 20, 33, 44, 444444)) assert_equal(obj.end_time, datetime(2023, 9, 7, 20, 33, 44, 999999)) self.assert_elapsed(obj, 0.555555) obj.config(starttime='20230907 20:33:44.555555', endtime='20230907 20:33:44.999999') - assert_equal(obj.starttime, '20230907 20:33:44.556') - assert_equal(obj.endtime, '20230907 20:33:45.000') + assert_equal(obj.starttime, '20230907 20:33:44.555') + assert_equal(obj.endtime, '20230907 20:33:44.999') assert_equal(obj.start_time, datetime(2023, 9, 7, 20, 33, 44, 555555)) assert_equal(obj.end_time, datetime(2023, 9, 7, 20, 33, 44, 999999)) self.assert_elapsed(obj, 0.444444) diff --git a/utest/result/test_resultserializer.py b/utest/result/test_resultserializer.py index e19e3629298..48c75bb530f 100644 --- a/utest/result/test_resultserializer.py +++ b/utest/result/test_resultserializer.py @@ -11,9 +11,6 @@ class StreamXmlWriter(XmlWriter): - def _order_attrs(self, attrs): - return sorted(attrs.items()) - def _create_output(self, output): return output From b23bc242a61a0bb2b3c5802d95d4f5ca9a74f7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 8 Sep 2023 13:55:41 +0300 Subject: [PATCH 0675/1592] Change `Message.timestamp` to `datatime`. Timestamps in output.xml and also in the debug file are now in ISO 8601 format. Part of #4258. --- atest/robot/cli/runner/debugfile.robot | 4 +- .../listener_interface/listener_v3.robot | 4 +- .../robot/test_libraries/as_listenerv3.robot | 4 +- .../timestamps_for_stdout_messages.robot | 20 +++---- doc/schema/robot.xsd | 2 +- src/robot/model/__init__.py | 2 +- src/robot/model/message.py | 31 +++++++---- src/robot/output/debugfile.py | 34 ++++++------ src/robot/output/listenerarguments.py | 4 +- src/robot/output/loggerhelper.py | 46 ++++++++-------- src/robot/output/xmllogger.py | 3 +- src/robot/reporting/jsbuildingcontext.py | 7 +-- src/robot/reporting/jsmodelbuilders.py | 6 +-- src/robot/result/xmlelementhandlers.py | 52 +++++++++++-------- src/robot/running/statusreporter.py | 2 +- utest/model/test_message.py | 12 +++++ utest/output/test_filelogger.py | 33 +++++------- utest/output/test_logger.py | 2 +- utest/output/test_stdout_splitter.py | 4 +- utest/reporting/test_jsbuildingcontext.py | 15 +++--- utest/result/golden.xml | 8 +-- utest/result/goldenTwice.xml | 14 ++--- utest/result/test_resultbuilder.py | 3 +- 23 files changed, 171 insertions(+), 141 deletions(-) diff --git a/atest/robot/cli/runner/debugfile.robot b/atest/robot/cli/runner/debugfile.robot index 0e366d6978b..838f573858b 100644 --- a/atest/robot/cli/runner/debugfile.robot +++ b/atest/robot/cli/runner/debugfile.robot @@ -3,7 +3,7 @@ Test Setup Create Output Directory Resource cli_resource.robot *** Variables *** -${TIMESTAMP} ???????? ??:??:??.??? +${TIMESTAMP} 20??-??-?? ??:??:??.?????? *** Test Cases *** Debugfile @@ -55,7 +55,7 @@ Writing Non-ASCII To Debugfile Stderr Should Be Empty ${content} = Get File ${CLI OUTDIR}/debug.txt Debugfile should contain ${content} ${TIMESTAMP} - FAIL - Circle is 360°, Hyvää üötä, উৄ ৰ ৺ ট ৫ ৪ হ - Debugfile should contain ${content} ${TIMESTAMP} - INFO - +- START TEST: Ñöñ-ÄŚÇÏÏ Tëśt äņd Këywörd Nämës, Спасибо ? ? + Debugfile should contain ${content} ${TIMESTAMP} - INFO - +- START TEST: Ñöñ-ÄŚÇÏÏ Tëśt äņd Këywörd Nämës, Спасибо No Debugfile Run Tests Without Processing Output --outputdir ${CLI OUTDIR} --debugfile NoNe -o o.xml ${TESTFILE} diff --git a/atest/robot/output/listener_interface/listener_v3.robot b/atest/robot/output/listener_interface/listener_v3.robot index d533bbd7c35..b736343b180 100644 --- a/atest/robot/output/listener_interface/listener_v3.robot +++ b/atest/robot/output/listener_interface/listener_v3.robot @@ -65,10 +65,10 @@ Changing current element docs does not change console output, but does change ou Log messages and timestamps can be changed ${tc} = Get test case Pass [start suite] Check log message ${tc.kws[0].kws[0].msgs[0]} HELLO SAYS "PASS"! - Should be equal ${tc.kws[0].kws[0].msgs[0].timestamp} 20151216 15:51:20.141 + Should be equal ${tc.kws[0].kws[0].msgs[0].timestamp} ${datetime(2015, 12, 16, 15, 51, 20, 141000)} Syslog messages can be changed - Syslog Should Contain Match 20151216 15:51:20.141 | INFO \ | TESTS EXECUTION ENDED. STATISTICS: + Syslog Should Contain Match 2015-12-16 15:51:20.141000 | INFO \ | TESTS EXECUTION ENDED. STATISTICS: File methods and close are called Stderr Should Be Equal To SEPARATOR=\n diff --git a/atest/robot/test_libraries/as_listenerv3.robot b/atest/robot/test_libraries/as_listenerv3.robot index 4169c0d1623..167c8530018 100644 --- a/atest/robot/test_libraries/as_listenerv3.robot +++ b/atest/robot/test_libraries/as_listenerv3.robot @@ -30,10 +30,10 @@ Metadata can be modified Log messages and timestamps can be changed ${tc}= Get test case Pass Check log message ${tc.kws[0].msgs[0]} Passing [log_message] - Should be equal ${tc.kws[0].msgs[0].timestamp} 20151216 15:51:20.141 + Should be equal ${tc.kws[0].msgs[0].timestamp} ${datetime(2015, 12, 16, 15, 51,20, 141000)} Message to syslog can be changed - Syslog Should Contain 20151216 15:51:20.141 | WARN \ | Foo [log_message] [message] + Syslog Should Contain 2015-12-16 15:51:20.141000 | WARN \ | Foo [log_message] [message] Check log message ${ERRORS[0]} Foo [log_message] [message] WARN Close is called diff --git a/atest/robot/test_libraries/timestamps_for_stdout_messages.robot b/atest/robot/test_libraries/timestamps_for_stdout_messages.robot index 9640f56c8bc..a70a24d8c32 100644 --- a/atest/robot/test_libraries/timestamps_for_stdout_messages.robot +++ b/atest/robot/test_libraries/timestamps_for_stdout_messages.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} test_libraries/timestamps_for_stdout_messages.robot +Suite Setup Run Tests ${EMPTY} test_libraries/timestamps_for_stdout_messages.robot Resource atest_resource.robot *** Test Cases *** @@ -11,16 +11,16 @@ Library adds timestamp as float *** Keywords *** Test's timestamps should be correct - ${tc} = Check Test Case ${TESTNAME} - Known timestamp should be correct ${tc.kws[0].msgs[0]} - Current timestamp should be smaller than kw end time ${tc.kws[0]} + ${tc} = Check Test Case ${TESTNAME} + Known timestamp should be correct ${tc.body[0].msgs[0]} + Current timestamp should be smaller than kw end time ${tc.body[0]} Known timestamp should be correct - [Arguments] ${msg} - Check log message ${msg} Known timestamp - Should Be Equal ${msg.timestamp} 20110618 20:43:54.931 + [Arguments] ${msg} + Check Log Message ${msg} Known timestamp + Should Be Equal ${msg.timestamp} ${datetime(2011, 6, 18, 20, 43, 54, 931000)} Current timestamp should be smaller than kw end time - [Arguments] ${kw} - Check log message ${kw.msgs[1]} <b>Current</b> INFO html=True - Should Be True "${kw.endtime}" > "${kw.msgs[1].timestamp}" + [Arguments] ${kw} + Check Log Message ${kw.msgs[1]} <b>Current</b> INFO html=True + Should Be True $kw.end_time > $kw.msgs[1].timestamp diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index aa9d142116d..e03f3a61257 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -284,7 +284,7 @@ <xs:complexType name="Message"> <xs:simpleContent> <xs:extension base="xs:string"> - <xs:attribute name="timestamp" type="xs:string" use="required" /> + <xs:attribute name="time" type="xs:dateTime" use="required" /> <xs:attribute name="level" type="MessageLevel" use="required" /> <xs:attribute name="html" type="xs:boolean" /> </xs:extension> diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index 9e78b7cd9ba..181198899c8 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -31,7 +31,7 @@ from .fixture import create_fixture from .itemlist import ItemList from .keyword import Keyword, Keywords -from .message import Message, Messages +from .message import Message, MessageLevel, Messages from .modelobject import DataDict, ModelObject from .modifier import ModelModifier from .namepatterns import SuiteNamePatterns, TestNamePatterns diff --git a/src/robot/model/message.py b/src/robot/model/message.py index d59521c1313..ff225f12d4f 100644 --- a/src/robot/model/message.py +++ b/src/robot/model/message.py @@ -13,12 +13,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import html_escape +from datetime import datetime +from typing import Literal + +from robot.utils import html_escape, setter from .body import BodyItem from .itemlist import ItemList +MessageLevel = Literal['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FAIL', 'SKIP'] + + class Message(BodyItem): """A message created during the test execution. @@ -27,22 +33,25 @@ class Message(BodyItem): """ type = BodyItem.MESSAGE repr_args = ('message', 'level') - __slots__ = ['message', 'level', 'html', 'timestamp'] + __slots__ = ['message', 'level', 'html', '_timestamp'] - def __init__(self, message='', level='INFO', html=False, timestamp=None, parent=None): - #: The message content as a string. + def __init__(self, message: str = '', + level: MessageLevel = 'INFO', + html: bool = False, + timestamp: 'datetime|str|None' = None, + parent: 'BodyItem|None' = None): self.message = message - #: Severity of the message. Either ``TRACE``, ``DEBUG``, ``INFO``, - #: ``WARN``, ``ERROR``, ``FAIL`` or ``SKIP`. The last two are only used - #: with keyword failure messages. self.level = level - #: ``True`` if the content is in HTML, ``False`` otherwise. self.html = html - #: Timestamp in format ``%Y%m%d %H:%M:%S.%f``. - self.timestamp = timestamp # FIXME: Change to datetime! - #: The object this message was triggered by. + self.timestamp = timestamp self.parent = parent + @setter + def timestamp(self, timestamp: 'datetime|str|None') -> 'datetime|None': + if isinstance(timestamp, str): + return datetime.fromisoformat(timestamp) + return timestamp + @property def html_message(self): """Returns the message content as HTML.""" diff --git a/src/robot/output/debugfile.py b/src/robot/output/debugfile.py index 15797786d69..5b81f4026a9 100644 --- a/src/robot/output/debugfile.py +++ b/src/robot/output/debugfile.py @@ -14,7 +14,7 @@ # limitations under the License. from robot.errors import DataError -from robot.utils import get_timestamp, file_writer, seq2str2 +from robot.utils import file_writer, seq2str2 from .logger import LOGGER from .loggerhelper import IsLogged @@ -46,12 +46,12 @@ def __init__(self, outfile): def start_suite(self, suite): self._separator('SUITE') - self._start('SUITE', suite.longname) + self._start('SUITE', suite.longname, suite.start_time) self._separator('SUITE') def end_suite(self, suite): self._separator('SUITE') - self._end('SUITE', suite.longname, suite.elapsedtime) + self._end('SUITE', suite.longname, suite.end_time, suite.elapsed_time) self._separator('SUITE') if self._indent == 0: LOGGER.output_file('Debug', self._outfile.name) @@ -59,49 +59,51 @@ def end_suite(self, suite): def start_test(self, test): self._separator('TEST') - self._start('TEST', test.name) + self._start('TEST', test.name, test.start_time) self._separator('TEST') def end_test(self, test): self._separator('TEST') - self._end('TEST', test.name, test.elapsedtime) + self._end('TEST', test.name, test.end_time, test.elapsed_time) self._separator('TEST') def start_keyword(self, kw): if self._kw_level == 0: self._separator('KEYWORD') - self._start(kw.type, kw.name, kw.args) + self._start(kw.type, kw.name, kw.start_time, seq2str2(kw.args)) self._kw_level += 1 def end_keyword(self, kw): - self._end(kw.type, kw.name, kw.elapsedtime) + self._end(kw.type, kw.name, kw.end_time, kw.elapsed_time) self._kw_level -= 1 def log_message(self, msg): if self._is_logged(msg.level): - self._write(msg.message, level=msg.level, timestamp=msg.timestamp) + self._write(f'{msg.timestamp} - {msg.level} - {msg.message}') def close(self): if not self._outfile.closed: self._outfile.close() - def _start(self, type_, name, args=''): - args = ' ' + seq2str2(args) - self._write('+%s START %s: %s%s' % ('-'*self._indent, type_, name, args)) + def _start(self, type, name, timestamp, extra=''): + if extra: + extra = f' {extra}' + indent = '-' * self._indent + self._write(f'{timestamp} - INFO - +{indent} START {type}: {name}{extra}') self._indent += 1 - def _end(self, type_, name, elapsed): + def _end(self, type, name, timestamp, elapsed): self._indent -= 1 - self._write('+%s END %s: %s (%s)' % ('-'*self._indent, type_, name, elapsed)) + indent = '-' * self._indent + elapsed = elapsed.total_seconds() + self._write(f'{timestamp} - INFO - +{indent} END {type}: {name} ({elapsed} s)') def _separator(self, type_): self._write(self._separators[type_] * 78, separator=True) - def _write(self, text, separator=False, level='INFO', timestamp=None): + def _write(self, text, separator=False): if separator and self._separator_written_last: return - if not separator: - text = '%s - %s - %s' % (timestamp or get_timestamp(), level, text) self._outfile.write(text.rstrip() + '\n') self._outfile.flush() self._separator_written_last = separator diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 2b930b08979..4eec92f0cb0 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -56,7 +56,9 @@ def by_method_name(cls, name, arguments): class MessageArguments(ListenerArguments): def _get_version2_arguments(self, msg): - attributes = {'timestamp': msg.timestamp, + # Timestamp in our legacy format. + timestamp = msg.timestamp.isoformat(' ', timespec='milliseconds').replace('-', '') + attributes = {'timestamp': timestamp, 'message': msg.message, 'level': msg.level, 'html': 'yes' if msg.html else 'no'} diff --git a/src/robot/output/loggerhelper.py b/src/robot/output/loggerhelper.py index 4253f8948a2..7851e7e22a3 100644 --- a/src/robot/output/loggerhelper.py +++ b/src/robot/output/loggerhelper.py @@ -14,10 +14,12 @@ # limitations under the License. import sys +from datetime import datetime +from typing import Callable, Literal from robot.errors import DataError -from robot.model import Message as BaseMessage -from robot.utils import get_timestamp, is_string, safe_str, console_encode +from robot.model import Message as BaseMessage, MessageLevel +from robot.utils import console_encode, safe_str LEVELS = { @@ -30,6 +32,7 @@ 'DEBUG' : 1, 'TRACE' : 0, } +PseudoLevel = Literal['HTML', 'CONSOLE'] def write_to_console(msg, newline=True, stream='stdout'): @@ -88,38 +91,35 @@ def message(self, msg): class Message(BaseMessage): __slots__ = ['_message'] - def __init__(self, message, level='INFO', html=False, timestamp=None): - message = self._normalize_message(message) + def __init__(self, message: 'str|Callable[[], str]', + level: 'MessageLevel|PseudoLevel' = 'INFO', + html: bool = False, + timestamp: 'datetime|str|None' = None): level, html = self._get_level_and_html(level, html) - timestamp = timestamp or get_timestamp() - super().__init__(message, level, html, timestamp) - - def _normalize_message(self, msg): - if callable(msg): - return msg - if not is_string(msg): - msg = safe_str(msg) - if '\r\n' in msg: - msg = msg.replace('\r\n', '\n') - return msg - - def _get_level_and_html(self, level, html): + super().__init__(message, level, html, timestamp or datetime.now()) + + def _get_level_and_html(self, level, html) -> 'tuple[MessageLevel, bool]': level = level.upper() if level == 'HTML': return 'INFO', True if level == 'CONSOLE': - level = 'INFO' - if level not in LEVELS: - raise DataError("Invalid log level '%s'." % level) - return level, html + return 'INFO', html + if level in LEVELS: + return level, html + raise DataError(f"Invalid log level '{level}'.") @property - def message(self): + def message(self) -> str: self.resolve_delayed_message() return self._message @message.setter - def message(self, message): + def message(self, message: 'str|Callable[[], str]'): + if not callable(message): + if not isinstance(message, str): + message = safe_str(message) + if '\r\n' in message: + message = message.replace('\r\n', '\n') self._message = message def resolve_delayed_message(self): diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 40ee753085d..13d66938654 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -60,7 +60,8 @@ def log_message(self, msg): self._write_message(msg) def _write_message(self, msg): - attrs = {'timestamp': msg.timestamp or 'N/A', 'level': msg.level} + attrs = {'time': msg.timestamp.isoformat() if msg.timestamp else None, + 'level': msg.level} if msg.html: attrs['html'] = 'true' self._writer.element('msg', msg.message, attrs) diff --git a/src/robot/reporting/jsbuildingcontext.py b/src/robot/reporting/jsbuildingcontext.py index 689a8055cb1..4a1c037d4d5 100644 --- a/src/robot/reporting/jsbuildingcontext.py +++ b/src/robot/reporting/jsbuildingcontext.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime from contextlib import contextmanager from pathlib import Path @@ -64,10 +65,10 @@ def relative_source(self, source): if self._log_dir and source and source.exists() else '' return self.string(rel_source) - def timestamp(self, time): - if not time: + def timestamp(self, ts: datetime) -> 'int|None': + if not ts: return None - millis = int(timestamp_to_secs(time) * 1000) + millis = round(ts.timestamp() * 1000) if self.basemillis is None: self.basemillis = millis return millis - self.basemillis diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index 521f4bffc3d..29600f5068b 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -49,7 +49,7 @@ def build_from(self, result_from_xml): class _Builder: - def __init__(self, context): + def __init__(self, context: JsBuildingContext): self._context = context self._string = self._context.string self._html = self._context.html @@ -57,8 +57,8 @@ def __init__(self, context): def _get_status(self, item): model = (STATUSES[item.status], - self._timestamp(item.starttime), # TODO: Use `start_time` instead. - item.elapsedtime) + self._timestamp(item.start_time), + round(item.elapsed_time.total_seconds() * 1000)) msg = getattr(item, 'message', '') if not msg: return model diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 22f2fd2d422..9bd56691056 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime + from robot.errors import DataError @@ -58,9 +60,13 @@ def start(self, elem, result): def end(self, elem, result): pass - def _timestamp(self, elem, attr_name): - timestamp = elem.get(attr_name) - return timestamp if timestamp != 'N/A' else None + def _legacy_timestamp(self, elem, attr_name): + ts = elem.get(attr_name) + if ts == 'N/A' or not ts: + return None + ts = ts.ljust(24, '0') + return datetime(int(ts[:4]), int(ts[4:6]), int(ts[6:8]), + int(ts[9:11]), int(ts[12:14]), int(ts[15:17]), int(ts[18:24])) class RootHandler(ElementHandler): @@ -287,11 +293,23 @@ class MessageHandler(ElementHandler): tag = 'msg' def end(self, elem, result): - html_true = ('true', 'yes') # 'yes' is compatibility for RF < 4. - result.body.create_message(elem.text or '', - elem.get('level', 'INFO'), - elem.get('html') in html_true, - self._timestamp(elem, 'timestamp')) + self._create_message(elem, result.body.create_message) + + def _create_message(self, elem, creator): + if 'time' in elem.attrib: # RF >= 7 + timestamp = elem.attrib['time'] + else: # RF < 7 + timestamp = self._legacy_timestamp(elem, 'timestamp') + creator(elem.text or '', + elem.get('level', 'INFO'), + elem.get('html') in ('true', 'yes'), # 'yes' is RF < 4 compatibility + timestamp) + + +class ErrorMessageHandler(MessageHandler): + + def end(self, elem, result): + self._create_message(elem, result.messages.create) @ElementHandler.register @@ -304,12 +322,12 @@ def __init__(self, set_status=True): def end(self, elem, result): if self.set_status: result.status = elem.get('status', 'FAIL') - if 'start' in elem.attrib: + if 'start' in elem.attrib: # RF >= 7 result.start_time = elem.attrib['start'] result.elapsed_time = float(elem.attrib['elapsed']) - else: # RF < 7.0 compatibility - result.starttime = self._timestamp(elem, 'starttime') - result.endtime = self._timestamp(elem, 'endtime') + else: # RF < 7 + result.start_time = self._legacy_timestamp(elem, 'starttime') + result.end_time = self._legacy_timestamp(elem, 'endtime') if elem.text: result.message = elem.text @@ -419,16 +437,6 @@ def get_child_handler(self, tag): return ErrorMessageHandler() -class ErrorMessageHandler(ElementHandler): - - def end(self, elem, result): - html_true = ('true', 'yes') # 'yes' is compatibility for RF < 4. - result.messages.create(elem.text or '', - elem.get('level', 'INFO'), - elem.get('html') in html_true, - self._timestamp(elem, 'timestamp')) - - @ElementHandler.register class StatisticsHandler(ElementHandler): tag = 'statistics' diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index abc68dc756a..72fcfe6a5be 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -17,7 +17,7 @@ from robot.errors import (ExecutionFailed, ExecutionStatus, DataError, HandlerExecutionFailed) -from robot.utils import ErrorDetails, get_timestamp +from robot.utils import ErrorDetails from .modelcombiner import ModelCombiner diff --git a/utest/model/test_message.py b/utest/model/test_message.py index be654c48131..dca63bd88e4 100644 --- a/utest/model/test_message.py +++ b/utest/model/test_message.py @@ -1,4 +1,5 @@ import unittest +from datetime import datetime from robot.model import Message from robot.result import Keyword @@ -8,6 +9,17 @@ class TestMessage(unittest.TestCase): + def test_timestamp(self): + dt = datetime.now() + assert_equal(Message().timestamp, None) + assert_equal(Message(timestamp=dt).timestamp, dt) + assert_equal(Message(timestamp=dt.isoformat()).timestamp, dt) + msg = Message() + msg.timestamp = dt + assert_equal(msg.timestamp, dt) + msg.timestamp = dt.isoformat() + assert_equal(msg.timestamp, dt) + def test_slots(self): assert_raises(AttributeError, setattr, Message(), 'attr', 'value') diff --git a/utest/output/test_filelogger.py b/utest/output/test_filelogger.py index df8c4595846..abbf0c94583 100644 --- a/utest/output/test_filelogger.py +++ b/utest/output/test_filelogger.py @@ -1,46 +1,39 @@ -from io import StringIO import unittest -import time +from io import StringIO from robot.output.filelogger import FileLogger -from robot.utils import robottime from robot.utils.asserts import assert_equal -from robot.utils.robottime import TimestampCache -class _FakeTimeCache(TimestampCache): +class LoggerSub(FileLogger): - def __init__(self): - TimestampCache.__init__(self) + def _get_writer(self, path): + return StringIO() - def _get_epoch(self): - return 1613230353.123 + time.timezone + def message(self, msg): + msg.timestamp = '2023-09-08 12:16:00.123456' + super().message(msg) class TestFileLogger(unittest.TestCase): def setUp(self): - robottime.TIMESTAMP_CACHE = _FakeTimeCache() - FileLogger._get_writer = lambda *args: StringIO() - self.logger = FileLogger('whatever', 'INFO') - - def tearDown(self): - robottime.TIMESTAMP_CACHE = TimestampCache() + self.logger = LoggerSub('whatever', 'INFO') def test_write(self): self.logger.write('my message', 'INFO') - expected = '20210213 15:32:33.123 | INFO | my message\n' + expected = '2023-09-08 12:16:00.123456 | INFO | my message\n' self._verify_message(expected) self.logger.write('my 2nd msg\nwith 2 lines', 'ERROR') - expected += '20210213 15:32:33.123 | ERROR | my 2nd msg\nwith 2 lines\n' + expected += '2023-09-08 12:16:00.123456 | ERROR | my 2nd msg\nwith 2 lines\n' self._verify_message(expected) def test_write_helpers(self): self.logger.info('my message') - expected = '20210213 15:32:33.123 | INFO | my message\n' + expected = '2023-09-08 12:16:00.123456 | INFO | my message\n' self._verify_message(expected) self.logger.warn('my 2nd msg\nwith 2 lines') - expected += '20210213 15:32:33.123 | WARN | my 2nd msg\nwith 2 lines\n' + expected += '2023-09-08 12:16:00.123456 | WARN | my 2nd msg\nwith 2 lines\n' self._verify_message(expected) def test_set_level(self): @@ -48,7 +41,7 @@ def test_set_level(self): self._verify_message('') self.logger.set_level('DEBUG') self.logger.write('msg', 'DEBUG') - self._verify_message('20210213 15:32:33.123 | DEBUG | msg\n') + self._verify_message('2023-09-08 12:16:00.123456 | DEBUG | msg\n') def _verify_message(self, expected): assert_equal(self.logger._writer.getvalue(), expected) diff --git a/utest/output/test_logger.py b/utest/output/test_logger.py index 342b1dea6cc..72a914ab8e1 100644 --- a/utest/output/test_logger.py +++ b/utest/output/test_logger.py @@ -48,7 +48,7 @@ def test_write_to_one_logger(self): logger = LoggerMock(('Hello, world!', 'INFO')) self.logger.register_logger(logger) self.logger.write('Hello, world!', 'INFO') - assert_true(logger.msg.timestamp.startswith('20')) + assert_true(logger.msg.timestamp.year >= 2023) def test_write_to_one_logger_with_trace_level(self): logger = LoggerMock(('expected message', 'TRACE')) diff --git a/utest/output/test_stdout_splitter.py b/utest/output/test_stdout_splitter.py index f9c1fa27b8a..d50e320a8a3 100644 --- a/utest/output/test_stdout_splitter.py +++ b/utest/output/test_stdout_splitter.py @@ -1,5 +1,6 @@ import unittest import time +from datetime import datetime from robot.utils.asserts import assert_equal from robot.utils import format_time @@ -78,8 +79,7 @@ def _verify_message(self, splitter, msg='X', level='INFO', html=False, assert_equal(message.level, level) assert_equal(message.html, html) if timestamp: - assert_equal(message.timestamp, - format_time(timestamp, millissep='.')) + assert_equal(message.timestamp, datetime.fromtimestamp(timestamp)) if __name__ == '__main__': diff --git a/utest/reporting/test_jsbuildingcontext.py b/utest/reporting/test_jsbuildingcontext.py index 76572873775..6b44651f375 100644 --- a/utest/reporting/test_jsbuildingcontext.py +++ b/utest/reporting/test_jsbuildingcontext.py @@ -1,7 +1,8 @@ import random import unittest -from robot.output.loggerhelper import LEVELS +from datetime import datetime +from robot.output.loggerhelper import LEVELS from robot.reporting.jsmodelbuilders import JsBuildingContext from robot.utils.asserts import assert_equal @@ -43,12 +44,12 @@ def setUp(self): self._context = JsBuildingContext() def test_timestamp(self): - assert_equal(self._context.timestamp('20110603 12:00:00.042'), 0) - assert_equal(self._context.timestamp('20110603 12:00:00.043'), 1) - assert_equal(self._context.timestamp('20110603 12:00:00.000'), -42) - assert_equal(self._context.timestamp('20110603 12:00:01.041'), 999) - assert_equal(self._context.timestamp('20110604 12:00:00.042'), - 24 * 60 * 60 * 1000) + assert_equal(self._context.timestamp(datetime(2011, 6, 3, 12, 0, 0, 42000)), 0) + assert_equal(self._context.timestamp(datetime(2011, 6, 3, 12, 0, 0, 43000)), 1) + assert_equal(self._context.timestamp(datetime(2011, 6, 3, 12, 0, 0, 0)), -42) + assert_equal(self._context.timestamp(datetime(2011, 6, 3, 12, 0, 1, 41000)), 999) + assert_equal(self._context.timestamp(datetime(2011, 6, 4, 12, 0, 0, 42000)), + 24 * 60 * 60 * 1000) def test_none_timestamp(self): assert_equal(self._context.timestamp(None), None) diff --git a/utest/result/golden.xml b/utest/result/golden.xml index 2bb59723aba..4fb366003e3 100644 --- a/utest/result/golden.xml +++ b/utest/result/golden.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<robot generator="Rebot 7.0.dev1 (Python 3.12.0rc2 on linux)" generated="20230908 00:06:39.213" rpa="false" schemaversion="5"> +<robot generator="Rebot 7.0.dev1 (Python 3.12.0rc2 on linux)" generated="2023-09-08T12:01:47.906104" rpa="false" schemaversion="5"> <suite id="s1" name="Normal" source="normal.html"> <kw name="my setup" type="SETUP"> <timeout value="1 year"/> @@ -9,7 +9,7 @@ <kw name="Log" library="BuiltIn"> <arg>Test 1</arg> <doc>Logs the given message with the given level.</doc> -<msg timestamp="20111024 13:41:20.927" level="INFO">Test 1</msg> +<msg time="2011-10-24T13:41:20.927000" level="INFO">Test 1</msg> <status status="PASS" start="2011-10-24T13:41:20.926000" elapsed="0.002"/> </kw> <kw name="logs on trace"> @@ -31,7 +31,7 @@ <kw name="Log" library="BuiltIn"> <arg>${x}</arg> <doc>Logs the given message with the given level.</doc> -<msg timestamp="20210329 17:05:45.267" level="INFO">not in source</msg> +<msg time="2021-03-29T17:05:45.267000" level="INFO">not in source</msg> <status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> <status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> @@ -76,6 +76,6 @@ </suite> </statistics> <errors> -<msg timestamp="20111024 13:41:20.873" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> +<msg time="2011-10-24T13:41:20.873000" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> </errors> </robot> diff --git a/utest/result/goldenTwice.xml b/utest/result/goldenTwice.xml index 0a3aadfb74c..a0c15eb2705 100644 --- a/utest/result/goldenTwice.xml +++ b/utest/result/goldenTwice.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<robot generator="Rebot 7.0.dev1 (Python 3.12.0rc2 on linux)" generated="20230908 00:09:51.199" rpa="false" schemaversion="5"> +<robot generator="Rebot 7.0.dev1 (Python 3.12.0rc2 on linux)" generated="2023-09-08T12:01:30.419054" rpa="false" schemaversion="5"> <suite id="s1" name="Normal & Normal"> <suite id="s1-s1" name="Normal" source="normal.html"> <kw name="my setup" type="SETUP"> @@ -10,7 +10,7 @@ <kw name="Log" library="BuiltIn"> <arg>Test 1</arg> <doc>Logs the given message with the given level.</doc> -<msg timestamp="20111024 13:41:20.927" level="INFO">Test 1</msg> +<msg time="2011-10-24T13:41:20.927000" level="INFO">Test 1</msg> <status status="PASS" start="2011-10-24T13:41:20.926000" elapsed="0.002"/> </kw> <kw name="logs on trace"> @@ -32,7 +32,7 @@ <kw name="Log" library="BuiltIn"> <arg>${x}</arg> <doc>Logs the given message with the given level.</doc> -<msg timestamp="20210329 17:05:45.267" level="INFO">not in source</msg> +<msg time="2021-03-29T17:05:45.267000" level="INFO">not in source</msg> <status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> <status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> @@ -74,7 +74,7 @@ <kw name="Log" library="BuiltIn"> <arg>Test 1</arg> <doc>Logs the given message with the given level.</doc> -<msg timestamp="20111024 13:41:20.927" level="INFO">Test 1</msg> +<msg time="2011-10-24T13:41:20.927000" level="INFO">Test 1</msg> <status status="PASS" start="2011-10-24T13:41:20.926000" elapsed="0.002"/> </kw> <kw name="logs on trace"> @@ -96,7 +96,7 @@ <kw name="Log" library="BuiltIn"> <arg>${x}</arg> <doc>Logs the given message with the given level.</doc> -<msg timestamp="20210329 17:05:45.267" level="INFO">not in source</msg> +<msg time="2021-03-29T17:05:45.267000" level="INFO">not in source</msg> <status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> </kw> <status status="PASS" start="2021-03-29T17:05:45.266000" elapsed="0.001"/> @@ -145,7 +145,7 @@ </suite> </statistics> <errors> -<msg timestamp="20111024 13:41:20.873" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> -<msg timestamp="20111024 13:41:20.873" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> +<msg time="2011-10-24T13:41:20.873000" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> +<msg time="2011-10-24T13:41:20.873000" level="ERROR">Error in file 'normal.html' in table 'Settings': Resource file 'nope' does not exist.</msg> </errors> </robot> diff --git a/utest/result/test_resultbuilder.py b/utest/result/test_resultbuilder.py index 6dcb8bf0236..17e9c31c0b8 100644 --- a/utest/result/test_resultbuilder.py +++ b/utest/result/test_resultbuilder.py @@ -1,6 +1,7 @@ import os import unittest import tempfile +from datetime import datetime from io import StringIO from pathlib import Path @@ -73,7 +74,7 @@ def test_message_is_built(self): message = self.test.body[0].messages[0] assert_equal(message.message, 'Test 1') assert_equal(message.level, 'INFO') - assert_equal(message.timestamp, '20111024 13:41:20.927') + assert_equal(message.timestamp, datetime(2011, 10, 24, 13, 41, 20, 927000)) def test_for_is_built(self): for_ = self.test.body[2] From d537bac680b14573f96f2a820e59621330907a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 8 Sep 2023 15:26:30 +0300 Subject: [PATCH 0676/1592] Test cleanup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid old `starttime`, `endtime` and `elapsedtime` and use new `start_time`, `end_time´ and `elapsed_time` instead. Related to #4258. --- atest/resources/atest_resource.robot | 37 +++++++---- atest/resources/rebot_resource.robot | 20 +++--- atest/robot/output/processing_output.robot | 13 ++-- atest/robot/rebot/combine.robot | 61 +++++++++---------- atest/robot/rebot/filter_by_names.robot | 39 ++++++------ atest/robot/rebot/merge.robot | 8 +-- .../robot/running/failures_in_teardown.robot | 2 +- atest/robot/running/if/if_else.robot | 8 +-- atest/robot/running/timeouts.robot | 7 +-- atest/robot/running/while/while.robot | 4 +- .../standard_libraries/builtin/sleep.robot | 3 +- .../builtin/wait_until_keyword_succeeds.robot | 6 +- .../process/terminate_process.robot | 2 +- .../tags/include_and_exclude_with_rebot.robot | 33 +++++----- 14 files changed, 121 insertions(+), 122 deletions(-) diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index f239dbe9cea..65870a9d05a 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -304,19 +304,34 @@ Check Names Should Be Equal ${item.longname} ${longprefix}${name} Timestamp Should Be Valid - [Arguments] ${time} - Log ${time} - Should Not Be Equal ${time} ${None} - Should Match Regexp ${time} ^20\\d{6} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}$ Not valid timestamp + [Arguments] ${timestamp} + Should Be True isinstance($timestamp, datetime.datetime) and $timestamp.year > 2000 + +Timestamp Should Be + [Arguments] ${timestamp} ${expected} + IF $expected is not None + ${expected} = Evaluate datetime.datetime.fromisoformat('${expected}') + END + Should Be Equal ${timestamp} ${expected} Elapsed Time Should Be Valid - [Arguments] ${time} - Log ${time} - Should Be True isinstance($time, int) Not valid elapsed time: ${time} - # On CI elapsed time has sometimes been negative. We cannot control system time there, - # so better to log a warning than fail the test in that case. - IF $time < 0 - ... Log Negative elapsed time '${time}'. Someone messing with system time? WARN + [Arguments] ${elapsed} ${minimum}=0 ${maximum}=${{sys.maxsize}} + Should Be True isinstance($elapsed, datetime.timedelta) + Should Be True $elapsed.total_seconds() >= ${minimum} + Should Be True $elapsed.total_seconds() <= ${maximum} + +Elapsed Time Should Be + [Arguments] ${elapsed} ${expected} + IF isinstance($expected, str) + ${expected} = Evaluate ${expected} + END + Should Be Equal As Numbers ${elapsed.total_seconds()} ${expected} + +Times Should Be + [Arguments] ${item} ${start} ${end} ${elapsed} + Timestamp Should Be ${item.start_time} ${start} + Timestamp Should Be ${item.end_time} ${end} + Elapsed Time Should Be ${item.elapsed_time} ${elapsed} Previous test should have passed [Arguments] ${name} diff --git a/atest/resources/rebot_resource.robot b/atest/resources/rebot_resource.robot index b4dc7dc8849..83291b2cbd5 100644 --- a/atest/resources/rebot_resource.robot +++ b/atest/resources/rebot_resource.robot @@ -10,16 +10,10 @@ ${ORIG_ELAPSED} -- ;; -- Create Output With Robot [Arguments] ${outputname} ${options} ${sources} Run Tests ${options} ${sources} - Timestamp Should Be Valid ${SUITE.starttime} - Timestamp Should Be Valid ${SUITE.endtime} - Elapsed Time Should Be Valid ${SUITE.elapsedtime} - Set Suite Variable $ORIG_START ${SUITE.starttime} - Set Suite Variable $ORIG_END ${SUITE.endtime} - Set Suite Variable $ORIG_ELAPSED ${SUITE.elapsedtime} - Run Keyword If $outputname Move File ${OUTFILE} ${outputname} - -Check times - [Arguments] ${item} ${start} ${end} ${elapsed} - Should Be Equal ${item.starttime} ${start} - Should Be Equal ${item.endtime} ${end} - Should Be Equal As Integers ${item.elapsedtime} ${elapsed} + Timestamp Should Be Valid ${SUITE.start_time} + Timestamp Should Be Valid ${SUITE.end_time} + Elapsed Time Should Be Valid ${SUITE.elapsed_time} + Set Suite Variable $ORIG_START ${SUITE.start_time} + Set Suite Variable $ORIG_END ${SUITE.end_time} + Set Suite Variable $ORIG_ELAPSED ${SUITE.elapsed_time} + IF $outputname Move File ${OUTFILE} ${outputname} diff --git a/atest/robot/output/processing_output.robot b/atest/robot/output/processing_output.robot index 0a6d72b6baa..86426fb8e6b 100644 --- a/atest/robot/output/processing_output.robot +++ b/atest/robot/output/processing_output.robot @@ -61,16 +61,15 @@ Check Minimal Suite Defaults Check Normal Suite Times [Arguments] ${suite} - Timestamp Should Be Valid ${suite.starttime} - Timestamp Should Be Valid ${suite.endtime} - Elapsed Time Should Be Valid ${suite.elapsedtime} - Should Be True ${suite.elapsedtime} >= 1 + Timestamp Should Be Valid ${suite.start_time} + Timestamp Should Be Valid ${suite.end_time} + Elapsed Time Should Be Valid ${suite.elapsed_time} minimum=0.001 Check Minimal Suite Times [Arguments] ${suite} - Should Be Equal ${suite.starttime} ${NONE} - Should Be Equal ${suite.endtime} ${NONE} - Should Be Equal ${suite.elapsedtime} ${0} + Should Be Equal ${suite.start_time} ${NONE} + Should Be Equal ${suite.end_time} ${NONE} + Elapsed Time Should Be ${suite.elapsed_time} 0 Check Suite Defaults [Arguments] ${suite} ${message}= ${setup}=${None} ${teardown}=${None} diff --git a/atest/robot/rebot/combine.robot b/atest/robot/rebot/combine.robot index 85b9e3d0d8c..f8aedbd16ad 100644 --- a/atest/robot/rebot/combine.robot +++ b/atest/robot/rebot/combine.robot @@ -81,39 +81,34 @@ Suite Metadata Should Be Equal ${SUITE2.metadata['Other Meta']} Another value Suite Times - Should Be Equal ${SUITE3.starttime} ${NONE} - Should Be Equal ${SUITE3.endtime} ${NONE} - Elapsed Time Should Be Valid ${SUITE3.elapsedtime} - Should Be True ${SUITE3.elapsedtime} == ${MILLIS1} + ${MILLIS2} + 9999 - Timestamp Should Be Valid ${SUITE3.suites[0].starttime} - Timestamp Should Be Valid ${SUITE3.suites[0].endtime} - Elapsed Time Should Be Valid ${SUITE3.suites[0].elapsedtime} - Should Be Equal ${SUITE3.suites[0].elapsedtime} ${MILLIS1} - Timestamp Should Be Valid ${SUITE3.suites[1].starttime} - Timestamp Should Be Valid ${SUITE3.suites[1].endtime} - Elapsed Time Should Be Valid ${SUITE3.suites[1].elapsedtime} - Should Be Equal ${SUITE3.suites[1].elapsedtime} ${MILLIS2} - Should Be Equal ${SUITE3.suites[2].starttime} 20061227 11:59:59.000 - Should Be Equal ${SUITE3.suites[2].endtime} 20061227 12:00:08.999 - Should Be Equal ${SUITE3.suites[2].elapsedtime} ${9999} + Should Be Equal ${SUITE3.start_time} ${NONE} + Should Be Equal ${SUITE3.end_time} ${NONE} + Elapsed Time Should Be ${SUITE3.elapsed_time} ${ELAPSED1} + ${ELAPSED2} + 9.999 + Timestamp Should Be Valid ${SUITE3.suites[0].start_time} + Timestamp Should Be Valid ${SUITE3.suites[0].end_time} + Elapsed Time Should Be ${SUITE3.suites[0].elapsed_time} ${ELAPSED1} + Timestamp Should Be Valid ${SUITE3.suites[1].start_time} + Timestamp Should Be Valid ${SUITE3.suites[1].end_time} + Elapsed Time Should Be ${SUITE3.suites[1].elapsed_time} ${ELAPSED2} + Timestamp Should Be ${SUITE3.suites[2].start_time} 2006-12-27 11:59:59 + Timestamp Should Be ${SUITE3.suites[2].end_time} 2006-12-27 12:00:08.999 + Elapsed Time Should Be ${SUITE3.suites[2].elapsed_time} 9.999 Suite Times In Recombine - Should Be Equal ${SUITE4.starttime} ${NONE} - Should Be Equal ${SUITE4.endtime} ${NONE} - Should Be True ${SUITE4.elapsedtime} == 9999 + ${MILLIS1} + ${MILLIS2} - Should Be Equal ${SUITE4.suites[0].starttime} 20061227 11:59:59.000 - Should Be Equal ${SUITE4.suites[0].endtime} 20061227 12:00:08.999 - Should Be Equal ${SUITE4.suites[0].elapsedtime} ${9999} - Should Be Equal ${SUITE4.suites[1].starttime} ${NONE} - Should Be Equal ${SUITE4.suites[1].endtime} ${NONE} - Timestamp Should Be Valid ${SUITE4.suites[1].suites[0].starttime} - Timestamp Should Be Valid ${SUITE4.suites[1].suites[0].endtime} - Elapsed Time Should Be Valid ${SUITE4.suites[1].suites[0].elapsedtime} - Should Be Equal ${SUITE4.suites[1].suites[0].elapsedtime} ${MILLIS1} - Timestamp Should Be Valid ${SUITE4.suites[1].suites[1].starttime} - Timestamp Should Be Valid ${SUITE4.suites[1].suites[1].endtime} - Elapsed Time Should Be Valid ${SUITE4.suites[1].suites[1].elapsedtime} - Should Be Equal ${SUITE4.suites[1].suites[1].elapsedtime} ${MILLIS2} + Should Be Equal ${SUITE4.start_time} ${NONE} + Should Be Equal ${SUITE4.end_time} ${NONE} + Elapsed Time Should Be ${SUITE4.elapsed_time} ${ELAPSED1} + ${ELAPSED2} + 9.999 + Timestamp Should Be ${SUITE4.suites[0].start_time} 2006-12-27 11:59:59 + Timestamp Should Be ${SUITE4.suites[0].end_time} 2006-12-27 12:00:08.999 + Elapsed Time Should Be ${SUITE4.suites[0].elapsed_time} 9.999 + Should Be Equal ${SUITE4.suites[1].start_time} ${NONE} + Should Be Equal ${SUITE4.suites[1].end_time} ${NONE} + Timestamp Should Be Valid ${SUITE4.suites[1].suites[0].start_time} + Timestamp Should Be Valid ${SUITE4.suites[1].suites[0].end_time} + Elapsed Time Should Be ${SUITE4.suites[1].suites[0].elapsed_time} ${ELAPSED1} + Timestamp Should Be Valid ${SUITE4.suites[1].suites[1].start_time} + Timestamp Should Be Valid ${SUITE4.suites[1].suites[1].end_time} + Elapsed Time Should Be ${SUITE4.suites[1].suites[1].elapsed_time} ${ELAPSED2} Combined Suite Names Are Correct In Statistics ${suites} = Get Suite Stat Nodes ${COMB OUT 1} @@ -142,11 +137,11 @@ Create inputs for Rebot Create first input for Rebot Create Output With Robot ${TEMP OUT 1} ${EMPTY} misc/pass_and_fail.robot - Set Suite Variable $MILLIS1 ${ORIG ELAPSED} + Set Suite Variable $ELAPSED1 ${ORIG ELAPSED.total_seconds()} Create second input for Rebot Create Output With Robot ${TEMP OUT 2} ${EMPTY} misc/normal.robot - Set Suite Variable $MILLIS2 ${ORIG ELAPSED} + Set Suite Variable $ELAPSED2 ${ORIG ELAPSED.total_seconds()} Combine without options Run Rebot ${EMPTY} ${TEMP OUT 1} ${TEMP OUT 2} diff --git a/atest/robot/rebot/filter_by_names.robot b/atest/robot/rebot/filter_by_names.robot index 66d9275c572..2af7b31b481 100644 --- a/atest/robot/rebot/filter_by_names.robot +++ b/atest/robot/rebot/filter_by_names.robot @@ -79,24 +79,24 @@ ${INPUT FILE} %{TEMPDIR}${/}robot-test-file.xml Elapsed Time [Documentation] Test setting start, end and elapsed times correctly when filtering by tags - Comment 1) Rebot hand-edited output with predefined times and check that times are read correctly. (A sanity check) + # 1) Rebot hand-edited output with predefined times and check that times are read correctly. (A sanity check) Run Rebot ${EMPTY} rebot${/}times.xml - Check Times ${SUITE.tests[0]} 20061227 12:00:00.000 20061227 12:00:01.000 1000 # Incl-1 - Check Times ${SUITE.tests[1]} 20061227 12:00:01.000 20061227 12:00:03.000 2000 # Incl-12 - Check Times ${SUITE.tests[2]} 20061227 12:00:03.000 20061227 12:00:07.000 4000 # Incl-123 - Check Times ${SUITE.tests[3]} 20061227 12:00:07.000 20061227 12:00:07.001 0001 # Excl-1 - Check Times ${SUITE.tests[4]} 20061227 12:00:07.001 20061227 12:00:07.003 0002 # Excl-12 - Check Times ${SUITE.tests[5]} 20061227 12:00:07.003 20061227 12:00:07.007 0004 # Excl-123 - Check Times ${SUITE} 20061227 11:59:59.000 20061227 12:00:08.999 9999 # Suite + Times Should Be ${SUITE.tests[0]} 2006-12-27 12:00:00.000 2006-12-27 12:00:01.000 1.000 # Incl-1 + Times Should Be ${SUITE.tests[1]} 2006-12-27 12:00:01.000 2006-12-27 12:00:03.000 2.000 # Incl-12 + Times Should Be ${SUITE.tests[2]} 2006-12-27 12:00:03.000 2006-12-27 12:00:07.000 4.000 # Incl-123 + Times Should Be ${SUITE.tests[3]} 2006-12-27 12:00:07.000 2006-12-27 12:00:07.001 0.001 # Excl-1 + Times Should Be ${SUITE.tests[4]} 2006-12-27 12:00:07.001 2006-12-27 12:00:07.003 0.002 # Excl-12 + Times Should Be ${SUITE.tests[5]} 2006-12-27 12:00:07.003 2006-12-27 12:00:07.007 0.004 # Excl-123 + Times Should Be ${SUITE} 2006-12-27 11:59:59.000 2006-12-27 12:00:08.999 9.999 # Suite Should Be Equal As Integers ${SUITE.test_count} 6 - Comment 2) Filter output created in earlier step and check that times are set accordingly. + # 2) Filter output created in earlier step and check that times are set accordingly. Copy Previous Outfile Run Rebot --test Exc* --test Incl-1 ${OUTFILE COPY} - Check Times ${SUITE.tests[0]} 20061227 12:00:00.000 20061227 12:00:01.000 1000 # Incl-1 - Check Times ${SUITE.tests[1]} 20061227 12:00:07.000 20061227 12:00:07.001 0001 # Excl-1 - Check Times ${SUITE.tests[2]} 20061227 12:00:07.001 20061227 12:00:07.003 0002 # Excl-12 - Check Times ${SUITE.tests[3]} 20061227 12:00:07.003 20061227 12:00:07.007 0004 # Excl-123 - Check Times ${SUITE} ${NONE} ${NONE} 1007 # Suite + Times Should Be ${SUITE.tests[0]} 2006-12-27 12:00:00.000 2006-12-27 12:00:01.000 1.000 # Incl-1 + Times Should Be ${SUITE.tests[1]} 2006-12-27 12:00:07.000 2006-12-27 12:00:07.001 0.001 # Excl-1 + Times Should Be ${SUITE.tests[2]} 2006-12-27 12:00:07.001 2006-12-27 12:00:07.003 0.002 # Excl-12 + Times Should Be ${SUITE.tests[3]} 2006-12-27 12:00:07.003 2006-12-27 12:00:07.007 0.004 # Excl-123 + Times Should Be ${SUITE} ${NONE} ${NONE} 1.007 # Suite Should Be Equal As Integers ${SUITE.test_count} 4 *** Keywords *** @@ -106,9 +106,9 @@ Create Input File Remove Temps Remove Directory ${MYOUTDIR} recursive - Remove FIle ${INPUT FILE} + Remove File ${INPUT FILE} -Run and check Tests +Run and Check Tests [Arguments] ${params} @{tests} Run Rebot ${params} ${INPUT FILE} Stderr Should Be Empty @@ -118,10 +118,9 @@ Run and check Tests Check Stats Should Be True ${SUITE.statistics.failed} == 0 - Should Be Equal ${SUITE.starttime} ${NONE} - Should Be Equal ${SUITE.endtime} ${NONE} - Elapsed Time Should Be Valid ${SUITE.elapsedtime} - Should Be True ${SUITE.elapsedtime} <= ${ORIGELAPSED} + Should Be Equal ${SUITE.start_time} ${NONE} + Should Be Equal ${SUITE.end_time} ${NONE} + Elapsed Time Should Be Valid ${SUITE.elapsed_time} maximum=${ORIG_ELAPSED.total_seconds()} Run and Check Suites [Arguments] ${params} @{suites} diff --git a/atest/robot/rebot/merge.robot b/atest/robot/rebot/merge.robot index 4f03574c4bd..b7beda3bbfe 100644 --- a/atest/robot/rebot/merge.robot +++ b/atest/robot/rebot/merge.robot @@ -253,15 +253,15 @@ Merge should have failed Timestamps should be cleared [Arguments] @{suites} FOR ${suite} IN @{suites} - Should Be Equal ${suite.starttime} ${None} - Should Be Equal ${suite.endtime} ${None} + Should Be Equal ${suite.start_time} ${None} + Should Be Equal ${suite.end_time} ${None} END Timestamps should be set [Arguments] @{suites} FOR ${suite} IN @{suites} - Timestamp Should Be Valid ${suite.starttime} - Timestamp Should Be Valid ${suite.endtime} + Timestamp Should Be Valid ${suite.start_time} + Timestamp Should Be Valid ${suite.end_time} END Create expected merge message header diff --git a/atest/robot/running/failures_in_teardown.robot b/atest/robot/running/failures_in_teardown.robot index a38425dee86..8ab85190675 100644 --- a/atest/robot/running/failures_in_teardown.robot +++ b/atest/robot/running/failures_in_teardown.robot @@ -22,7 +22,7 @@ Failure In For Loop Execution Continues After Test Timeout ${tc} = Check Test Case ${TESTNAME} - Should Be True ${tc.elapsedtime} >= 300 + Elapsed Time Should Be Valid ${tc.elapsed_time} minimum=0.3 Execution Stops After Keyword Timeout ${tc} = Check Test Case ${TESTNAME} diff --git a/atest/robot/running/if/if_else.robot b/atest/robot/running/if/if_else.robot index 216faad9739..c1d5689808d 100644 --- a/atest/robot/running/if/if_else.robot +++ b/atest/robot/running/if/if_else.robot @@ -41,7 +41,7 @@ If failing in else keyword Expression evaluation time is included in elapsed time ${tc} = Check Test Case ${TESTNAME} - Should Be True ${tc.body[0].elapsedtime} >= 200 - Should Be True ${tc.body[0].body[0].elapsedtime} >= 100 - Should Be True ${tc.body[0].body[1].elapsedtime} >= 100 - Should Be True ${tc.body[0].body[2].elapsedtime} < 1000 + Elapsed Time Should Be Valid ${tc.body[0].elapsed_time} minimum=0.2 + Elapsed Time Should Be Valid ${tc.body[0].body[0].elapsed_time} minimum=0.1 + Elapsed Time Should Be Valid ${tc.body[0].body[1].elapsed_time} minimum=0.1 + Elapsed Time Should Be Valid ${tc.body[0].body[2].elapsed_time} maximum=1.0 diff --git a/atest/robot/running/timeouts.robot b/atest/robot/running/timeouts.robot index d668abd95da..d106d24919b 100644 --- a/atest/robot/running/timeouts.robot +++ b/atest/robot/running/timeouts.robot @@ -162,13 +162,13 @@ Zero timeout is ignored ${tc} = Check Test Case ${TEST NAME} Should Be Equal ${tc.timeout} 0 seconds Should Be Equal ${tc.kws[0].timeout} 0 seconds - Should Be True ${tc.kws[0].elapsedtime} > 99 + Elapsed Time Should Be Valid ${tc.kws[0].elapsed_time} minimum=0.099 Negative timeout is ignored ${tc} = Check Test Case ${TEST NAME} Should Be Equal ${tc.kws[0].timeout} - 1 second Should Be Equal ${tc.kws[0].timeout} - 1 second - Should Be True ${tc.kws[0].elapsedtime} > 99 + Elapsed Time Should Be Valid ${tc.kws[0].elapsed_time} minimum=0.099 Invalid test timeout Check Test Case ${TEST NAME} @@ -181,8 +181,7 @@ Timeout should have been active [Arguments] ${kw} ${timeout} ${msg count} ${exceeded}=False ${type}=Test Check Log Message ${kw.msgs[0]} ${type} timeout ${timeout} active. * left. DEBUG pattern=True Length Should Be ${kw.msgs} ${msg count} - Run Keyword If ${exceeded} - ... Timeout should have exceeded ${kw} ${timeout} ${type} + IF ${exceeded} Timeout should have exceeded ${kw} ${timeout} ${type} Keyword timeout should have been active [Arguments] ${kw} ${timeout} ${msg count} ${exceeded}=False diff --git a/atest/robot/running/while/while.robot b/atest/robot/running/while/while.robot index 45b028f951f..899d6881da6 100644 --- a/atest/robot/running/while/while.robot +++ b/atest/robot/running/while/while.robot @@ -50,5 +50,5 @@ With RETURN Condition evaluation time is included in elapsed time ${loop} = Check WHILE loop PASS 1 - Should Be True ${loop.elapsedtime} >= 200 - Should Be True ${loop.body[0].elapsedtime} >= 100 + Elapsed Time Should Be Valid ${loop.elapsed_time} minimum=0.2 + Elapsed Time Should Be Valid ${loop.body[0].elapsed_time} minimum=0.1 diff --git a/atest/robot/standard_libraries/builtin/sleep.robot b/atest/robot/standard_libraries/builtin/sleep.robot index fbfc2b8a254..0cffda8b948 100644 --- a/atest/robot/standard_libraries/builtin/sleep.robot +++ b/atest/robot/standard_libraries/builtin/sleep.robot @@ -24,5 +24,4 @@ Invalid Time Does Not Cause Uncatchable Error Can Stop Sleep With Timeout ${tc}= Check Test Case ${TESTNAME} - Should Be True ${tc.elapsedtime} < 10000 - + Elapsed Time Should Be Valid ${tc.elapsed_time} maximum=10 diff --git a/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot b/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot index a8ab7e8bbda..85a08da31df 100644 --- a/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot +++ b/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot @@ -101,17 +101,17 @@ Variable Values Should Not Be Visible In Keyword Arguments Strict retry interval ${tc} = Check Test Case ${TESTNAME} Length Should Be ${tc.body[0].kws} 4 - Should Be True 300 <= ${tc.body[0].elapsedtime} < 900 + Elapsed Time Should Be Valid ${tc.body[0].elapsed_time} minimum=0.3 maximum=0.9 Fail with strict retry interval ${tc} = Check Test Case ${TESTNAME} Length Should Be ${tc.body[0].kws} 3 - Should Be True 200 <= ${tc.body[0].elapsedtime} < 600 + Elapsed Time Should Be Valid ${tc.body[0].elapsed_time} minimum=0.2 maximum=0.6 Strict retry interval violation ${tc} = Check Test Case ${TESTNAME} Length Should Be ${tc.body[0].kws} 4 - Should Be True 400 <= ${tc.body[0].elapsedtime} < 1200 + Elapsed Time Should Be Valid ${tc.body[0].elapsed_time} minimum=0.4 maximum=1.2 FOR ${index} IN 1 3 5 7 Check Log Message ${tc.body[0].body[${index}]} ... Keyword execution time ??? milliseconds is longer than retry interval 100 milliseconds. diff --git a/atest/robot/standard_libraries/process/terminate_process.robot b/atest/robot/standard_libraries/process/terminate_process.robot index 4263a650a4f..ced07f0305d 100644 --- a/atest/robot/standard_libraries/process/terminate_process.robot +++ b/atest/robot/standard_libraries/process/terminate_process.robot @@ -32,7 +32,7 @@ Kill process when terminate fails Check Log Message ${tc.kws[5].msgs[0]} Gracefully terminating process. Check Log Message ${tc.kws[5].msgs[1]} Graceful termination failed. Check Log Message ${tc.kws[5].msgs[2]} Forcefully killing process. - Should Be True ${tc.elapsedtime} >= 2000 + Elapsed Time Should Be Valid ${tc.elapsed_time} minimum=2 Terminating already terminated process is ok Check Test Case ${TESTNAME} diff --git a/atest/robot/tags/include_and_exclude_with_rebot.robot b/atest/robot/tags/include_and_exclude_with_rebot.robot index ac638db420f..b8419e2d51c 100644 --- a/atest/robot/tags/include_and_exclude_with_rebot.robot +++ b/atest/robot/tags/include_and_exclude_with_rebot.robot @@ -128,21 +128,21 @@ Elapsed Time [Template] NONE # Rebot hand-edited output with predefined times and check that times are read correctly. Run Rebot ${EMPTY} rebot/times.xml - Check Times ${SUITE.tests[0]} 20061227 12:00:00.000 20061227 12:00:01.000 1000 - Check Times ${SUITE.tests[1]} 20061227 12:00:01.000 20061227 12:00:03.000 2000 - Check Times ${SUITE.tests[2]} 20061227 12:00:03.000 20061227 12:00:07.000 4000 - Check Times ${SUITE.tests[3]} 20061227 12:00:07.000 20061227 12:00:07.001 0001 - Check Times ${SUITE.tests[4]} 20061227 12:00:07.001 20061227 12:00:07.003 0002 - Check Times ${SUITE.tests[5]} 20061227 12:00:07.003 20061227 12:00:07.007 0004 - Check Times ${SUITE} 20061227 11:59:59.000 20061227 12:00:08.999 9999 + Times Should Be ${SUITE.tests[0]} 2006-12-27 12:00:00.000 2006-12-27 12:00:01.000 1.000 + Times Should Be ${SUITE.tests[1]} 2006-12-27 12:00:01.000 2006-12-27 12:00:03.000 2.000 + Times Should Be ${SUITE.tests[2]} 2006-12-27 12:00:03.000 2006-12-27 12:00:07.000 4.000 + Times Should Be ${SUITE.tests[3]} 2006-12-27 12:00:07.000 2006-12-27 12:00:07.001 0.001 + Times Should Be ${SUITE.tests[4]} 2006-12-27 12:00:07.001 2006-12-27 12:00:07.003 0.002 + Times Should Be ${SUITE.tests[5]} 2006-12-27 12:00:07.003 2006-12-27 12:00:07.007 0.004 + Times Should Be ${SUITE} 2006-12-27 11:59:59.000 2006-12-27 12:00:08.999 9.999 Length Should Be ${SUITE.tests} 6 # Filter ouput created in earlier step and check that times are set accordingly. Copy Previous Outfile Run Rebot --include incl2 --include excl3 ${OUTFILE COPY} - Check Times ${SUITE} ${NONE} ${NONE} 6004 - Check Times ${SUITE.tests[0]} 20061227 12:00:01.000 20061227 12:00:03.000 2000 - Check Times ${SUITE.tests[1]} 20061227 12:00:03.000 20061227 12:00:07.000 4000 - Check Times ${SUITE.tests[2]} 20061227 12:00:07.003 20061227 12:00:07.007 004 + Times Should Be ${SUITE} ${NONE} ${NONE} 6.004 + Times Should Be ${SUITE.tests[0]} 2006-12-27 12:00:01.000 2006-12-27 12:00:03.000 2.000 + Times Should Be ${SUITE.tests[1]} 2006-12-27 12:00:03.000 2006-12-27 12:00:07.000 4.000 + Times Should Be ${SUITE.tests[2]} 2006-12-27 12:00:07.003 2006-12-27 12:00:07.007 0.004 Length Should Be ${SUITE.tests} 3 *** Keywords *** @@ -158,14 +158,13 @@ Run And Check Include And Exclude Should Be True $SUITE.statistics.passed == len($tests) Should Be True $SUITE.statistics.failed == 0 IF ${times_are_none} - Should Be Equal ${SUITE.starttime} ${None} - Should Be Equal ${SUITE.endtime} ${None} + Should Be Equal ${SUITE.start_time} ${None} + Should Be Equal ${SUITE.end_time} ${None} ELSE - Should Be Equal ${SUITE.starttime} ${ORIG_START} - Should Be Equal ${SUITE.endtime} ${ORIG_END} + Should Be Equal ${SUITE.start_time} ${ORIG_START} + Should Be Equal ${SUITE.end_time} ${ORIG_END} END - Elapsed Time Should Be Valid ${SUITE.elapsedtime} - Should Be True $SUITE.elapsedtime <= $ORIG_ELAPSED + 1 + Elapsed Time Should Be Valid ${SUITE.elapsed_time} maximum=${ORIG_ELAPSED.total_seconds()} + 1 Run And Check Error [Arguments] ${params} ${filter msg} ${suite name}=Include And Exclude From eb3175cde47ea41dff0b63c17649f8e9cfcf470b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Fri, 8 Sep 2023 23:50:50 +0300 Subject: [PATCH 0677/1592] Test and code fixes related to #4258. Most importantly, fix tests using message timestamps in format `yyyymmdd hh:mm:ss`. They worked with Python 3.11 and 3.12, because with them `datetime.fromisoformat` accepted them, but with older there was a ValueError. --- .../timestamps_for_stdout_messages.robot | 11 +-- .../testdata/output/listener_interface/v3.py | 2 +- .../as_listener/listenerlibrary3.py | 4 +- src/robot/output/stdoutlogsplitter.py | 13 ++-- utest/output/test_stdout_splitter.py | 72 ++++++++++--------- utest/reporting/test_jsmodelbuilders.py | 22 +++--- utest/reporting/test_reporting.py | 4 +- 7 files changed, 67 insertions(+), 61 deletions(-) diff --git a/atest/robot/test_libraries/timestamps_for_stdout_messages.robot b/atest/robot/test_libraries/timestamps_for_stdout_messages.robot index a70a24d8c32..4abb53b50a6 100644 --- a/atest/robot/test_libraries/timestamps_for_stdout_messages.robot +++ b/atest/robot/test_libraries/timestamps_for_stdout_messages.robot @@ -4,21 +4,22 @@ Resource atest_resource.robot *** Test Cases *** Library adds timestamp as integer - Test's timestamps should be correct + Test's timestamps should be correct 931000 Library adds timestamp as float - Test's timestamps should be correct + Test's timestamps should be correct 930502 *** Keywords *** Test's timestamps should be correct + [Arguments] ${micro} ${tc} = Check Test Case ${TESTNAME} - Known timestamp should be correct ${tc.body[0].msgs[0]} + Known timestamp should be correct ${tc.body[0].msgs[0]} ${micro} Current timestamp should be smaller than kw end time ${tc.body[0]} Known timestamp should be correct - [Arguments] ${msg} + [Arguments] ${msg} ${micro} Check Log Message ${msg} Known timestamp - Should Be Equal ${msg.timestamp} ${datetime(2011, 6, 18, 20, 43, 54, 931000)} + Should Be Equal ${msg.timestamp} ${datetime(2011, 6, 18, 20, 43, 54, ${micro})} Current timestamp should be smaller than kw end time [Arguments] ${kw} diff --git a/atest/testdata/output/listener_interface/v3.py b/atest/testdata/output/listener_interface/v3.py index f53a2d079b1..48b41c14171 100644 --- a/atest/testdata/output/listener_interface/v3.py +++ b/atest/testdata/output/listener_interface/v3.py @@ -64,7 +64,7 @@ def end_test(data, result): def log_message(msg): msg.message = msg.message.upper() - msg.timestamp = '20151216 15:51:20.141' + msg.timestamp = '2015-12-16 15:51:20.141' message = log_message diff --git a/atest/testdata/test_libraries/as_listener/listenerlibrary3.py b/atest/testdata/test_libraries/as_listener/listenerlibrary3.py index 1d8df4c1088..76e53738f19 100644 --- a/atest/testdata/test_libraries/as_listener/listenerlibrary3.py +++ b/atest/testdata/test_libraries/as_listener/listenerlibrary3.py @@ -39,14 +39,14 @@ def end_test(self, data, result): def log_message(self, msg): msg.message += ' [log_message]' - msg.timestamp = '20151216 15:51:20.141' + msg.timestamp = '2015-12-16 15:51:20.141' def foo(self): print("*WARN* Foo") def message(self, msg): msg.message += ' [message]' - msg.timestamp = '20151216 15:51:20.141' + msg.timestamp = '2015-12-16 15:51:20.141' def close(self): sys.__stderr__.write('CLOSING Listener library 3\n') diff --git a/src/robot/output/stdoutlogsplitter.py b/src/robot/output/stdoutlogsplitter.py index 6bcf86a24d0..6b79a65f65f 100644 --- a/src/robot/output/stdoutlogsplitter.py +++ b/src/robot/output/stdoutlogsplitter.py @@ -14,8 +14,8 @@ # limitations under the License. import re +from datetime import datetime -from robot.utils import format_time from .loggerhelper import Message, write_to_console @@ -36,7 +36,7 @@ def _get_messages(self, output): write_to_console(msg.lstrip()) level = 'INFO' if timestamp: - timestamp = self._format_timestamp(timestamp[1:]) + timestamp = datetime.fromtimestamp(float(timestamp[1:]) / 1000) yield Message(msg.strip(), level, timestamp=timestamp) def _split_output(self, output): @@ -53,8 +53,11 @@ def _add_initial_level_and_time_if_needed(self, tokens): def _output_started_with_level(self, tokens): return tokens[0] == '' - def _format_timestamp(self, millis): - return format_time(float(millis)/1000, millissep='.') - def __iter__(self): return iter(self._messages) + + def __len__(self): + return len(self._messages) + + def __getitem__(self, item): + return self._messages[item] diff --git a/utest/output/test_stdout_splitter.py b/utest/output/test_stdout_splitter.py index d50e320a8a3..a4776f70893 100644 --- a/utest/output/test_stdout_splitter.py +++ b/utest/output/test_stdout_splitter.py @@ -16,65 +16,67 @@ def test_empty_output_should_result_in_empty_messages_list(self): def test_plain_output_should_have_info_level(self): splitter = Splitter('this is message\nin many\nlines.') - self._verify_message(splitter, 'this is message\nin many\nlines.') - assert_equal(len(list(splitter)), 1) + self._verify_message(splitter[0], 'this is message\nin many\nlines.') + assert_equal(len(splitter), 1) def test_leading_and_trailing_space_should_be_stripped(self): splitter = Splitter('\t \n My message \t\r\n') - self._verify_message(splitter, 'My message') - assert_equal(len(list(splitter)), 1) + self._verify_message(splitter[0], 'My message') + assert_equal(len(splitter), 1) def test_legal_level_is_correctly_read(self): splitter = Splitter('*DEBUG* My message details') - self._verify_message(splitter, 'My message details', 'DEBUG') - assert_equal(len(list(splitter)), 1) + self._verify_message(splitter[0], 'My message details', 'DEBUG') + assert_equal(len(splitter), 1) def test_space_after_level_is_optional(self): splitter = Splitter('*WARN*No space!') - self._verify_message(splitter, 'No space!', 'WARN') - assert_equal(len(list(splitter)), 1) + self._verify_message(splitter[0], 'No space!', 'WARN') + assert_equal(len(splitter), 1) def test_it_is_possible_to_define_multiple_levels(self): splitter = Splitter('*WARN* WARNING!\n' '*TRACE*msg') - self._verify_message(splitter, 'WARNING!', 'WARN') - self._verify_message(splitter, 'msg', 'TRACE', index=1) - assert_equal(len(list(splitter)), 2) + self._verify_message(splitter[0], 'WARNING!', 'WARN') + self._verify_message(splitter[1], 'msg', 'TRACE') + assert_equal(len(splitter), 2) def test_html_flag_should_be_parsed_correctly_and_uses_info_level(self): splitter = Splitter('*HTML* <b>Hello</b>') - self._verify_message(splitter, '<b>Hello</b>', level='INFO', html=True) - assert_equal(len(list(splitter)), 1) + self._verify_message(splitter[0], '<b>Hello</b>', html=True) + assert_equal(len(splitter), 1) def test_default_level_for_first_message_is_info(self): splitter = Splitter('<img src="foo bar">\n' - '*DEBUG*bar foo') - self._verify_message(splitter, '<img src="foo bar">') - self._verify_message(splitter, 'bar foo', 'DEBUG', index=1) - assert_equal(len(list(splitter)), 2) + '*DEBUG*bar foo') + self._verify_message(splitter[0], '<img src="foo bar">') + self._verify_message(splitter[1], 'bar foo', 'DEBUG') + assert_equal(len(splitter), 2) def test_timestamp_given_as_integer(self): now = int(time.time()) - splitter = Splitter('*INFO:xxx* No timestamp\n' - '*INFO:0* Epoch\n' - '*HTML:%d*X' % (now*1000)) - self._verify_message(splitter, '*INFO:xxx* No timestamp') - self._verify_message(splitter, 'Epoch', timestamp=0, index=1) - self._verify_message(splitter, html=True, timestamp=now, index=2) - assert_equal(len(list(splitter)), 3) + splitter = Splitter(f'*INFO:xxx* No timestamp\n' + f'*INFO:0* Epoch\n' + f'*HTML:{now * 1000}*X') + self._verify_message(splitter[0], '*INFO:xxx* No timestamp') + self._verify_message(splitter[1], 'Epoch', timestamp=0) + self._verify_message(splitter[2], html=True, timestamp=now) + assert_equal(len(splitter), 3) def test_timestamp_given_as_float(self): - splitter = Splitter('*INFO:1x2* No timestamp\n' - '*HTML:1000.123456789* X\n' - '*INFO:12345678.9*X') - self._verify_message(splitter, '*INFO:1x2* No timestamp') - self._verify_message(splitter, html=True, timestamp=1, index=1) - self._verify_message(splitter, timestamp=12345.679, index=2) - assert_equal(len(list(splitter)), 3) - - def _verify_message(self, splitter, msg='X', level='INFO', html=False, - timestamp=None, index=0): - message = list(splitter)[index] + now = float(time.time()) + splitter = Splitter(f'*INFO:1x2* No timestamp\n' + f'*HTML:1000.123456789* X\n' + f'*INFO:12345678.9*X\n' + f'*WARN:{now * 1000}* Run!\n') + self._verify_message(splitter[0], '*INFO:1x2* No timestamp') + self._verify_message(splitter[1], html=True, timestamp=1.000123) + self._verify_message(splitter[2], timestamp=12345.6789) + self._verify_message(splitter[3], 'Run!', 'WARN', timestamp=now) + assert_equal(len(splitter), 4) + + def _verify_message(self, message, msg='X', level='INFO', html=False, + timestamp=None): assert_equal(message.message, msg) assert_equal(message.level, level) assert_equal(message.html, html) diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index 056115c13fd..e64b560e337 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -97,12 +97,12 @@ def test_default_message(self): self._verify_min_message_level('INFO') def test_message_with_values(self): - msg = Message('Message', 'DEBUG', timestamp='20111204 22:04:03.210') + msg = Message('Message', 'DEBUG', timestamp='2011-12-04 22:04:03.210') self._verify_message(msg, 'Message', 1, 0) self._verify_min_message_level('DEBUG') def test_warning_linking(self): - msg = Message('Message', 'WARN', timestamp='20111204 22:04:03.210', + msg = Message('Message', 'WARN', timestamp='2011-12-04 22:04:03.210', parent=TestCase().body.create_keyword()) self._verify_message(msg, 'Message', 3, 0) links = self.context._msg_links @@ -111,7 +111,7 @@ def test_warning_linking(self): assert_equal(remap(links[key], self.context.strings), 't1-k1') def test_error_linking(self): - msg = Message('ERROR Message', 'ERROR', timestamp='20150609 01:02:03.004', + msg = Message('ERROR Message', 'ERROR', timestamp='2015-06-09 01:02:03.004', parent=TestCase().body.create_keyword().body.create_keyword()) self._verify_message(msg, 'ERROR Message', 4, 0) links = self.context._msg_links @@ -153,8 +153,8 @@ def test_nested_structure(self): def test_timestamps(self): suite = TestSuite(start_time='2011-12-05 00:33:33.333') suite.setup.config(kwname='s1', start_time='2011-12-05 00:33:33.334') - suite.setup.body.create_message('Message', timestamp='20111205 00:33:33.343') - suite.setup.body.create_message(level='DEBUG', timestamp='20111205 00:33:33.344') + suite.setup.body.create_message('Message', timestamp='2011-12-05 00:33:33.343') + suite.setup.body.create_message(level='DEBUG', timestamp='2011-12-05 00:33:33.344') suite.tests.create(start_time='2011-12-05 00:33:34.333') context = JsBuildingContext() model = SuiteBuilder(context).build(suite) @@ -331,10 +331,10 @@ def _get_nested_suite_with_tests_and_keywords(self): def test_message_linking(self): suite = self._get_suite_with_keywords() msg1 = suite.setup.body[0].body.create_message( - 'Message 1', 'WARN', timestamp='20111204 22:04:03.210' + 'Message 1', 'WARN', timestamp='2011-12-04 22:04:03.210' ) msg2 = suite.tests.create().body.create_keyword().body.create_message( - 'Message 2', 'ERROR', timestamp='20111204 22:04:04.210' + 'Message 2', 'ERROR', timestamp='2011-12-04 22:04:04.210' ) context = JsBuildingContext(split_log=True) SuiteBuilder(context).build(suite) @@ -483,8 +483,8 @@ def _verify_stat(self, stat, pass_, fail, skip, label, elapsed, **attrs): class TestBuildErrors(unittest.TestCase): def setUp(self): - msgs = [Message('Error', 'ERROR', timestamp='20111206 14:33:00.000'), - Message('Warning', 'WARN', timestamp='20111206 14:33:00.042')] + msgs = [Message('Error', 'ERROR', timestamp='2011-12-06 14:33:00.000'), + Message('Warning', 'WARN', timestamp='2011-12-06 14:33:00.042')] self.errors = ExecutionErrors(msgs) def test_errors(self): @@ -495,10 +495,10 @@ def test_errors(self): def test_linking(self): self.errors.messages.create('Linkable', 'WARN', - timestamp='20111206 14:33:00.001') + timestamp='2011-12-06 14:33:00.001') context = JsBuildingContext() msg = TestSuite().tests.create().body.create_keyword().body.create_message( - 'Linkable', 'WARN', timestamp='20111206 14:33:00.001' + 'Linkable', 'WARN', timestamp='2011-12-06 14:33:00.001' ) MessageBuilder(context).build(msg) model = ErrorsBuilder(context).build(self.errors) diff --git a/utest/reporting/test_reporting.py b/utest/reporting/test_reporting.py index 89574fccf33..376cab8e54e 100644 --- a/utest/reporting/test_reporting.py +++ b/utest/reporting/test_reporting.py @@ -86,10 +86,10 @@ def _get_execution_result(self): tc = suite.tests.create(name=self.EXPECTED_FAILING_TEST) kw = tc.body.create_keyword(kwname=self.EXPECTED_KEYWORD_NAME) kw.body.create_message(message=self.EXPECTED_DEBUG_MESSAGE, - level='DEBUG', timestamp='20201212 12:12:12.000') + level='DEBUG', timestamp='2020-12-12 12:12:12.000') errors = ExecutionErrors() errors.messages.create(message=self.EXPECTED_ERROR_MESSAGE, - level='ERROR', timestamp='20201212 12:12:12.000') + level='ERROR', timestamp='2020-12-12 12:12:12.000') return Result(root_suite=suite, errors=errors) def _verify_output(self, content): From 679edf6967f30f45dfe6cd3c3d6dcf0121b7a474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 9 Sep 2023 01:28:27 +0300 Subject: [PATCH 0678/1592] Deprecate not needed time related utils. #4501 Some of these aren't needed anymore due to timestamps being created using `datetime` (#4258). Others were used so few times that preserving them didn't make sense. Also introduce new `parse_timestamp` util that parses timestamps to a `datetime`. It isn't as strict as `datetime.fromisoformat`. --- .../rebot/start_and_endtime_from_cli.robot | 2 +- src/robot/conf/settings.py | 11 +- src/robot/libraries/OperatingSystem.py | 7 +- src/robot/reporting/jsbuildingcontext.py | 3 +- src/robot/result/configurer.py | 7 +- src/robot/utils/__init__.py | 2 +- src/robot/utils/robottime.py | 135 ++++++++++-------- utest/utils/test_robottime.py | 66 ++++++--- utest/utils/test_timestampcache.py | 56 -------- 9 files changed, 133 insertions(+), 156 deletions(-) delete mode 100644 utest/utils/test_timestampcache.py diff --git a/atest/robot/rebot/start_and_endtime_from_cli.robot b/atest/robot/rebot/start_and_endtime_from_cli.robot index 2aa42f3bcc1..b22cdc84d2b 100644 --- a/atest/robot/rebot/start_and_endtime_from_cli.robot +++ b/atest/robot/rebot/start_and_endtime_from_cli.robot @@ -11,7 +11,7 @@ ${COMBINED} %{TEMPDIR}${/}combined.xml *** Test Cases *** Combine with both start time and end time Log Many ${INPUT1} ${INPUT2} - Run Rebot --starttime 2007:09:25:21:51 --endtime 2007:09:26:01:12:30.200 ${INPUT1} ${INPUT2} + Run Rebot --starttime 2007:09:25:21:51 --endtime 2007-09-26T01:12:30.200 ${INPUT1} ${INPUT2} Should Be Equal ${SUITE.start_time} ${datetime(2007, 9, 25, 21, 51)} Should Be Equal ${SUITE.end_time} ${datetime(2007, 9, 26, 1, 12, 30, 200000)} Should Be Equal ${SUITE.elapsed_time} ${timedelta(seconds=3*60*60 + 21*60 + 30.2)} diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index a8b2f2fbd35..b6defd1cb17 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -20,13 +20,14 @@ import sys import time import warnings +from datetime import datetime from pathlib import Path from robot.errors import DataError, FrameworkError from robot.output import LOGGER, loggerhelper from robot.result.keywordremover import KeywordRemover from robot.result.flattenkeywordmatcher import validate_flatten_keyword -from robot.utils import (abspath, create_destination_directory, escape, format_time, +from robot.utils import (abspath, create_destination_directory, escape, get_link_path, html_escape, is_list_like, plural_or_not as s, seq2str, split_args_from_name_or_path) @@ -73,7 +74,7 @@ class _BaseSettings: _output_opts = ['Output', 'Log', 'Report', 'XUnit', 'DebugFile'] def __init__(self, options=None, **extra_options): - self.start_timestamp = format_time(time.time(), '', '-', '') + self.start_time = datetime.now() self._opts = {} self._cli_opts = self._cli_opts.copy() self._cli_opts.update(self._extra_cli_opts) @@ -230,7 +231,9 @@ def _get_output_file(self, option): def _process_output_name(self, option, name): base, ext = os.path.splitext(name) if self['TimestampOutputs']: - base = f'{base}-{self.start_timestamp}' + s = self.start_time + base = (f'{base}-{s.year}{s.month:02}{s.day:02}-' + f'{s.hour:02}{s.minute:02}{s.second:02}') ext = self._get_output_extension(ext, option) return base + ext @@ -488,7 +491,7 @@ class RobotSettings(_BaseSettings): def get_rebot_settings(self): settings = RebotSettings() - settings.start_timestamp = self.start_timestamp + settings.start_time = self.start_time not_copied = {'Include', 'Exclude', 'TestNames', 'SuiteNames', 'ParseInclude', 'Name', 'Doc', 'Metadata', 'SetTag', 'Output', 'LogLevel', 'TimestampOutputs'} diff --git a/src/robot/libraries/OperatingSystem.py b/src/robot/libraries/OperatingSystem.py index 2079eda27bb..dc462bed679 100644 --- a/src/robot/libraries/OperatingSystem.py +++ b/src/robot/libraries/OperatingSystem.py @@ -21,6 +21,7 @@ import shutil import tempfile import time +from datetime import datetime from robot.version import get_version from robot.api import logger @@ -28,7 +29,7 @@ from robot.utils import (abspath, ConnectionCache, console_decode, del_env_var, get_env_var, get_env_vars, get_time, is_truthy, is_string, normpath, parse_time, plural_or_not, - safe_str, secs_to_timestamp, secs_to_timestr, seq2str, + safe_str, secs_to_timestr, seq2str, set_env_var, timestr_to_secs, CONSOLE_ENCODING, WINDOWS) __version__ = get_version() @@ -1297,8 +1298,8 @@ def set_modified_time(self, path, mtime): if not os.path.isfile(path): self._error("Path '%s' is not a regular file." % path) os.utime(path, (mtime, mtime)) - time.sleep(0.1) # Give os some time to really set these times - tstamp = secs_to_timestamp(mtime, seps=('-', ' ', ':')) + time.sleep(0.1) # Give OS some time to really set these times. + tstamp = datetime.fromtimestamp(mtime).isoformat(' ', timespec='seconds') self._link("Set modified time of '%%s' to %s." % tstamp, path) def get_file_size(self, path): diff --git a/src/robot/reporting/jsbuildingcontext.py b/src/robot/reporting/jsbuildingcontext.py index 4a1c037d4d5..1865bd0cfde 100644 --- a/src/robot/reporting/jsbuildingcontext.py +++ b/src/robot/reporting/jsbuildingcontext.py @@ -18,8 +18,7 @@ from pathlib import Path from robot.output.loggerhelper import LEVELS -from robot.utils import (attribute_escape, get_link_path, html_escape, safe_str, - timestamp_to_secs) +from robot.utils import attribute_escape, get_link_path, html_escape, safe_str from .expandkeywordmatcher import ExpandKeywordMatcher from .stringcache import StringCache diff --git a/src/robot/result/configurer.py b/src/robot/result/configurer.py index 071b4ab2753..2c0dc454fab 100644 --- a/src/robot/result/configurer.py +++ b/src/robot/result/configurer.py @@ -13,10 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from datetime import datetime - from robot import model -from robot.utils import is_string, timestamp_to_secs +from robot.utils import is_string, parse_timestamp class SuiteConfigurer(model.SuiteConfigurer): @@ -51,10 +49,9 @@ def _to_datetime(self, timestamp): if not timestamp: return None try: - secs = timestamp_to_secs(timestamp, seps=' :.-_') + return parse_timestamp(timestamp) except ValueError: return None - return datetime.fromtimestamp(secs) def visit_suite(self, suite): model.SuiteConfigurer.visit_suite(self, suite) diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 508fa4e4b36..4d860882ece 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -61,7 +61,7 @@ from .robottime import (elapsed_time_to_string, format_time, get_elapsed_time, get_time, get_timestamp, secs_to_timestamp, secs_to_timestr, timestamp_to_secs, timestr_to_secs, - parse_time) + parse_time, parse_timestamp) from .robottypes import (has_args, is_bytes, is_dict_like, is_falsy, is_integer, is_list_like, is_number, is_pathlike, is_string, is_truthy, is_union, type_name, type_repr, typeddict_types) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 314cb92672e..762cc14a0ef 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -16,7 +16,7 @@ import re import time import warnings -from datetime import timedelta +from datetime import datetime, timedelta from .normalizing import normalize from .misc import plural_or_not @@ -195,17 +195,9 @@ def _secs_to_components(self, float_secs): def format_time(timetuple_or_epochsecs, daysep='', daytimesep=' ', timesep=':', millissep=None): - """Returns a timestamp formatted from given time using separators. - - Time can be given either as a timetuple or seconds after epoch. - - Timetuple is (year, month, day, hour, min, sec[, millis]), where parts must - be integers and millis is required only when millissep is not None. - Notice that this is not 100% compatible with standard Python timetuples - which do not have millis. - - Seconds after epoch can be either an integer or a float. - """ + """Deprecated in Robot Framework 7.0. Will be removed in Robot Framework 8.0.""" + warnings.warn("'robot.utils.format_time' is deprecated and will be " + "removed in Robot Framework 8.0.") if is_number(timetuple_or_epochsecs): timetuple = _get_timetuple(timetuple_or_epochsecs) else: @@ -239,14 +231,15 @@ def get_time(format='timestamp', time_=None): # 1) Return time in seconds since epoc if 'epoch' in format: return time_ - timetuple = time.localtime(time_) + dt = datetime.fromtimestamp(time_) parts = [] - for i, match in enumerate('year month day hour min sec'.split()): - if match in format: - parts.append('%.2d' % timetuple[i]) + for part, name in [(dt.year, 'year'), (dt.month, 'month'), (dt.day, 'day'), + (dt.hour, 'hour'), (dt.minute, 'min'), (dt.second, 'sec')]: + if name in format: + parts.append(f'{part:02}') # 2) Return time as timestamp if not parts: - return format_time(timetuple, daysep='-') + return dt.isoformat(' ', timespec='seconds') # Return requested parts of the time elif len(parts) == 1: return parts[0] @@ -254,6 +247,39 @@ def get_time(format='timestamp', time_=None): return parts +def parse_timestamp(timestamp: str) -> datetime: + """Parse timestamp in ISO 8601-like formats into a ``datetime``. + + Months, days, hours, minutes and seconds must use two digits and + year must use four. Microseconds can use up to six digits. All time + parts can be omitted. + + Timestamps can use separators '-', '_', ' ', 'T', ':' and '.' between + date and time components. Separators can also be omitted. + + Examples: + 2023-09-08T14:34:42.123456 + 2023-09-08 14:34:42.123 + 20230908 143442 + 2023-09-08 + + New in Robot Framework 7.0. + """ + if isinstance(timestamp, datetime): + return timestamp + orig = timestamp + for sep in ('-', '_', ' ', 'T', ':', '.'): + if sep in timestamp: + timestamp = timestamp.replace(sep, '') + timestamp = timestamp.ljust(20, '0') + try: + return datetime(int(timestamp[0:4]), int(timestamp[4:6]), int(timestamp[6:8]), + int(timestamp[8:10]), int(timestamp[10:12]), int(timestamp[12:14]), + int(timestamp[14:20])) + except ValueError: + raise ValueError(f"Invalid timestamp '{orig}'.") + + def parse_time(timestr): """Parses the time string and returns its value as seconds since epoch. @@ -290,7 +316,7 @@ def _parse_time_epoch(timestr): def _parse_time_timestamp(timestr): try: - return timestamp_to_secs(timestr, (' ', ':', '-', '.')) + return parse_timestamp(timestr).timestamp() except ValueError: return None @@ -333,10 +359,22 @@ def _get_dst_difference(time1, time2): def get_timestamp(daysep='', daytimesep=' ', timesep=':', millissep='.'): - return TIMESTAMP_CACHE.get_timestamp(daysep, daytimesep, timesep, millissep) + """Deprecated in Robot Framework 7.0. Will be removed in Robot Framework 8.0.""" + warnings.warn("'robot.utils.get_timestamp' is deprecated and will be " + "removed in Robot Framework 8.0.") + dt = datetime.now() + parts = [str(dt.year), daysep, f'{dt.month:02}', daysep, f'{dt.day:02}', daytimesep, + f'{dt.hour:02}', timesep, f'{dt.minute:02}', timesep, f'{dt.second:02}'] + if millissep: + millis = round(dt.microsecond, -3) // 1000 + parts.extend([millissep, f'{millis:03}']) + return ''.join(parts) def timestamp_to_secs(timestamp, seps=None): + """Deprecated in Robot Framework 7.0. Will be removed in Robot Framework 8.0.""" + warnings.warn("'robot.utils.timestamp_to_secs' is deprecated and will be " + "removed in Robot Framework 8.0. User 'parse_timestamp' instead.") try: secs = _timestamp_to_millis(timestamp, seps) / 1000.0 except (ValueError, OverflowError): @@ -346,6 +384,9 @@ def timestamp_to_secs(timestamp, seps=None): def secs_to_timestamp(secs, seps=None, millis=False): + """Deprecated in Robot Framework 7.0. Will be removed in Robot Framework 8.0.""" + warnings.warn("'robot.utils.secs_to_timestamp' is deprecated and will be " + "removed in Robot Framework 8.0.") if not seps: seps = ('', ' ', ':', '.' if millis else None) ttuple = time.localtime(secs)[:6] @@ -356,22 +397,29 @@ def secs_to_timestamp(secs, seps=None, millis=False): def get_elapsed_time(start_time, end_time): - """Returns the time between given timestamps in milliseconds.""" + """Deprecated in Robot Framework 7.0. Will be removed in Robot Framework 8.0.""" + warnings.warn("'robot.utils.get_elapsed_time' is deprecated and will be " + "removed in Robot Framework 8.0.") if start_time == end_time or not (start_time and end_time): return 0 if start_time[:-4] == end_time[:-4]: return int(end_time[-3:]) - int(start_time[-3:]) start_millis = _timestamp_to_millis(start_time) end_millis = _timestamp_to_millis(end_time) - # start/end_millis can be long but we want to return int when possible - return int(end_millis - start_millis) + return end_millis - start_millis + +def elapsed_time_to_string(elapsed: 'int|float|timedelta', + include_millis: bool = True): + """Converts elapsed time to format 'hh:mm:ss.mil'. -def elapsed_time_to_string(elapsed, include_millis=True): - """Converts elapsed time in milliseconds to format 'hh:mm:ss.mil'. + Elapsed time can be given as milliseconds either as an integer or + as a float. Alternatively it can be given as a ``timedelta``. If `include_millis` is True, '.mil' part is omitted. """ + if isinstance(elapsed, timedelta): + elapsed = round(elapsed.total_seconds() * 1000) prefix = '' if elapsed < 0: prefix = '-' @@ -420,42 +468,3 @@ def _split_timestamp(timestamp): secs = int(timestamp[15:17]) millis = int(timestamp[18:21]) return years, mons, days, hours, mins, secs, millis - - -class TimestampCache: - - def __init__(self): - self._previous_secs = None - self._previous_separators = None - self._previous_timestamp = None - - def get_timestamp(self, daysep='', daytimesep=' ', timesep=':', millissep='.'): - epoch = self._get_epoch() - secs, millis = _float_secs_to_secs_and_millis(epoch) - if self._use_cache(secs, daysep, daytimesep, timesep): - return self._cached_timestamp(millis, millissep) - timestamp = format_time(epoch, daysep, daytimesep, timesep, millissep) - self._cache_timestamp(secs, timestamp, daysep, daytimesep, timesep, millissep) - return timestamp - - # Seam for mocking - def _get_epoch(self): - return time.time() - - def _use_cache(self, secs, *separators): - return self._previous_timestamp \ - and self._previous_secs == secs \ - and self._previous_separators == separators - - def _cached_timestamp(self, millis, millissep): - if millissep: - return self._previous_timestamp + millissep + format(millis, '03d') - return self._previous_timestamp - - def _cache_timestamp(self, secs, timestamp, daysep, daytimesep, timesep, millissep): - self._previous_secs = secs - self._previous_separators = (daysep, daytimesep, timesep) - self._previous_timestamp = timestamp[:-4] if millissep else timestamp - - -TIMESTAMP_CACHE = TimestampCache() diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index c63b452ad43..6ed978d6a61 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -9,7 +9,7 @@ from robot.utils.robottime import (timestr_to_secs, secs_to_timestr, get_time, parse_time, format_time, get_elapsed_time, - get_timestamp, timestamp_to_secs, + get_timestamp, timestamp_to_secs, parse_timestamp, elapsed_time_to_string, _get_timetuple) @@ -218,7 +218,8 @@ def test_format_time(self): for seps, exp in [(('-',' ',':'), '2005-11-02 14:23:12'), (('', '-', ''), '20051102-142312'), (('-',' ',':','.'), '2005-11-02 14:23:12.123')]: - assert_equal(format_time(timetuple, *seps), exp) + with warnings.catch_warnings(record=True): + assert_equal(format_time(timetuple, *seps), exp) def test_get_timestamp(self): for seps, pattern in [ @@ -227,20 +228,18 @@ def test_get_timestamp(self): (('', '', '', None), r'^\d{14}$'), (('-', ' ', ':', ';'), r'^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d;\d\d\d$') ]: - ts = get_timestamp(*seps) + with warnings.catch_warnings(record=True): + ts = get_timestamp(*seps) assert_not_none(re.search(pattern, ts), "'%s' didn't match '%s'" % (ts, pattern), False) - def test_timestamp_to_secs_with_default(self): - assert_equal(timestamp_to_secs('20070920 16:15:14.123'), EXAMPLE_TIME+0.123) - - def test_timestamp_to_secs_with_seps(self): - result = timestamp_to_secs('2007-09-20#16x15x14M123', ('-','#','x','M')) - assert_equal(result, EXAMPLE_TIME+0.123) - - def test_timestamp_to_secs_with_millis(self): - result = timestamp_to_secs('20070920 16:15:14.123') - assert_equal(result, EXAMPLE_TIME+0.123) + def test_timestamp_to_secs(self): + with warnings.catch_warnings(record=True): + assert_equal(timestamp_to_secs('20070920 16:15:14.123'), EXAMPLE_TIME+0.123) + assert_equal(timestamp_to_secs('20070920T16:15:14.123'), EXAMPLE_TIME+0.123) + assert_equal(timestamp_to_secs('2007-09-20#16x15x14M123', ('-','#','x','M')), + EXAMPLE_TIME+0.123) + assert_equal(timestamp_to_secs('20070920 16:15:14.123'), EXAMPLE_TIME+0.123) def test_get_elapsed_time(self): starttime = '20060526 14:01:10.500' @@ -260,7 +259,8 @@ def test_get_elapsed_time(self): ('20060601 14:01:10.499', 518399999), ('20060601 14:01:10.500', 518400000), ('20060601 14:01:10.501', 518400001)]: - actual = get_elapsed_time(starttime, endtime) + with warnings.catch_warnings(record=True): + actual = get_elapsed_time(starttime, endtime) assert_equal(actual, expected, endtime) def test_get_elapsed_time_negative(self): @@ -271,14 +271,15 @@ def test_get_elapsed_time_negative(self): ('20060526 14:01:09.501', -999), ('20060526 14:01:09.500', -1000), ('20060526 14:01:09.499', -1001)]: - actual = get_elapsed_time(starttime, endtime) + with warnings.catch_warnings(record=True): + actual = get_elapsed_time(starttime, endtime) assert_equal(actual, expected, endtime) def test_elapsed_time_to_string(self): for elapsed, expected in [(0, '00:00:00.000'), (0.1, '00:00:00.000'), (0.5, '00:00:00.000'), - (0.50001, '00:00:00.001'), + (0.501, '00:00:00.001'), (1, '00:00:00.001'), (1.5, '00:00:00.002'), (42, '00:00:00.042'), @@ -295,7 +296,9 @@ def test_elapsed_time_to_string(self): (360000000, '100:00:00.000'), (360000000 + 36000000 + 3600000 + 660000 + 11111, '111:11:11.111')]: + td = timedelta(seconds=elapsed / 1000) assert_equal(elapsed_time_to_string(elapsed), expected, elapsed) + assert_equal(elapsed_time_to_string(td), expected, elapsed) if expected != '00:00:00.000': assert_equal(elapsed_time_to_string(-1 * elapsed), '-' + expected, elapsed) @@ -328,6 +331,32 @@ def test_elapsed_time_to_string_without_millis(self): assert_equal(elapsed_time_to_string(-1 * elapsed, False), '-' + expected, elapsed) + def test_parse_timestamp(self): + for timestamp in ['2023-09-08 23:34:45.123456', + '2023-09-08T23:34:45.123456', + '20230908 23:34:45.123456', + '2023_09_08 233445.123456', + '20230908233445123456']: + assert_equal(parse_timestamp(timestamp), + datetime(2023, 9, 8, 23, 34, 45, 123456)) + + def test_parse_timestamp_fill_missing(self): + for timestamp, expected in [ + ('2023-09-08 23:34:45.123', '2023-09-08 23:34:45.123'), + ('2023-09-08 23:34:45', '2023-09-08 23:34:45'), + ('20230908 23:34:45', '2023-09-08 23:34:45'), + ('2023-09-08 23:34', '2023-09-08 23:34:00'), + ('20230101', '2023-01-01 00:00:00') + ]: + assert_equal(parse_timestamp(timestamp), + datetime.fromisoformat(expected)) + + def test_parse_timestamp_invalid(self): + assert_raises_with_msg(ValueError, + "Invalid timestamp 'bad'.", + parse_timestamp, + 'bad') + def test_parse_time_with_valid_times(self): for input, expected in [('100', 100), ('2007-09-20 16:15:14', EXAMPLE_TIME), @@ -387,11 +416,6 @@ def _verify_parse_time_and_get_time_rounding(self): assert_equal(gt_result, '%02d' % start_secs) assert_equal(pt_result, start_secs) - def test_get_timestamp_without_millis(self): - # Need to test twice to verify also possible cached timestamp - assert_true(re.match(r'\d{8} \d\d:\d\d:\d\d', get_timestamp(millissep=None))) - assert_true(re.match(r'\d{8} \d\d:\d\d:\d\d', get_timestamp(millissep=None))) - if __name__ == "__main__": unittest.main() diff --git a/utest/utils/test_timestampcache.py b/utest/utils/test_timestampcache.py deleted file mode 100644 index d571c8a9162..00000000000 --- a/utest/utils/test_timestampcache.py +++ /dev/null @@ -1,56 +0,0 @@ -import time -import unittest - -from robot.utils.asserts import assert_equal -from robot.utils.robottime import TimestampCache - - -class FakeTimestampCache(TimestampCache): - - def __init__(self, epoch): - TimestampCache.__init__(self) - self.epoch = epoch + self.timezone_correction() - - def _get_epoch(self): - return self.epoch - - def timezone_correction(self): - dst = 3600 if time.daylight == 0 else 0 - tz = 7200 + time.timezone - return (tz + dst) - - -class TestTimestamp(unittest.TestCase): - - def test_new_timestamp(self): - actual = FakeTimestampCache(1338816626.999).get_timestamp() - assert_equal(actual, '20120604 16:30:26.999') - - def test_cached(self): - cache = FakeTimestampCache(1338816626.900) - cache.get_timestamp() - cache.epoch += 0.099 - assert_equal(cache.get_timestamp(), '20120604 16:30:26.999') - - def test_round_to_next_second(self): - cache = FakeTimestampCache(1338816626.0) - assert_equal(cache.get_timestamp(), '20120604 16:30:26.000') - cache.epoch += 0.9995 - assert_equal(cache.get_timestamp(), '20120604 16:30:27.000') - - def test_cache_timestamp_without_millis_separator(self): - cache = FakeTimestampCache(1338816626.0) - assert_equal(cache.get_timestamp(millissep=None), '20120604 16:30:26') - assert_equal(cache.get_timestamp(millissep=None), '20120604 16:30:26') - assert_equal(cache.get_timestamp(), '20120604 16:30:26.000') - - def test_separators(self): - cache = FakeTimestampCache(1338816626.001) - assert_equal(cache.get_timestamp(daysep='-', daytimesep='T'), - '2012-06-04T16:30:26.001') - assert_equal(cache.get_timestamp(timesep='', millissep='X'), - '20120604 163026X001') - - -if __name__ == "__main__": - unittest.main() From 2dc9115308bb27ec81d1dad563abbcb8eb0a5e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 9 Sep 2023 01:34:28 +0300 Subject: [PATCH 0679/1592] Remove deprecated argument from `timestr_to_secs` util. `accept_plain_values` was deprecated in RF 6.1 (#4522) and can now be safely removed in RF 7.0. Fixes #4861. --- src/robot/utils/robottime.py | 13 +++---------- utest/utils/test_robottime.py | 7 ------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 762cc14a0ef..1ad14d8c58b 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -40,7 +40,7 @@ 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, accept_plain_values=True): +def timestr_to_secs(timestr, round_to=3): """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, @@ -48,23 +48,16 @@ def timestr_to_secs(timestr, round_to=3, accept_plain_values=True): The result is rounded according to the `round_to` argument. Use `round_to=None` to disable rounding altogether. - - `accept_plain_values` is considered deprecated and should not be used. """ if is_string(timestr) or is_number(timestr): - if accept_plain_values: - converters = [_number_to_secs, _timer_to_secs, _time_string_to_secs] - else: - # TODO: Remove 'accept_plain_values' in 7.0 - warnings.warn("'accept_plain_values' is deprecated and will be removed in RF 7.0.") - converters = [_timer_to_secs, _time_string_to_secs] + converters = [_number_to_secs, _timer_to_secs, _time_string_to_secs] for converter in converters: secs = converter(timestr) if secs is not None: return secs if round_to is None else round(secs, round_to) if isinstance(timestr, timedelta): return timestr.total_seconds() - raise ValueError("Invalid time string '%s'." % timestr) + raise ValueError(f"Invalid time string '{timestr}'.") def _number_to_secs(number): diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index 6ed978d6a61..78a5e0457f0 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -177,13 +177,6 @@ def test_timestr_to_secs_with_invalid(self): assert_raises_with_msg(ValueError, "Invalid time string '%s'." % inv, timestr_to_secs, inv) - def test_timestr_to_secs_accept_plain_values(self): - with warnings.catch_warnings(record=True) as w: - assert_raises_with_msg(ValueError, "Invalid time string '100'.", - timestr_to_secs, '100', accept_plain_values=False) - assert_equal(str(w[-1].message), - "'accept_plain_values' is deprecated and will be removed in RF 7.0.") - def test_secs_to_timestr(self): for inp, compact, verbose in [ (0.001, '1ms', '1 millisecond'), From f1071c634a568a33c9e87b1bf85ac0799600584d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sat, 9 Sep 2023 08:34:18 +0300 Subject: [PATCH 0680/1592] libdoc: remove deprecated writing of types part of #4667 --- atest/robot/libdoc/libdoc_resource.robot | 1 - src/robot/libdocpkg/xmlwriter.py | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index 1cca607d29d..eada29552ed 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -181,7 +181,6 @@ Get Type ${args} = Catenate SEPARATOR=,${SPACE} @{nested} ${type} = Set Variable ${type}\[${args}] END - Should Be Equal ${elem.text} ${type} RETURN ${type} Get Element Optional Text diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index 864fe4f966c..b5795453f5f 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -89,10 +89,7 @@ def _write_type_info(self, type_info: TypeInfo, type_docs: dict, writer, top=Tru attrs['union'] = 'true' if type_info.name in type_docs: attrs['typedoc'] = type_docs[type_info.name] - # Writing content, and omitting newlines, is backwards compatibility with - # specs created using RF < 6.1. TODO: Remove in RF 7. - writer.start('type', attrs, newline=False) - writer.content(str(type_info)) + writer.start('type', attrs) for nested in type_info.nested: self._write_type_info(nested, type_docs, writer, top=False) writer.end('type', newline=top) From f4058f4d8964dbef343981715dca7fa08c5ace33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 9 Sep 2023 16:36:24 +0300 Subject: [PATCH 0681/1592] Avoid old `elapsedtime` attribute in statistics. Use new `elapsed_time` instead. Hopefully the final part of #4258. --- src/robot/model/stats.py | 24 +++++++++--------------- utest/model/test_statistics.py | 27 ++++++++++++++------------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/robot/model/stats.py b/src/robot/model/stats.py index 836d90d27b5..f7b0aac427a 100644 --- a/src/robot/model/stats.py +++ b/src/robot/model/stats.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import timedelta + from robot.utils import (Sortable, elapsed_time_to_string, html_escape, is_string, normalize) @@ -31,14 +33,10 @@ def __init__(self, name): #: or name of the tag for #: :class:`~robot.model.tagstatistics.TagStatistics` self.name = name - #: Number of passed tests. self.passed = 0 - #: Number of failed tests. self.failed = 0 - #: Number of skipped tests. self.skipped = 0 - #: Number of milliseconds it took to execute. - self.elapsed = 0 + self.elapsed = timedelta() self._norm_name = normalize(name, ignore='_') def get_attributes(self, include_label=False, include_elapsed=False, @@ -49,8 +47,7 @@ def get_attributes(self, include_label=False, include_elapsed=False, if include_label: attrs['label'] = self.name if include_elapsed: - attrs['elapsed'] = elapsed_time_to_string(self.elapsed, - include_millis=False) + attrs['elapsed'] = elapsed_time_to_string(self.elapsed, include_millis=False) if exclude_empty: attrs = dict((k, v) for k, v in attrs.items() if v not in ('', None)) if values_as_strings: @@ -83,7 +80,7 @@ def _update_stats(self, test): self.failed += 1 def _update_elapsed(self, test): - self.elapsed += test.elapsedtime # TODO: Use `test.elapsed_time` instead. + self.elapsed += test.elapsed_time @property def _sort_key(self): @@ -106,12 +103,9 @@ class SuiteStat(Stat): type = 'suite' def __init__(self, suite): - Stat.__init__(self, suite.longname) - #: Identifier of the suite, e.g. `s1-s2`. + super().__init__(suite.longname) self.id = suite.id - #: Number of milliseconds it took to execute this suite, - #: including sub-suites. - self.elapsed = suite.elapsedtime # TODO: Use `suite.elapsed_time` instead. + self.elapsed = suite.elapsed_time self._name = suite.name def _get_custom_attrs(self): @@ -131,7 +125,7 @@ class TagStat(Stat): type = 'tag' def __init__(self, name, doc='', links=None, combined=None): - Stat.__init__(self, name) + super().__init__(name) #: Documentation of tag as a string. self.doc = doc #: List of tuples in which the first value is the link URL and @@ -164,7 +158,7 @@ def _sort_key(self): class CombinedTagStat(TagStat): def __init__(self, pattern, name=None, doc='', links=None): - TagStat.__init__(self, name or pattern, doc, links, combined=pattern) + super().__init__(name or pattern, doc, links, combined=pattern) self.pattern = TagPattern.from_string(pattern) def match(self, tags): diff --git a/utest/model/test_statistics.py b/utest/model/test_statistics.py index 21ac84f548c..109b8466648 100644 --- a/utest/model/test_statistics.py +++ b/utest/model/test_statistics.py @@ -1,4 +1,5 @@ import unittest +from datetime import timedelta from robot.utils.asserts import assert_equal from robot.model.statistics import Statistics @@ -6,7 +7,7 @@ def verify_stat(stat, name, passed, failed, skipped, - combined=None, id=None, elapsed=0): + combined=None, id=None, elapsed=0.0): assert_equal(stat.name, name, 'stat.name') assert_equal(stat.passed, passed) assert_equal(stat.failed, failed) @@ -16,7 +17,7 @@ def verify_stat(stat, name, passed, failed, skipped, assert_equal(stat.combined, combined) if hasattr(stat, 'id'): assert_equal(stat.id, id) - assert_equal(stat.elapsed, elapsed) + assert_equal(stat.elapsed, timedelta(seconds=elapsed)) def verify_suite(suite, name, id, passed, failed, skipped): @@ -178,33 +179,33 @@ def setUp(self): self.stats = Statistics(suite, tag_stat_combine=[('?2', 'combined')]) def test_total_stats(self): - assert_equal(self.stats.total._stat.elapsed, 11001) + assert_equal(self.stats.total._stat.elapsed, timedelta(seconds=11.001)) def test_tag_stats(self): t1, t2, t3 = self.stats.tags.tags.values() - verify_stat(t1, 't1', 0, 3, 0, elapsed=11001) - verify_stat(t2, 't2', 0, 2, 0, elapsed=11000) - verify_stat(t3, 't3', 0, 1, 0, elapsed=10000) + verify_stat(t1, 't1', 0, 3, 0, elapsed=11.001) + verify_stat(t2, 't2', 0, 2, 0, elapsed=11.000) + verify_stat(t3, 't3', 0, 1, 0, elapsed=10.000) def test_combined_tag_stats(self): combined = self.stats.tags.combined[0] - verify_stat(combined, 'combined', 0, 2, 0, combined='?2', elapsed=11000) + verify_stat(combined, 'combined', 0, 2, 0, combined='?2', elapsed=11.000) def test_suite_stats(self): - assert_equal(self.stats.suite.stat.elapsed, 59999) - assert_equal(self.stats.suite.suites[0].stat.elapsed, 30000) - assert_equal(self.stats.suite.suites[1].stat.elapsed, 12042) + assert_equal(self.stats.suite.stat.elapsed, timedelta(seconds=59.999)) + assert_equal(self.stats.suite.suites[0].stat.elapsed, timedelta(seconds=30.000)) + assert_equal(self.stats.suite.suites[1].stat.elapsed, timedelta(seconds=12.042)) def test_suite_stats_when_suite_has_no_times(self): suite = TestSuite() - assert_equal(Statistics(suite).suite.stat.elapsed, 0) + assert_equal(Statistics(suite).suite.stat.elapsed, timedelta()) ts = '2012-08-16 00:00:' suite.tests = [TestCase(start_time=ts+'00.000', end_time=ts+'00.001'), TestCase(start_time=ts+'00.001', end_time=ts+'01.001')] - assert_equal(Statistics(suite).suite.stat.elapsed, 1001) + assert_equal(Statistics(suite).suite.stat.elapsed, timedelta(seconds=1.001)) suite.suites = [TestSuite(start_time=ts+'02.000', end_time=ts+'12.000'), TestSuite()] - assert_equal(Statistics(suite).suite.stat.elapsed, 11001) + assert_equal(Statistics(suite).suite.stat.elapsed, timedelta(seconds=11.001)) def test_elapsed_from_get_attributes(self): for time, expected in [('00:00:00.000', '00:00:00'), From c131811a06df4bf1d4da4eabb51a22f36dd0c1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 9 Sep 2023 16:46:25 +0300 Subject: [PATCH 0682/1592] Deprecate `elapsed_time_to_string` accepting time as milliseconds Fixes #4862. --- src/robot/libraries/DateTime.py | 2 +- src/robot/utils/robottime.py | 37 ++++++++--- utest/utils/test_robottime.py | 107 ++++++++++++++++++-------------- 3 files changed, 90 insertions(+), 56 deletions(-) diff --git a/src/robot/libraries/DateTime.py b/src/robot/libraries/DateTime.py index 91b2429b745..0c97dafac0f 100644 --- a/src/robot/libraries/DateTime.py +++ b/src/robot/libraries/DateTime.py @@ -619,7 +619,7 @@ def _convert_to_compact(self, seconds, millis=True): return secs_to_timestr(seconds, compact=True) def _convert_to_timer(self, seconds, millis=True): - return elapsed_time_to_string(seconds * 1000, include_millis=millis) + return elapsed_time_to_string(seconds, include_millis=millis, seconds=True) def _convert_to_timedelta(self, seconds, millis=True): return timedelta(seconds=seconds) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 1ad14d8c58b..7747beb2c94 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -403,37 +403,54 @@ def get_elapsed_time(start_time, end_time): def elapsed_time_to_string(elapsed: 'int|float|timedelta', - include_millis: bool = True): + include_millis: bool = True, + seconds: bool = False): """Converts elapsed time to format 'hh:mm:ss.mil'. - Elapsed time can be given as milliseconds either as an integer or - as a float. Alternatively it can be given as a ``timedelta``. + Elapsed time as an integer or as a float is currently considered to be + milliseconds, but that will be changed to seconds in Robot Framework 8.0. + Use ``seconds=True`` to change the behavior already now and to avoid the + deprecation warning. An alternative is giving the elapsed time as + a ``timedelta``. If `include_millis` is True, '.mil' part is omitted. + + Support for giving the elapsed time as a ``timedelta`` and the ``seconds`` + argument are new in Robot Framework 7.0. """ + # TODO: Change the default input to seconds in RF 8.0. if isinstance(elapsed, timedelta): - elapsed = round(elapsed.total_seconds() * 1000) + elapsed = elapsed.total_seconds() + elif not seconds: + elapsed /= 1000 + warnings.warn("'robot.utils.elapsed_time_to_string' currently accepts " + "input as milliseconds, but that will be changed to seconds " + "in Robot Framework 8.0. Use 'seconds=True' to change the " + "behavior already now and to avoid this warning. Alternatively " + "pass the elapsed time as a 'timedelta'.") prefix = '' if elapsed < 0: prefix = '-' elapsed = abs(elapsed) if include_millis: - return prefix + _elapsed_time_to_string(elapsed) + return prefix + _elapsed_time_to_string_with_millis(elapsed) return prefix + _elapsed_time_to_string_without_millis(elapsed) -def _elapsed_time_to_string(elapsed): - secs, millis = divmod(round(elapsed), 1000) +def _elapsed_time_to_string_with_millis(elapsed): + elapsed = round(elapsed, 3) + secs = int(elapsed) + millis = round((elapsed - secs) * 1000) mins, secs = divmod(secs, 60) hours, mins = divmod(mins, 60) - return '%02d:%02d:%02d.%03d' % (hours, mins, secs, millis) + return f'{hours:02}:{mins:02}:{secs:02}.{millis:03}' def _elapsed_time_to_string_without_millis(elapsed): - secs = round(elapsed, ndigits=-3) // 1000 + secs = round(elapsed) mins, secs = divmod(secs, 60) hours, mins = divmod(mins, 60) - return '%02d:%02d:%02d' % (hours, mins, secs) + return f'{hours:02}:{mins:02}:{secs:02}' def _timestamp_to_millis(timestamp, seps=None): diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index 78a5e0457f0..ab4d16499be 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -270,60 +270,77 @@ def test_get_elapsed_time_negative(self): def test_elapsed_time_to_string(self): for elapsed, expected in [(0, '00:00:00.000'), - (0.1, '00:00:00.000'), - (0.5, '00:00:00.000'), - (0.501, '00:00:00.001'), - (1, '00:00:00.001'), - (1.5, '00:00:00.002'), - (42, '00:00:00.042'), - (999, '00:00:00.999'), - (999.9, '00:00:01.000'), - (1000, '00:00:01.000'), - (1001, '00:00:01.001'), - (60000, '00:01:00.000'), - (600000, '00:10:00.000'), - (654321, '00:10:54.321'), - (660000, '00:11:00.000'), - (3600000, '01:00:00.000'), - (36000000, '10:00:00.000'), - (360000000, '100:00:00.000'), - (360000000 + 36000000 + 3600000 + 660000 + 11111, + (0.0001, '00:00:00.000'), + (0.00049, '00:00:00.000'), + (0.00050, '00:00:00.001'), + (0.00051, '00:00:00.001'), + (0.001, '00:00:00.001'), + (0.0015, '00:00:00.002'), + (0.042, '00:00:00.042'), + (0.999, '00:00:00.999'), + (0.9999, '00:00:01.000'), + (1.0, '00:00:01.000'), + (1, '00:00:01.000'), + (1.001, '00:00:01.001'), + (60, '00:01:00.000'), + (600, '00:10:00.000'), + (654.321, '00:10:54.321'), + (660, '00:11:00.000'), + (3600, '01:00:00.000'), + (36000, '10:00:00.000'), + (360000, '100:00:00.000'), + (360000 + 36000 + 3600 + 660 + 11.111, '111:11:11.111')]: - td = timedelta(seconds=elapsed / 1000) - assert_equal(elapsed_time_to_string(elapsed), expected, elapsed) - assert_equal(elapsed_time_to_string(td), expected, elapsed) - if expected != '00:00:00.000': - assert_equal(elapsed_time_to_string(-1 * elapsed), + assert_equal(elapsed_time_to_string(elapsed, seconds=True), + expected, elapsed) + assert_equal(elapsed_time_to_string(timedelta(seconds=elapsed)), + expected, elapsed) + if elapsed != 0: + assert_equal(elapsed_time_to_string(-elapsed, seconds=True), + '-' + expected, elapsed) + assert_equal(elapsed_time_to_string(timedelta(seconds=-elapsed)), '-' + expected, elapsed) def test_elapsed_time_to_string_without_millis(self): for elapsed, expected in [(0, '00:00:00'), - (1, '00:00:00'), - (500, '00:00:00'), - (500.001, '00:00:01'), - (999, '00:00:01'), - (1000, '00:00:01'), - (1499.999, '00:00:01'), - (1500, '00:00:02'), - (59499.9, '00:00:59'), - (59500.0, '00:01:00'), - (59999, '00:01:00'), - (60000, '00:01:00'), - (654321, '00:10:54'), - (654500, '00:10:54'), - (654501, '00:10:55'), - (3599999, '01:00:00'), - (3600000, '01:00:00'), - (359999999, '100:00:00'), - (360000000, '100:00:00'), - (360000500, '100:00:00'), - (360000500.001, '100:00:01')]: - assert_equal(elapsed_time_to_string(elapsed, include_millis=False), + (0.001, '00:00:00'), + (0.5, '00:00:00'), + (0.501, '00:00:01'), + (0.999, '00:00:01'), + (1.0, '00:00:01'), + (1, '00:00:01'), + (1.4999, '00:00:01'), + (1.500, '00:00:02'), + (59.4999, '00:00:59'), + (59.5, '00:01:00'), + (59.999, '00:01:00'), + (60, '00:01:00'), + (654.321, '00:10:54'), + (654.500, '00:10:54'), + (654.501, '00:10:55'), + (3599.999, '01:00:00'), + (3600, '01:00:00'), + (359999.999, '100:00:00'), + (360000, '100:00:00'), + (360000.5, '100:00:00'), + (360000.501, '100:00:01')]: + assert_equal(elapsed_time_to_string(elapsed, include_millis=False, + seconds=True), expected, elapsed) if expected != '00:00:00': - assert_equal(elapsed_time_to_string(-1 * elapsed, False), + assert_equal(elapsed_time_to_string(-1 * elapsed, False, True), '-' + expected, elapsed) + def test_elapsed_time_default_input_is_deprecated(self): + with warnings.catch_warnings(record=True) as w: + assert_equal(elapsed_time_to_string(1000), '00:00:01.000') + assert_equal(str(w[0].message), + "'robot.utils.elapsed_time_to_string' currently accepts input " + "as milliseconds, but that will be changed to seconds in " + "Robot Framework 8.0. Use 'seconds=True' to change the behavior " + "already now and to avoid this warning. Alternatively pass " + "the elapsed time as a 'timedelta'.") + def test_parse_timestamp(self): for timestamp in ['2023-09-08 23:34:45.123456', '2023-09-08T23:34:45.123456', From bc038fe1e3751fe2991a235b1aef9d8679c7a12b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 9 Sep 2023 17:56:40 +0300 Subject: [PATCH 0683/1592] Set result elapsed time, not end time, during execution. Setting any two of `start_time`, `end_time` and `elapsed_time` is enough, because the third can be calculated. Elapsed time is written to output.xml so better to calculate it immediately. End time is't generally needed during execution. This is to some extend related to #4258. --- src/robot/running/statusreporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index 72fcfe6a5be..e443594c75c 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -63,7 +63,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): result.message = failure.message if self.initial_test_status == 'PASS': context.test.status = result.status - result.end_time = datetime.now() + result.elapsed_time = datetime.now() - result.start_time context.end_keyword(ModelCombiner(self.data, result)) if failure is not exc_val and not self.suppress: raise failure From ad7326ce899e0125ba1b86996fd6fbcdcef84c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 9 Sep 2023 18:00:24 +0300 Subject: [PATCH 0684/1592] Enhance parse_elapsed_time docs and tests --- src/robot/utils/robottime.py | 21 ++++++++++++++++----- utest/utils/test_robottime.py | 6 ++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 7747beb2c94..7951653cc13 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -240,26 +240,37 @@ def get_time(format='timestamp', time_=None): return parts -def parse_timestamp(timestamp: str) -> datetime: +def parse_timestamp(timestamp: 'str|datetime') -> datetime: """Parse timestamp in ISO 8601-like formats into a ``datetime``. Months, days, hours, minutes and seconds must use two digits and year must use four. Microseconds can use up to six digits. All time parts can be omitted. - Timestamps can use separators '-', '_', ' ', 'T', ':' and '.' between - date and time components. Separators can also be omitted. + Separators '-', '_', ' ', 'T', ':' and '.' between date and time components. + Separators can also be omitted altogether. + + Examples:: - Examples: 2023-09-08T14:34:42.123456 2023-09-08 14:34:42.123 20230908 143442 - 2023-09-08 + 2023_09_08 + + This is similar to ``datetime.fromisoformat``, but a little less strict. + The standard function is recommended if the input format is known to be + accepted. + + If the input is a ``datetime``, it is returned as-is. New in Robot Framework 7.0. """ if isinstance(timestamp, datetime): return timestamp + try: + return datetime.fromisoformat(timestamp) + except ValueError: + pass orig = timestamp for sep in ('-', '_', ' ', 'T', ':', '.'): if sep in timestamp: diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index ab4d16499be..a77fd7ecf54 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -344,6 +344,8 @@ def test_elapsed_time_default_input_is_deprecated(self): def test_parse_timestamp(self): for timestamp in ['2023-09-08 23:34:45.123456', '2023-09-08T23:34:45.123456', + '2023-09-08 23:34:45:123456', + '2023:09:08:23:34:45:123456', '20230908 23:34:45.123456', '2023_09_08 233445.123456', '20230908233445123456']: @@ -361,6 +363,10 @@ def test_parse_timestamp_fill_missing(self): assert_equal(parse_timestamp(timestamp), datetime.fromisoformat(expected)) + def test_parse_timestamp_with_datetime(self): + dt = datetime.now() + assert_equal(parse_timestamp(dt), dt) + def test_parse_timestamp_invalid(self): assert_raises_with_msg(ValueError, "Invalid timestamp 'bad'.", From 812c83ae733d14c408d9a14577965c18c658a715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Sat, 9 Sep 2023 18:09:20 +0300 Subject: [PATCH 0685/1592] Fix unit test breaking on PyPy. Interestingly with other Python versions `datetime.fromisoformat()` accepts `:` as a microsecond separator but with PyPy it causes a ValueError. I believe `:` was used in this test by accident but didn't cause problems earlier when we parsed timestamps using our custom code. --- utest/model/test_statistics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utest/model/test_statistics.py b/utest/model/test_statistics.py index 109b8466648..45689108105 100644 --- a/utest/model/test_statistics.py +++ b/utest/model/test_statistics.py @@ -217,8 +217,8 @@ def test_elapsed_from_get_attributes(self): ('00:00:01.001', '00:00:01'), ('00:00:01.499', '00:00:01'), ('00:00:01.500', '00:00:02'), - ('01:59:59:499', '01:59:59'), - ('01:59:59:500', '02:00:00')]: + ('01:59:59.499', '01:59:59'), + ('01:59:59.500', '02:00:00')]: suite = TestSuite(start_time='2012-08-17 00:00:00.000', end_time='2012-08-17 ' + time) stat = Statistics(suite).suite.stat From c1597bc17e01af262f5f42c13486f75becf98eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= <janne.harkonen@reaktor.com> Date: Sat, 9 Sep 2023 20:33:16 +0300 Subject: [PATCH 0686/1592] Remove Reserved library Fixes #4302 --- atest/robot/cli/dryrun/reserved.robot | 40 --------- atest/robot/standard_libraries/reserved.robot | 27 ------ atest/testdata/cli/dryrun/reserved.robot | 88 ------------------- atest/testdata/running/for/for.robot | 5 +- atest/testdata/running/if/invalid_if.robot | 2 +- .../running/if/invalid_inline_if.robot | 2 +- .../standard_libraries/reserved.robot | 42 --------- src/robot/libraries/Reserved.py | 48 ---------- src/robot/libraries/__init__.py | 4 +- src/robot/running/librarykeywordrunner.py | 3 +- src/robot/running/namespace.py | 11 ++- 11 files changed, 13 insertions(+), 259 deletions(-) delete mode 100644 atest/robot/cli/dryrun/reserved.robot delete mode 100644 atest/robot/standard_libraries/reserved.robot delete mode 100644 atest/testdata/cli/dryrun/reserved.robot delete mode 100644 atest/testdata/standard_libraries/reserved.robot delete mode 100644 src/robot/libraries/Reserved.py diff --git a/atest/robot/cli/dryrun/reserved.robot b/atest/robot/cli/dryrun/reserved.robot deleted file mode 100644 index 30f74ad484f..00000000000 --- a/atest/robot/cli/dryrun/reserved.robot +++ /dev/null @@ -1,40 +0,0 @@ -*** Settings *** -Suite Setup Run Tests --dryrun cli/dryrun/reserved.robot -Resource atest_resource.robot - -*** Test Cases *** -For - Check Test Case ${TESTNAME} - -Valid END after For - Check Test Case ${TESTNAME} - -If - Check Test Case ${TESTNAME} - -Else If - Check Test Case ${TESTNAME} - -Else - Check Test Case ${TESTNAME} - -Else inside valid IF - Check Test Case ${TESTNAME} - -Else If inside valid IF - Check Test Case ${TESTNAME} - -End - Check Test Case ${TESTNAME} - -End after valid FOR header - Check Test Case ${TESTNAME} - -End after valid If header - Check Test Case ${TESTNAME} - -Reserved inside FOR - Check Test Case ${TESTNAME} - -Reserved inside IF - Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/reserved.robot b/atest/robot/standard_libraries/reserved.robot deleted file mode 100644 index a09e2f09e6f..00000000000 --- a/atest/robot/standard_libraries/reserved.robot +++ /dev/null @@ -1,27 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} standard_libraries/reserved.robot -Resource atest_resource.robot - -*** Test Cases *** -Markers should get note about case - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - -Others should just be reserved - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - -'End' gets extra note - Check Test Case ${TESTNAME} - -'Else' gets extra note - Check Test Case ${TESTNAME} - -'Else if' gets extra note - Check Test Case ${TESTNAME} - -'Elif' gets extra note - Check Test Case ${TESTNAME} - -Reserved in user keyword - Check Test Case ${TESTNAME} diff --git a/atest/testdata/cli/dryrun/reserved.robot b/atest/testdata/cli/dryrun/reserved.robot deleted file mode 100644 index d5fd1a62559..00000000000 --- a/atest/testdata/cli/dryrun/reserved.robot +++ /dev/null @@ -1,88 +0,0 @@ -*** Test Cases *** -For - [Documentation] FAIL 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. - For ${x} IN invalid - -Valid END after For - [Documentation] FAIL - ... Several failures occurred: - ... - ... 1) 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. - ... - ... 2) END is not allowed in this context. - For ${x} IN invalid - Log ${x} - END - -If - [Documentation] FAIL 'If' is a reserved keyword. It must be an upper case 'IF' when used as a marker. - If invalid - -Else If - [Documentation] FAIL 'Else If' is a reserved keyword. It must be an upper case 'ELSE IF' and follow an opening 'IF' when used as a marker. - Else If invalid - -Else - [Documentation] FAIL 'Else' is a reserved keyword. It must be an upper case 'ELSE' and follow an opening 'IF' when used as a marker. - Else - -Else inside valid IF - [Documentation] FAIL 'Else' is a reserved keyword. It must be an upper case 'ELSE' and follow an opening 'IF' when used as a marker. - IF False - No operation - Else - No operation - END - -Else If inside valid IF - [Documentation] FAIL 'Else If' is a reserved keyword. It must be an upper case 'ELSE IF' and follow an opening 'IF' when used as a marker. - IF False - No operation - Else If invalid - No operation - END - -End - [Documentation] FAIL 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. - End - -End after valid FOR header - [Documentation] FAIL 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. - FOR ${x} IN whatever - Log ${x} - End - -End after valid If header - [Documentation] FAIL 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. - IF True - No operation - End - -Reserved inside FOR - [Documentation] FAIL 'If' is a reserved keyword. It must be an upper case 'IF' when used as a marker. - FOR ${x} IN whatever - If ${x} - END - -Reserved inside IF - [Documentation] FAIL - ... Several failures occurred: - ... - ... 1) 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. - ... - ... 2) 'If' is a reserved keyword. It must be an upper case 'IF' when used as a marker. - ... - ... 3) END is not allowed in this context. - ... - ... 4) 'Return' is a reserved keyword. - ... - ... 5) END is not allowed in this context. - IF True - For ${x} IN invalid - Log ${x} - END - If False - No Operation - END - Return - END diff --git a/atest/testdata/running/for/for.robot b/atest/testdata/running/for/for.robot index bce9fe91738..ecb615ff36d 100644 --- a/atest/testdata/running/for/for.robot +++ b/atest/testdata/running/for/for.robot @@ -6,7 +6,7 @@ Variables binary_list.py @{NUMS} 1 2 3 4 5 @{RESULT} ${WRONG VALUES} Number of FOR loop values should be multiple of its variables. -${INVALID FOR} 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. +${INVALID FOR} Support for the old FOR loop syntax has been removed. Replace 'For' with 'FOR', end the loop with 'END', and remove escaping backslashes. ${INVALID END} END is not allowed in this context. *** Test Cases *** @@ -297,7 +297,8 @@ FOR is case and space sensitive 1 END FOR is case and space sensitive 2 - [Documentation] FAIL ${INVALID FOR} + [Documentation] FAIL No keyword with name 'F O R' found. Did you mean:\n${SPACE}${SPACE}${SPACE}${SPACE}For In UK + F O R ${var} IN one two Fail Should not be executed END diff --git a/atest/testdata/running/if/invalid_if.robot b/atest/testdata/running/if/invalid_if.robot index 8a4365ab39c..df9552aa939 100644 --- a/atest/testdata/running/if/invalid_if.robot +++ b/atest/testdata/running/if/invalid_if.robot @@ -81,7 +81,7 @@ Invalid END END this is invalid IF with wrong case - [Documentation] FAIL 'If' is a reserved keyword. It must be an upper case 'IF' when used as a marker. + [Documentation] FAIL No keyword with name 'if' found. if ${True} Fail Should not be run END diff --git a/atest/testdata/running/if/invalid_inline_if.robot b/atest/testdata/running/if/invalid_inline_if.robot index 08de7680562..f711839f7f5 100644 --- a/atest/testdata/running/if/invalid_inline_if.robot +++ b/atest/testdata/running/if/invalid_inline_if.robot @@ -100,7 +100,7 @@ Nested IF 3 ... ELSE IF True Not run Nested FOR - [Documentation] FAIL 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. + [Documentation] FAIL Support for the old FOR loop syntax has been removed. Replace 'FOR' with 'FOR', end the loop with 'END', and remove escaping backslashes. IF True FOR ${x} IN @{stuff} Unnecessary END diff --git a/atest/testdata/standard_libraries/reserved.robot b/atest/testdata/standard_libraries/reserved.robot deleted file mode 100644 index e6e83083019..00000000000 --- a/atest/testdata/standard_libraries/reserved.robot +++ /dev/null @@ -1,42 +0,0 @@ -*** Test Cases *** -Markers should get note about case 1 - [Documentation] FAIL 'For' is a reserved keyword. It must be an upper case 'FOR' when used as a marker. - For ${var} IN some items - Log ${var} - EnD - -Markers should get note about case 2 - [Documentation] FAIL 'If' is a reserved keyword. It must be an upper case 'IF' when used as a marker. - if Log ${message} - -Others should just be reserved 1 - [Documentation] FAIL 'Continue' is a reserved keyword. - Continue - -Others should just be reserved 2 - [Documentation] FAIL 'Return' is a reserved keyword. - Return ${something} - -'End' gets extra note - [Documentation] FAIL 'End' is a reserved keyword. It must be an upper case 'END' when used as a marker to close a block. - End - -'Else' gets extra note - [Documentation] FAIL 'Else' is a reserved keyword. It must be an upper case 'ELSE' and follow an opening 'IF' when used as a marker. - Else Log ${message} - -'Else if' gets extra note - [Documentation] FAIL 'Else If' is a reserved keyword. It must be an upper case 'ELSE IF' and follow an opening 'IF' when used as a marker. - Else if Log ${message} - -'Elif' gets extra note - [Documentation] FAIL 'Elif' is a reserved keyword. The marker to use with 'IF' is 'ELSE IF'. - ELIF - -Reserved in user keyword - [Documentation] FAIL 'While' is a reserved keyword. - User keyword with reserved keyword - -*** Keywords *** -User keyword with reserved keyword - While diff --git a/src/robot/libraries/Reserved.py b/src/robot/libraries/Reserved.py deleted file mode 100644 index d619b387367..00000000000 --- a/src/robot/libraries/Reserved.py +++ /dev/null @@ -1,48 +0,0 @@ -# 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.running import RUN_KW_REGISTER - - -RESERVED_KEYWORDS = ['for', 'while', 'break', 'continue', 'end', - 'if', 'else', 'elif', 'else if', 'return'] - - -class Reserved: - ROBOT_LIBRARY_SCOPE = 'GLOBAL' - - def __init__(self): - for kw in RESERVED_KEYWORDS: - self._add_reserved(kw) - - def _add_reserved(self, kw): - RUN_KW_REGISTER.register_run_keyword('Reserved', kw, - args_to_process=0, - deprecation_warning=False) - self.__dict__[kw] = lambda *args, **kwargs: self._run_reserved(kw) - - def _run_reserved(self, kw): - error = "'%s' is a reserved keyword." % kw.title() - if kw in ('for', 'end', 'if', 'else', 'else if'): - error += " It must be an upper case '%s'" % kw.upper() - if kw in ('else', 'else if'): - error += " and follow an opening 'IF'" - if kw == 'end': - error += " when used as a marker to close a block." - else: - error += " when used as a marker." - if kw == 'elif': - error += " The marker to use with 'IF' is 'ELSE IF'." - raise Exception(error) diff --git a/src/robot/libraries/__init__.py b/src/robot/libraries/__init__.py index 5ab5ed45d02..dbb8c22bb9e 100644 --- a/src/robot/libraries/__init__.py +++ b/src/robot/libraries/__init__.py @@ -27,5 +27,5 @@ """ STDLIBS = frozenset(('BuiltIn', 'Collections', 'DateTime', 'Dialogs', 'Easter', - 'OperatingSystem', 'Process', 'Remote', 'Reserved', - 'Screenshot', 'String', 'Telnet', 'XML')) + 'OperatingSystem', 'Process', 'Remote', 'Screenshot', + 'String', 'Telnet', 'XML')) diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index 29af0cf3fe8..44004141445 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -129,8 +129,7 @@ def _executed_in_dry_run(self, handler): 'BuiltIn.Set Library Search Order', 'BuiltIn.Set Tags', 'BuiltIn.Remove Tags') - return (handler.libname == 'Reserved' or - handler.longname in keywords_to_execute) + return handler.longname in keywords_to_execute class EmbeddedArgumentsRunner(LibraryKeywordRunner): diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 67f0f3967b7..98881c42ee4 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -35,7 +35,7 @@ class Namespace: - _default_libraries = ('BuiltIn', 'Reserved', 'Easter') + _default_libraries = ('BuiltIn', 'Easter') _library_import_by_path_ends = ('.py', '/', os.sep) _variables_import_by_path_ends = _library_import_by_path_ends + ('.yaml', '.yml') + ('.json',) @@ -511,9 +511,8 @@ def _get_all_handler_names(self): handlers = [('', printable_name(handler.name, True)) for handler in self.user_keywords.handlers] for library in chain(self.libraries.values(), self.resources.values()): - if library.name != 'Reserved': - handlers.extend( - ((library.name or '', - printable_name(handler.name, code_style=True)) - for handler in library.handlers)) + handlers.extend( + ((library.name or '', + printable_name(handler.name, code_style=True)) + for handler in library.handlers)) return sorted(handlers) From f126ce43c6ca07dabf76675909c08b509f2507cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 11 Sep 2023 13:45:13 +0300 Subject: [PATCH 0687/1592] Deprecate old utils loudly. Fixes #4501. This most importantly contains the old Python 2/3 compatibility layer. --- src/robot/utils/__init__.py | 82 ++++++++++------- src/robot/utils/platform.py | 20 +++-- .../test_old_py23_compatibility_layer.py | 89 ++++++++++++------- 3 files changed, 122 insertions(+), 69 deletions(-) diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 4d860882ece..684686c96ec 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -33,6 +33,8 @@ assert Matcher('H?llo').match('Hillo') """ +import warnings + from .argumentparser import ArgumentParser, cmdline2list from .application import Application from .compress import compress_text @@ -79,37 +81,49 @@ def read_rest_data(rstfile): return read_rest_data(rstfile) -# Quietly deprecated utils. Should be deprecated loudly in RF 7.0. -# https://github.com/robotframework/robotframework/issues/4501 - -from .robottypes import FALSE_STRINGS, TRUE_STRINGS - - -# Deprecated Python 2/3 compatibility layer. Not needed by Robot Framework itself -# after RF 5.0 when Python 2 support was dropped. Should be deprecated loudly in -# RF 7.0. Notice that there's also `PY2` in the `platform` submodule. -# https://github.com/robotframework/robotframework/issues/4501 - -from io import StringIO - - -PY3 = True -PY2 = JYTHON = IRONPYTHON = False -is_unicode = is_string -unicode = str -unic = safe_str -roundup = round - - -def py2to3(cls): - """Deprecated since RF 5.0. Use Python 3 features directly instead.""" - if hasattr(cls, '__unicode__'): - cls.__str__ = lambda self: self.__unicode__() - if hasattr(cls, '__nonzero__'): - cls.__bool__ = lambda self: self.__nonzero__() - return cls - - -def py3to2(cls): - """Deprecated since RF 5.0. Never done anything when used on Python 3.""" - return cls +def unic(item): + # Cannot be deprecated using '__getattr__' because a module with same name exists. + warnings.warn("'robot.utils.unic' is deprecated and will be removed in " + "Robot Framework 8.0.") + return safe_str(item) + + +def __getattr__(name): + # Deprecated utils mostly related to the old Python 2/3 compatibility layer. + # See also 'unic' above 'PY2' in the 'platform' module. TODO: Remove in RF 8.0. + # https://github.com/robotframework/robotframework/issues/4501 + + from io import StringIO + from .robottypes import FALSE_STRINGS, TRUE_STRINGS + + def py2to3(cls): + if hasattr(cls, '__unicode__'): + cls.__str__ = lambda self: self.__unicode__() + if hasattr(cls, '__nonzero__'): + cls.__bool__ = lambda self: self.__nonzero__() + return cls + + def py3to2(cls): + return cls + + deprecated = { + 'FALSE_STRINGS': FALSE_STRINGS, + 'TRUE_STRINGS': TRUE_STRINGS, + 'StringIO': StringIO, + 'PY3': True, + 'PY2': False, + 'JYTHON': False, + 'IRONPYTHON': False, + 'is_unicode': is_string, + 'unicode': str, + 'roundup': round, + 'py2to3': py2to3, + 'py3to2': py3to2, + } + + if name in deprecated: + warnings.warn(f"'robot.utils.{name}' is deprecated and will be removed in " + f"Robot Framework 8.0.") + return deprecated[name] + + raise AssertionError(f"'robot.utils' has no attribute '{name}'.") diff --git a/src/robot/utils/platform.py b/src/robot/utils/platform.py index 3f943f1253c..3c3a96cd3e9 100644 --- a/src/robot/utils/platform.py +++ b/src/robot/utils/platform.py @@ -23,11 +23,6 @@ WINDOWS = not UNIXY RERAISED_EXCEPTIONS = (KeyboardInterrupt, SystemExit, MemoryError) -# Part of the deprecated Python 2/3 compatibility layer. For more details see -# the comment in `utils/__init__.py`. This constant was added to support -# SSHLibrary: https://github.com/robotframework/SSHLibrary/issues/401 -PY2 = False - def isatty(stream): # first check if buffer was detached @@ -39,3 +34,18 @@ def isatty(stream): return stream.isatty() except ValueError: # Occurs if file is closed. return False + + +def __getattr__(name): + # Part of the deprecated Python 2/3 compatibility layer. For more details see + # the comment in `utils/__init__.py`. The 'PY2' constant exists here to support + # SSHLibrary: https://github.com/robotframework/SSHLibrary/issues/401 + + import warnings + + if name == 'PY2': + warnings.warn("'robot.utils.platform.PY2' is deprecated and will be removed " + "in Robot Framework 8.0.") + return False + + raise AttributeError(f"'robot.utils.platform' has no attribute '{name}'.") diff --git a/utest/utils/test_old_py23_compatibility_layer.py b/utest/utils/test_old_py23_compatibility_layer.py index 48846887163..397458c3600 100644 --- a/utest/utils/test_old_py23_compatibility_layer.py +++ b/utest/utils/test_old_py23_compatibility_layer.py @@ -1,65 +1,94 @@ import unittest +import warnings +from contextlib import contextmanager from robot.utils.asserts import assert_equal, assert_false, assert_true -from robot.utils import (PY2, PY3, StringIO, JYTHON, IRONPYTHON, py2to3, py3to2, - is_unicode, platform, roundup, unicode, unic) +from robot import utils class TestCompatibilityLayer(unittest.TestCase): + @contextmanager + def validate_deprecation(self, name): + with warnings.catch_warnings(record=True) as w: + yield + assert_equal(str(w[0].message), + f"'robot.utils.{name}' is deprecated and will be removed " + f"in Robot Framework 8.0.") + def test_constants(self): - assert_true(PY3 is True) - for not_supported in PY2, JYTHON, IRONPYTHON: - assert_true(not_supported is False) + with self.validate_deprecation('PY3'): + assert_true(utils.PY3 is True) + with self.validate_deprecation('PY2'): + assert_true(utils.PY2 is False) + with self.validate_deprecation('JYTHON'): + assert_true(utils.JYTHON is False) + with self.validate_deprecation('IRONPYTHON'): + assert_true(utils.IRONPYTHON is False) def test_py2_under_platform(self): # https://github.com/robotframework/SSHLibrary/issues/401 - assert_true(platform.PY2 is False) + with self.validate_deprecation('platform.PY2'): + assert_true(utils.platform.PY2 is False) def test_py2to3(self): - @py2to3 - class X: - def __unicode__(self): - return 'Hyvä!' - def __nonzero__(self): - return False + with self.validate_deprecation('py2to3'): + @utils.py2to3 + class X: + def __unicode__(self): + return 'Hyvä!' + def __nonzero__(self): + return False assert_false(X()) assert_equal(str(X()), 'Hyvä!') def test_py3to2(self): - @py3to2 - class X: - def __str__(self): - return 'Hyvä!' - def __bool__(self): - return False + with self.validate_deprecation('py3to2'): + @utils.py3to2 + class X: + def __str__(self): + return 'Hyvä!' + def __bool__(self): + return False assert_false(X()) assert_equal(str(X()), 'Hyvä!') def test_is_unicode(self): - assert_true(is_unicode('Hyvä')) - assert_true(is_unicode('Paha')) - assert_false(is_unicode(b'xxx')) - assert_false(is_unicode(42)) + with self.validate_deprecation('is_unicode'): + assert_true(utils.is_unicode('Hyvä')) + with self.validate_deprecation('is_unicode'): + assert_true(utils.is_unicode('Paha')) + with self.validate_deprecation('is_unicode'): + assert_false(utils.is_unicode(b'xxx')) + with self.validate_deprecation('is_unicode'): + assert_false(utils.is_unicode(42)) def test_roundup(self): - assert_true(roundup is round) + with self.validate_deprecation('roundup'): + assert_true(utils.roundup is round) def test_unicode(self): - assert_true(unicode is str) + with self.validate_deprecation('unicode'): + assert_true(utils.unicode is str) def test_unic(self): - assert_equal(unic('Hyvä'), 'Hyvä') - assert_equal(unic('Paha'), 'Paha') - assert_equal(unic(42), '42') - assert_equal(unic(b'Hyv\xe4'), r'Hyv\xe4') - assert_equal(unic(b'Paha'), 'Paha') + with self.validate_deprecation('unic'): + assert_equal(utils.unic('Hyvä'), 'Hyvä') + with self.validate_deprecation('unic'): + assert_equal(utils.unic('Paha'), 'Paha') + with self.validate_deprecation('unic'): + assert_equal(utils.unic(42), '42') + with self.validate_deprecation('unic'): + assert_equal(utils.unic(b'Hyv\xe4'), r'Hyv\xe4') + with self.validate_deprecation('unic'): + assert_equal(utils.unic(b'Paha'), 'Paha') def test_stringio(self): import io - assert_true(StringIO is io.StringIO) + with self.validate_deprecation('StringIO'): + assert_true(utils.StringIO is io.StringIO) if __name__ == '__main__': From 3a77ef6695450c6d13106c755047c26e89c68151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 11 Sep 2023 13:48:54 +0300 Subject: [PATCH 0688/1592] Cleanup. - Whitespace - Remove unnecessary helper (and fix tests that actually used it) --- src/robot/utils/robotpath.py | 1 + src/robot/utils/robottypes.py | 2 -- src/robot/utils/text.py | 11 +++++--- utest/utils/test_text.py | 47 ++++++++++++++++++----------------- 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/robot/utils/robotpath.py b/src/robot/utils/robotpath.py index 5c10ef580cf..0ff7b69401b 100644 --- a/src/robot/utils/robotpath.py +++ b/src/robot/utils/robotpath.py @@ -44,6 +44,7 @@ def normpath(path, case_normalize=False): That includes Windows and also OSX in default configuration. 4. Turn ``c:`` into ``c:\\`` on Windows instead of keeping it as ``c:``. """ + # FIXME: Support pathlib.Path if not is_string(path): path = system_decode(path) path = safe_str(path) # Handles NFC normalization on OSX diff --git a/src/robot/utils/robottypes.py b/src/robot/utils/robottypes.py index a2cadfefd0a..f4fef8e8d61 100644 --- a/src/robot/utils/robottypes.py +++ b/src/robot/utils/robottypes.py @@ -28,8 +28,6 @@ except ImportError: ExtTypedDict = None -from .platform import PY_VERSION - TRUE_STRINGS = {'TRUE', 'YES', 'ON', '1'} FALSE_STRINGS = {'FALSE', 'NO', 'OFF', '0', 'NONE', ''} diff --git a/src/robot/utils/text.py b/src/robot/utils/text.py index ee90c9f8771..d840b2c1380 100644 --- a/src/robot/utils/text.py +++ b/src/robot/utils/text.py @@ -36,13 +36,14 @@ def cut_long_message(msg): if MAX_ERROR_LINES is None: return msg lines = msg.splitlines() - lengths = _count_line_lengths(lines) + lengths = [_get_virtual_line_length(line) for line in lines] if sum(lengths) <= MAX_ERROR_LINES: return msg start = _prune_excess_lines(lines, lengths) end = _prune_excess_lines(lines, lengths, from_end=True) return '\n'.join(start + [_ERROR_CUT_EXPLN] + end) + def _prune_excess_lines(lines, lengths, from_end=False): if from_end: lines.reverse() @@ -60,6 +61,7 @@ def _prune_excess_lines(lines, lengths, from_end=False): ret.reverse() return ret + def _cut_long_line(line, used, from_end): available_lines = MAX_ERROR_LINES // 2 - used available_chars = available_lines * _MAX_ERROR_LINE_LENGTH - 3 @@ -70,10 +72,8 @@ def _cut_long_line(line, used, from_end): line = '...' + line[-available_chars:] return line -def _count_line_lengths(lines): - return [ _count_virtual_line_length(line) for line in lines ] -def _count_virtual_line_length(line): +def _get_virtual_line_length(line): if not line: return 1 lines, remainder = divmod(len(line), _MAX_ERROR_LINE_LENGTH) @@ -88,6 +88,7 @@ def format_assign_message(variable, value, items=None, cut_long=True): decorated_items = ''.join(f'[{item}]' for item in items) if items else '' return f'{variable}{decorated_items} = {value}' + def _dict_to_str(d): if not d: return '{ }' @@ -114,10 +115,12 @@ def pad_console_length(text, width): text = _lose_width(text, diff+3) + '...' return _pad_width(text, width) + def _pad_width(text, width): more = width - get_console_length(text) return text + ' ' * more + def _lose_width(text, diff): lost = 0 while lost < diff: diff --git a/utest/utils/test_text.py b/utest/utils/test_text.py index 25d3fca9578..39c25ed4f32 100644 --- a/utest/utils/test_text.py +++ b/utest/utils/test_text.py @@ -4,10 +4,9 @@ from robot.utils.asserts import assert_equal, assert_true from robot.utils.text import ( - cut_long_message, get_console_length, getdoc, getshortdoc, - pad_console_length, split_tags_from_doc, split_args_from_name_or_path, - _count_line_lengths, MAX_ERROR_LINES, _MAX_ERROR_LINE_LENGTH, - _ERROR_CUT_EXPLN + cut_long_message, get_console_length, _get_virtual_line_length, getdoc, + getshortdoc, pad_console_length, split_tags_from_doc, split_args_from_name_or_path, + MAX_ERROR_LINES, _MAX_ERROR_LINE_LENGTH, _ERROR_CUT_EXPLN ) @@ -59,7 +58,7 @@ def test_cut_message_ends_with_original_lines(self): class TestCuttingWithLinesLongerThanMax(unittest.TestCase): def setUp(self): - self.lines = ['line %d' % i for i in range(MAX_ERROR_LINES-1)] + self.lines = [f'line {i}' for i in range(MAX_ERROR_LINES-1)] self.lines.append('x' * (_MAX_ERROR_LINE_LENGTH+1)) self.result = cut_long_message('\n'.join(self.lines)).splitlines() @@ -67,7 +66,8 @@ def test_cut_message_present(self): assert_true(_ERROR_CUT_EXPLN in self.result) def test_correct_number_of_lines(self): - assert_equal(sum(_count_line_lengths(self.result)), MAX_ERROR_LINES+1) + line_count = sum(_get_virtual_line_length(line) for line in self.result) + assert_equal(line_count, MAX_ERROR_LINES+1) def test_correct_lines(self): expected = self.lines[:_HALF_ERROR_LINES] + [_ERROR_CUT_EXPLN] \ @@ -76,12 +76,13 @@ def test_correct_lines(self): def test_every_line_longer_than_limit(self): # sanity check - lines = [('line %d' % i) * _MAX_ERROR_LINE_LENGTH for i in range(MAX_ERROR_LINES+2)] + lines = [f'line {i}' * _MAX_ERROR_LINE_LENGTH for i in range(MAX_ERROR_LINES+2)] result = cut_long_message('\n'.join(lines)).splitlines() assert_true(_ERROR_CUT_EXPLN in result) assert_equal(result[0], lines[0]) assert_equal(result[-1], lines[-1]) - assert_true(sum(_count_line_lengths(result)) <= MAX_ERROR_LINES+1) + line_count = sum(_get_virtual_line_length(line) for line in result) + assert_true(line_count <= MAX_ERROR_LINES+1) class TestCutHappensInsideLine(unittest.TestCase): @@ -112,35 +113,35 @@ def test_one_huge_line(self): assert_true('...\n'+_ERROR_CUT_EXPLN+'\n...' in result) def _assert_basics(self, result, input=None): - assert_equal(sum(_count_line_lengths(result)), MAX_ERROR_LINES+1) + line_count = sum(_get_virtual_line_length(line) for line in result) + assert_equal(line_count, MAX_ERROR_LINES+1) assert_true(_ERROR_CUT_EXPLN in result) if input: assert_equal(result[0], input[0]) assert_equal(result[-1], input[-1]) -class TestCountLines(unittest.TestCase): - - def test_no_lines(self): - assert_equal(_count_line_lengths([]), []) +class TestVirtualLineLength(unittest.TestCase): def test_empty_line(self): - assert_equal(_count_line_lengths(['']), [1]) + assert_equal(_get_virtual_line_length(''), 1) def test_shorter_than_max_lines(self): - lines = ['', '1', 'foo', 'barz and fooz', '', 'a bit longer line', '', - 'This is a somewhat longer (but not long enough) error message'] - assert_equal(_count_line_lengths(lines), [1] * len(lines)) + for line in ['1', 'foo', 'barz and fooz', 'a bit longer line', + 'This is a somewhat longer, but not long enough, line']: + assert_equal(_get_virtual_line_length(line), 1) def test_longer_than_max_lines(self): - lines = [ '1' * i * (_MAX_ERROR_LINE_LENGTH+3) for i in range(4) ] - assert_equal(_count_line_lengths(lines), [1,2,3,4]) + for i in range(10): + length = i * (_MAX_ERROR_LINE_LENGTH+3) + assert_equal(_get_virtual_line_length('x' * length), i+1) def test_boundary(self): - b = _MAX_ERROR_LINE_LENGTH - lengths = [b-1, b, b+1, 2*b-1, 2*b, 2*b+1, 7*b-1, 7*b, 7*b+1] - lines = [ 'e'*length for length in lengths ] - assert_equal(_count_line_lengths(lines), [1, 1, 2, 2, 2, 3, 7, 7, 8]) + m = _MAX_ERROR_LINE_LENGTH + for length, expected in [(m-1, 1), (m, 1), (m+1, 2), + (2*m-1, 2), (2*m, 2), (2*m+1, 3), + (7*m-1, 7), (7*m, 7), (7*m+1, 8)]: + assert_equal(_get_virtual_line_length('x' * length), expected) class TestConsoleWidth(unittest.TestCase): From af71a9abf07066716db3a1d889a6f481a32193a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 11 Sep 2023 20:35:41 +0300 Subject: [PATCH 0689/1592] Test cleanup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most importantly: - Remove unnecessary `u` prefix from strings that still had it. - Avoid `\x` and `\u` escapes and use actual characters instead when possible. For example, `\xe4` -> `ä`. - Use f-strings instead of `%` formatting in files that were otherwise modified. --- utest/htmldata/test_jsonwriter.py | 2 +- utest/model/test_control.py | 6 +- utest/model/test_message.py | 7 +- utest/model/test_tags.py | 20 ++--- utest/model/test_tagstatistics.py | 91 +++++++++++----------- utest/model/test_testcase.py | 6 +- utest/parsing/test_tokenizer.py | 74 +++++++++--------- utest/parsing/test_tokens.py | 4 +- utest/running/test_testlibrary.py | 15 ++-- utest/utils/test_asserts.py | 14 ++-- utest/utils/test_compress.py | 4 +- utest/utils/test_escaping.py | 26 +++---- utest/utils/test_etreesource.py | 16 ++-- utest/utils/test_filereader.py | 2 +- utest/utils/test_htmlwriter.py | 21 +++-- utest/utils/test_importer_util.py | 123 +++++++++++++++--------------- utest/utils/test_markuputils.py | 2 +- utest/utils/test_misc.py | 5 +- utest/utils/test_normalizing.py | 24 +++--- utest/utils/test_robotenv.py | 4 +- utest/utils/test_robotpath.py | 22 +++--- utest/utils/test_robottypes.py | 3 +- utest/utils/test_text.py | 51 +++++++------ utest/utils/test_unic.py | 22 +++--- utest/utils/test_xmlwriter.py | 16 ++-- utest/variables/test_search.py | 32 ++++---- 26 files changed, 301 insertions(+), 311 deletions(-) diff --git a/utest/htmldata/test_jsonwriter.py b/utest/htmldata/test_jsonwriter.py index 056747f352d..40bd4ef9f65 100644 --- a/utest/htmldata/test_jsonwriter.py +++ b/utest/htmldata/test_jsonwriter.py @@ -22,7 +22,7 @@ def test_dump_string(self): self._test('123', '"123"') def test_dump_non_ascii_string(self): - self._test(u'hyv\xe4', u'"hyv\xe4"') + self._test('hyvä', '"hyvä"') def test_escape_string(self): self._test('"-\\-\n-\t-\r', '"\\"-\\\\-\\n-\\t-\\r"') diff --git a/utest/model/test_control.py b/utest/model/test_control.py index 77d38e6d0f9..c5b67300339 100644 --- a/utest/model/test_control.py +++ b/utest/model/test_control.py @@ -110,9 +110,9 @@ def test_string_reprs(self): (IfBranch(ELSE), 'ELSE', "IfBranch(type='ELSE', condition=None)"), - (IfBranch(condition=u'$x == "\xe4iti"'), - u'IF $x == "\xe4iti"', - u"IfBranch(type='IF', condition=%r)" % u'$x == "\xe4iti"'), + (IfBranch(condition='$x == "äiti"'), + 'IF $x == "äiti"', + "IfBranch(type='IF', condition='$x == \"äiti\"')"), ]: assert_equal(str(if_), exp_str) assert_equal(repr(if_), 'robot.model.' + exp_repr) diff --git a/utest/model/test_message.py b/utest/model/test_message.py index dca63bd88e4..9ab70837139 100644 --- a/utest/model/test_message.py +++ b/utest/model/test_message.py @@ -68,19 +68,18 @@ class TestStringRepresentation(unittest.TestCase): def setUp(self): self.empty = Message() self.ascii = Message('Kekkonen', level='WARN') - self.non_ascii = Message(u'hyv\xe4 nimi') + self.non_ascii = Message('hyvä') def test_str(self): for tc, expected in [(self.empty, ''), (self.ascii, 'Kekkonen'), - (self.non_ascii, u'hyv\xe4 nimi')]: + (self.non_ascii, 'hyvä')]: assert_equal(str(tc), expected) def test_repr(self): for tc, expected in [(self.empty, "Message(message='', level='INFO')"), (self.ascii, "Message(message='Kekkonen', level='WARN')"), - (self.non_ascii, u"Message(message=%r, level='INFO')" - % u'hyv\xe4 nimi')]: + (self.non_ascii, "Message(message='hyvä', level='INFO')")]: assert_equal(repr(tc), 'robot.model.' + expected) diff --git a/utest/model/test_tags.py b/utest/model/test_tags.py index bbce0a28305..f057099ad4e 100644 --- a/utest/model/test_tags.py +++ b/utest/model/test_tags.py @@ -99,10 +99,10 @@ def test_truth(self): def test_str(self): assert_equal(str(Tags()), '[]') assert_equal(str(Tags(['y', "X'X", 'Y'])), "[X'X, y]") - assert_equal(str(Tags(['\xe4', 'a'])), '[a, \xe4]') + assert_equal(str(Tags(['ä', 'a'])), '[a, ä]') def test_repr(self): - for tags in ([], ['y', "X'X"], ['\xe4', 'a']): + for tags in ([], ['y', "X'X"], ['ä', 'a']): assert_equal(repr(Tags(tags)), repr(sorted(tags))) def test__add__list(self): @@ -340,21 +340,21 @@ def test_str(self): for pattern in ['a', 'NOT a', 'a NOT b', 'a AND b', 'a OR b', 'a*', 'a OR b NOT c OR d AND e OR ??']: assert_equal(str(TagPatterns(pattern)), - '[%s]' % pattern) + f'[{pattern}]') assert_equal(str(TagPatterns(pattern.replace(' ', ''))), - '[%s]' % pattern) + f'[{pattern}]') assert_equal(str(TagPatterns([pattern, 'x', pattern, 'y'])), - '[%s, x, y]' % pattern) + f'[{pattern}, x, y]') - def test_unicode(self): - pattern = '\xe4 OR \xe5 NOT \xe6 AND \u2603 OR ??' - expected = '[%s]' % pattern + def test_non_ascii(self): + pattern = 'ä OR å NOT æ AND ☃ OR ??' + expected = f'[{pattern}]' assert_equal(str(TagPatterns(pattern)), expected) assert_equal(str(TagPatterns(pattern.replace(' ', ''))), expected) def test_seq2str(self): - patterns = TagPatterns(['is\xe4', '\xe4iti']) - assert_equal(seq2str(patterns), "'is\xe4' and '\xe4iti'") + patterns = TagPatterns(['isä', 'äiti']) + assert_equal(seq2str(patterns), "'isä' and 'äiti'") class AndOrPatternGenerator: diff --git a/utest/model/test_tagstatistics.py b/utest/model/test_tagstatistics.py index dea4c672342..19601480b61 100644 --- a/utest/model/test_tagstatistics.py +++ b/utest/model/test_tagstatistics.py @@ -2,22 +2,21 @@ from robot.utils.asserts import assert_equal, assert_none from robot.model.tagstatistics import TagStatisticsBuilder, TagStatLink -from robot.model import Tags from robot.result import TestCase from robot.utils import MultiMatcher class TestTagStatistics(unittest.TestCase): _incl_excl_data = [([], []), - ([], ['t1','t2']), - (['t1'], ['t1','t2']), - (['t1','t2'], ['t1','t2','t3','t4']), - (['UP'], ['t1','t2','up']), - (['not','not2'], ['t1','t2','t3']), - (['t*'], ['t1','s1','t2','t3','s2','s3']), - (['T*','r'], ['t1','t2','r','teeeeeeee']), - (['*'], ['t1','t2','s1','tag']), - (['t1','t2','t3','not'], ['t1','t2','t3','t4','s1','s2'])] + ([], ['t1', 't2']), + (['t1'], ['t1', 't2']), + (['t1', 't2'], ['t1', 't2', 't3', 't4']), + (['UP'], ['t1', 't2', 'up']), + (['not', 'not2'], ['t1', 't2', 't3']), + (['t*'], ['t1', 's1', 't2', 't3', 's2', 's3']), + (['T*', 'r'], ['t1', 't2', 'r', 'teeeeeeee']), + (['*'], ['t1', 't2', 's1', 'tag']), + (['t1', 't2', 't3', 'not'], ['t1', 't2', 't3', 't4', 's1', 's2'])] def test_include(self): for incl, tags in self._incl_excl_data: @@ -37,13 +36,13 @@ def test_exclude(self): def test_include_and_exclude(self): for incl, excl, tags, exp in [ - ([], [], ['t0','t1','t2'], ['t0','t1','t2']), - (['t1'], ['t2'], ['t0','t1','t2'], ['t1']), - (['t?'], ['t2'], ['t0','t1','t2','x'], ['t0','t1']), - (['t?'], ['*2'], ['t0','t1','t2','x2'], ['t0','t1']), - (['t1','t2'], ['t2'], ['t0','t1','t2'], ['t1']), - (['t1','t2','t3','not'], ['t2','t0'], - ['t0','t1','t2','t3','x'], ['t1','t3'] ) + ([], [], ['t0', 't1', 't2'], ['t0', 't1', 't2']), + (['t1'], ['t2'], ['t0', 't1', 't2'], ['t1']), + (['t?'], ['t2'], ['t0', 't1', 't2', 'x'], ['t0', 't1']), + (['t?'], ['*2'], ['t0', 't1', 't2', 'x2'], ['t0', 't1']), + (['t1', 't2'], ['t2'], ['t0', 't1', 't2'], ['t1']), + (['t1', 't2', 't3', 'not'], ['t2', 't0'], + ['t0', 't1', 't2', 't3', 'x'], ['t1', 't3'] ) ]: builder = TagStatisticsBuilder(included=incl, excluded=excl) builder.add_test(TestCase(status='PASS', tags=tags)) @@ -68,12 +67,12 @@ def test_is_combined_with_and_statements(self): ('t1', ['t1'], 1), ('t1', ['t2'], 0), ('t1&t2', ['t1'], 0), - ('t1&t2', ['t1','t2'], 1), - ('t1&t2', ['T1','t 2','t3'], 1), - ('t*', ['s','t','u'], 1), - ('t*', ['s','tee','t'], 1), - ('t*&s', ['s','tee','t'], 1), - ('t*&s&non', ['s','tee','t'], 0) + ('t1&t2', ['t1', 't2'], 1), + ('t1&t2', ['T1', 't 2', 't3'], 1), + ('t*', ['s', 't', 'u'], 1), + ('t*', ['s', 'tee', 't'], 1), + ('t*&s', ['s', 'tee', 't'], 1), + ('t*&s&non', ['s', 'tee', 't'], 0) ]: self._verify_combined_statistics(comb_tags, test_tags, expected_count) @@ -86,9 +85,9 @@ def test_is_combined_with_not_statements(self): for comb_tags, test_tags, expected_count in [ ('t1NOTt2', [], 0), ('t1NOTt2', ['t1'], 1), - ('t1NOTt2', ['t1','t2'], 0), + ('t1NOTt2', ['t1', 't2'], 0), ('t1NOTt2', ['t3'], 0), - ('t1NOTt2', ['t3','t2'], 0), + ('t1NOTt2', ['t3', 't2'], 0), ('t*NOTt2', ['t1'], 1), ('t*NOTt2', ['t'], 1), ('t*NOTt2', ['TEE'], 1), @@ -96,12 +95,12 @@ def test_is_combined_with_not_statements(self): ('T*NOTT?', ['t'], 1), ('T*NOTT?', ['tt'], 0), ('T*NOTT?', ['ttt'], 1), - ('T*NOTT?', ['tt','t'], 0), - ('T*NOTT?', ['ttt','something'], 1), + ('T*NOTT?', ['tt', 't'], 0), + ('T*NOTT?', ['ttt', 'something'], 1), ('tNOTs*NOTr', ['t'], 1), - ('tNOTs*NOTr', ['t','s'], 0), - ('tNOTs*NOTr', ['S','T'], 0), - ('tNOTs*NOTr', ['R','T','s'], 0), + ('tNOTs*NOTr', ['t', 's'], 0), + ('tNOTs*NOTr', ['S', 'T'], 0), + ('tNOTs*NOTr', ['R', 'T', 's'], 0), ('*NOTt', ['t'], 0), ('*NOTt', ['e'], 1), ('*NOTt', [], 0), @@ -167,15 +166,15 @@ def test_iter_sorting(self): def test_combine(self): # This is more like an acceptance test than a unit test ... for comb_tags, tests_tags in [ - (['t1&t2'], [['t1','t2','t3'],['t1','t3']]), - (['1&2&3'], [['1','2','3'],['1','2','3','4']]), - (['1&2','1&3'], [['1','2','3'],['1','3'],['1']]), - (['t*'], [['t1','x','y'],['tee','z'],['t']]), - (['t?&s'], [['t1','s'],['tt','s','u'],['tee','s']]), - (['t*&s','*'], [['s','t','u'],['tee','s'],[],['x']]), - (['tNOTs'], [['t','u'],['t','s']]), - (['tNOTs','t&s','tNOTsNOTu', 't&sNOTu'], - [['t','u'],['t','s'],['s','t','u'],['t'],['t','v']]), + (['t1&t2'], [['t1', 't2', 't3'],['t1', 't3']]), + (['1&2&3'], [['1', '2', '3'],['1', '2', '3', '4']]), + (['1&2', '1&3'], [['1', '2', '3'],['1', '3'],['1']]), + (['t*'], [['t1', 'x', 'y'],['tee', 'z'],['t']]), + (['t?&s'], [['t1', 's'],['tt', 's', 'u'],['tee', 's']]), + (['t*&s', '*'], [['s', 't', 'u'],['tee', 's'],[],['x']]), + (['tNOTs'], [['t', 'u'],['t', 's']]), + (['tNOTs', 't&s', 'tNOTsNOTu', 't&sNOTu'], + [['t', 'u'],['t', 's'],['s', 't', 'u'],['t'],['t', 'v']]), (['nonex'], [['t1'],['t1,t2'],[]]) ]: # 1) Create tag stats @@ -228,10 +227,8 @@ class TestTagStatLink(unittest.TestCase): def test_valid_string_is_parsed_correctly(self): for arg, exp in [(('Tag', 'bar/foo.html', 'foobar'), ('^Tag$', 'bar/foo.html', 'foobar')), - (('hello', 'gopher://hello.world:8090/hello.html', - 'Hello World'), - ('^hello$', 'gopher://hello.world:8090/hello.html', - 'Hello World'))]: + (('hi', 'gopher://hi.world:8090/hi.html', 'Hi World'), + ('^hi$', 'gopher://hi.world:8090/hi.html', 'Hi World'))]: link = TagStatLink(*arg) assert_equal(exp[0], link._regexp.pattern) assert_equal(exp[1], link._link) @@ -282,14 +279,14 @@ def test_pattern_match(self): def test_pattern_substitution_with_one_match(self): link = TagStatLink('tag-*', 'http://tracker/?id=%1', 'Tracker') for id in ['1', '23', '456']: - exp = ('http://tracker/?id=%s' % id, 'Tracker') - assert_equal(exp, link.get_link('tag-%s' % id)) + exp = (f'http://tracker/?id={id}', 'Tracker') + assert_equal(exp, link.get_link(f'tag-{id}')) def test_pattern_substitution_with_multiple_matches(self): link = TagStatLink('?-*', 'http://tracker/?id=%1-%2', 'Tracker') for id1, id2 in [('1', '2'), ('3', '45'), ('f', 'bar')]: - exp = ('http://tracker/?id=%s-%s' % (id1, id2), 'Tracker') - assert_equal(exp, link.get_link('%s-%s' % (id1, id2))) + exp = (f'http://tracker/?id={id1}-{id2}', 'Tracker') + assert_equal(exp, link.get_link(f'{id1}-{id2}')) def test_pattern_substitution_with_multiple_substitutions(self): link = TagStatLink('??-?-*', '%3-%3-%1-%2-%3', 'Tracker') diff --git a/utest/model/test_testcase.py b/utest/model/test_testcase.py index 4f39089d9dc..72815175a25 100644 --- a/utest/model/test_testcase.py +++ b/utest/model/test_testcase.py @@ -115,18 +115,18 @@ class TestStringRepresentation(unittest.TestCase): def setUp(self): self.empty = TestCase() self.ascii = TestCase(name='Kekkonen') - self.non_ascii = TestCase(name=u'hyv\xe4 nimi') + self.non_ascii = TestCase(name='hyvä nimi') def test_str(self): for tc, expected in [(self.empty, ''), (self.ascii, 'Kekkonen'), - (self.non_ascii, u'hyv\xe4 nimi')]: + (self.non_ascii, 'hyvä nimi')]: assert_equal(str(tc), expected) def test_repr(self): for tc, expected in [(self.empty, "TestCase(name='')"), (self.ascii, "TestCase(name='Kekkonen')"), - (self.non_ascii, u"TestCase(name=%r)" % u'hyv\xe4 nimi')]: + (self.non_ascii, "TestCase(name='hyvä nimi')")]: assert_equal(repr(tc), 'robot.model.' + expected) diff --git a/utest/parsing/test_tokenizer.py b/utest/parsing/test_tokenizer.py index 728e803bc62..36656b89446 100644 --- a/utest/parsing/test_tokenizer.py +++ b/utest/parsing/test_tokenizer.py @@ -316,62 +316,62 @@ def test_multiline_with_empty_lines(self): class TestNonAsciiSpaces(unittest.TestCase): - spaces = (u'\N{NO-BREAK SPACE}\N{OGHAM SPACE MARK}\N{EN QUAD}' - u'\N{EM SPACE}\N{HAIR SPACE}\N{IDEOGRAPHIC SPACE}') + spaces = ('\N{NO-BREAK SPACE}\N{OGHAM SPACE MARK}\N{EN QUAD}' + '\N{EM SPACE}\N{HAIR SPACE}\N{IDEOGRAPHIC SPACE}') data = '-' + '-'.join(spaces) + '-' def test_as_separator(self): - spaces = self.spaces - ls = len(spaces) - verify_split(u'Hello{s}world\n{s}!!!{s}\n'.format(s=spaces), + s = self.spaces + ls = len(s) + verify_split(f'Hello{s}world\n{s}!!!{s}\n', [(DATA, 'Hello', 1, 0), - (SEPA, spaces, 1, 5), + (SEPA, s, 1, 5), (DATA, 'world', 1, 5+ls), (EOL, '\n', 1, 5+ls+5)], [(DATA, '', 2, 0), - (SEPA, spaces, 2, 0), + (SEPA, s, 2, 0), (DATA, '!!!', 2, ls), - (EOL, spaces+'\n', 2, ls+3)]) + (EOL, s+'\n', 2, ls+3)]) def test_as_separator_with_pipes(self): - spaces = self.spaces - ls = len(spaces) - verify_split(u'|{s}Hello{s}world{s}|{s}!\n|{s}|{s}!!!{s}|{s}\n'.format(s=spaces), - [(SEPA, '|'+spaces, 1, 0), - (DATA, 'Hello'+spaces+'world', 1, 1+ls), - (SEPA, spaces+'|'+spaces, 1, 1+ls+5+ls+5), + s = self.spaces + ls = len(s) + verify_split(f'|{s}Hello{s}world{s}|{s}!\n|{s}|{s}!!!{s}|{s}\n', + [(SEPA, '|'+s, 1, 0), + (DATA, 'Hello'+s+'world', 1, 1+ls), + (SEPA, s+'|'+s, 1, 1+ls+5+ls+5), (DATA, '!', 1, 1+ls+5+ls+5+ls+1+ls), (EOL, '\n', 1, 1+ls+5+ls+5+ls+1+ls+1)], - [(SEPA, '|'+spaces, 2, 0), + [(SEPA, '|'+s, 2, 0), (DATA, '', 2, 1+ls), - (SEPA, '|'+spaces, 2, 1+ls), + (SEPA, '|'+s, 2, 1+ls), (DATA, '!!!', 2, 1+ls+1+ls), - (SEPA, spaces+'|', 2, 1+ls+1+ls+3), - (EOL, spaces+'\n', 2, 1+ls+1+ls+3+ls+1)]) + (SEPA, s+'|', 2, 1+ls+1+ls+3), + (EOL, s+'\n', 2, 1+ls+1+ls+3+ls+1)]) def test_in_data(self): - data = self.data - spaces = self.spaces - ld = len(data) - ls = len(spaces) - verify_split(u'{d}{s}{d}{s}{d}'.format(d=data, s=spaces), - [(DATA, data, 1, 0), - (SEPA, spaces, 1, ld), - (DATA, data, 1, ld+ls), - (SEPA, spaces, 1, ld+ls+ld), - (DATA, data, 1, ld+ls+ld+ls), + d = self.data + s = self.spaces + ld = len(d) + ls = len(s) + verify_split(f'{d}{s}{d}{s}{d}', + [(DATA, d, 1, 0), + (SEPA, s, 1, ld), + (DATA, d, 1, ld+ls), + (SEPA, s, 1, ld+ls+ld), + (DATA, d, 1, ld+ls+ld+ls), (EOL, '', 1, ld+ls+ld+ls+ld)]) def test_in_data_with_pipes(self): - data = self.data - spaces = self.spaces - ld = len(data) - ls = len(spaces) - verify_split(u'|{s}{d}{s}|{s}{d}'.format(d=data, s=spaces), - [(SEPA, '|'+spaces, 1, 0), - (DATA, data, 1, 1+ls), - (SEPA, spaces+'|'+spaces, 1, 1+ls+ld), - (DATA, data, 1, 1+ls+ld+ls+1+ls), + d = self.data + s = self.spaces + ld = len(d) + ls = len(s) + verify_split(f'|{s}{d}{s}|{s}{d}', + [(SEPA, '|'+s, 1, 0), + (DATA, d, 1, 1+ls), + (SEPA, s+'|'+s, 1, 1+ls+ld), + (DATA, d, 1, 1+ls+ld+ls+1+ls), (EOL, '', 1, 1+ls+ld+ls+1+ls+ld)]) diff --git a/utest/parsing/test_tokens.py b/utest/parsing/test_tokens.py index c7534c929c9..828214749f2 100644 --- a/utest/parsing/test_tokens.py +++ b/utest/parsing/test_tokens.py @@ -11,8 +11,8 @@ def test_string_repr(self): for token, exp_str, exp_repr in [ ((Token.ELSE_IF, 'ELSE IF', 6, 4), 'ELSE IF', "Token(ELSE_IF, 'ELSE IF', 6, 4)"), - ((Token.KEYWORD, u'Hyv\xe4', 6, 4), u'Hyv\xe4', - u"Token(KEYWORD, %r, 6, 4)" % u'Hyv\xe4'), + ((Token.KEYWORD, 'Hyvä', 6, 4), 'Hyvä', + "Token(KEYWORD, 'Hyvä', 6, 4)"), ((Token.ERROR, 'bad value', 6, 4, 'The error.'), 'bad value', "Token(ERROR, 'bad value', 6, 4, 'The error.')"), (((), '', diff --git a/utest/running/test_testlibrary.py b/utest/running/test_testlibrary.py index 29d0f164c7f..d4dc7210116 100644 --- a/utest/running/test_testlibrary.py +++ b/utest/running/test_testlibrary.py @@ -97,10 +97,10 @@ def test_import_invalid_type(self): TestLibrary, 'pythonmodule.some_object') def test_import_with_unicode_name(self): - self._verify_lib(TestLibrary(u"BuiltIn"), "BuiltIn", default_keywords) - self._verify_lib(TestLibrary(u"robot.libraries.BuiltIn.BuiltIn"), + self._verify_lib(TestLibrary("BuiltIn"), "BuiltIn", default_keywords) + self._verify_lib(TestLibrary("robot.libraries.BuiltIn.BuiltIn"), "robot.libraries.BuiltIn.BuiltIn", default_keywords) - self._verify_lib(TestLibrary(u"pythonmodule.library"), "pythonmodule.library", + self._verify_lib(TestLibrary("pythonmodule.library"), "pythonmodule.library", [("keyword from submodule", None)]) def test_global_scope(self): @@ -131,8 +131,7 @@ def _verify_lib(self, lib, libname, keywords): assert_equal(libname, lib.name) for name, _ in keywords: handler = lib.handlers[name] - exp = "%s.%s" % (libname, name) - assert_equal(normalize(handler.longname), normalize(exp)) + assert_equal(normalize(handler.longname), normalize(f"{libname}.{name}")) class TestLibraryInit(unittest.TestCase): @@ -316,7 +315,7 @@ class TestHandlers(unittest.TestCase): def test_get_handlers(self): for lib in [NameLibrary, DocLibrary, ArgInfoLibrary, GetattrLibrary, SynonymLibrary]: - handlers = TestLibrary('classes.%s' % lib.__name__).handlers + handlers = TestLibrary(f'classes.{lib.__name__}').handlers assert_equal(lib.handler_count, len(handlers), lib.__name__) for handler in handlers: assert_false(handler._handler_name.startswith('_')) @@ -345,7 +344,7 @@ def test_synonym_handlers(self): for handler in testlib.handlers: # test 'handler_name' -- raises ValueError if it isn't in 'names' names.remove(handler._handler_name) - assert_equal(len(names), 0, 'handlers %s not created' % names, False) + assert_equal(len(names), 0, f'handlers {names} not created', False) def test_global_handlers_are_created_only_once(self): lib = TestLibrary('classes.RecordingLibrary') @@ -518,7 +517,7 @@ def replace_string(self, variable): try: return self.variables[variable] except KeyError: - raise DataError("Non-existing variable '%s'" % variable) + raise DataError(f"Non-existing variable '{variable}'") def __setitem__(self, key, value): self.variables.__setitem__(key, value) def __getitem__(self, key): diff --git a/utest/utils/test_asserts.py b/utest/utils/test_asserts.py index 4d5b61252db..f5af0000a62 100644 --- a/utest/utils/test_asserts.py +++ b/utest/utils/test_asserts.py @@ -77,11 +77,11 @@ def test_assert_equal_with_values_having_same_string_repr(self): assert_equal, 'True', True) def test_assert_equal_with_custom_formatter(self): - assert_equal('hyv\xe4', 'hyv\xe4', formatter=repr) - assert_raises_with_msg(AE, "'hyv\xe4' != 'paha'", - assert_equal, 'hyv\xe4', 'paha', formatter=repr) + assert_equal('hyvä', 'hyvä', formatter=repr) + assert_raises_with_msg(AE, "'hyvä' != 'paha'", + assert_equal, 'hyvä', 'paha', formatter=repr) assert_raises_with_msg(AE, "'hyv\\xe4' != 'paha'", - assert_equal, 'hyv\xe4', 'paha', formatter=ascii) + assert_equal, 'hyvä', 'paha', formatter=ascii) def test_assert_not_equal(self): assert_not_equal('abc', 'ABC') @@ -95,9 +95,9 @@ def test_assert_not_equal(self): 'hello', False) def test_assert_not_equal_with_custom_formatter(self): - assert_not_equal('hyv\xe4', 'paha', formatter=repr) - assert_raises_with_msg(AE, "'\xe4' == '\xe4'", - assert_not_equal, '\xe4', '\xe4', formatter=repr) + assert_not_equal('hyvä', 'paha', formatter=repr) + assert_raises_with_msg(AE, "'ä' == 'ä'", + assert_not_equal, 'ä', 'ä', formatter=repr) def test_fail(self): assert_raises(AE, fail) diff --git a/utest/utils/test_compress.py b/utest/utils/test_compress.py index 28f5960468b..caebeb32fae 100644 --- a/utest/utils/test_compress.py +++ b/utest/utils/test_compress.py @@ -22,8 +22,8 @@ def test_100_char_strings(self): 'Rsakjaf AdfSasda asldjfaerew lasldjf awlkr aslk sd rl') def test_non_ascii(self): - self._test('hyv\xe4') - self._test('\u4e2d\u6587') + self._test('hyvä') + self._test('中文') if __name__ == '__main__': diff --git a/utest/utils/test_escaping.py b/utest/utils/test_escaping.py index 64d067a6c1b..5f76fba0f9f 100644 --- a/utest/utils/test_escaping.py +++ b/utest/utils/test_escaping.py @@ -20,8 +20,8 @@ def test_single_backslash(self): ('\\ ', ' '), ('a\\', 'a'), ('\\a', 'a'), - ('\\-', u'-'), - (u'\\\xe4', u'\xe4'), + ('\\-', '-'), + ('\\ä', 'ä'), ('\\0', '0'), ('a\\b\\c\\d', 'abcd')]: assert_unescape(inp, exp) @@ -86,9 +86,9 @@ def test_invalid_x(self): assert_unescape(inp, inp.replace('\\', '')) def test_valid_x(self): - for inp, exp in [(r'\x00', u'\x00'), - (r'\xab\xBA', u'\xab\xba'), - (r'\xe4iti', u'\xe4iti')]: + for inp, exp in [(r'\x00', '\x00'), + (r'\xab\xBA', '\xab\xba'), + (r'\xe4iti', 'äiti')]: assert_unescape(inp, exp) def test_invalid_u(self): @@ -104,9 +104,9 @@ def test_invalid_u(self): assert_unescape(inp, inp.replace('\\', '')) def test_valid_u(self): - for inp, exp in [(r'\u0000', u'\x00'), - (r'\uABba', u'\uabba'), - (r'\u00e4iti', u'\xe4iti')]: + for inp, exp in [(r'\u0000', '\x00'), + (r'\uABba', '\uabba'), + (r'\u00e4iti', 'äiti')]: assert_unescape(inp, exp) def test_invalid_U(self): @@ -122,11 +122,11 @@ def test_invalid_U(self): assert_unescape(inp, inp.replace('\\', '')) def test_valid_U(self): - for inp, exp in [(r'\U00000000', u'\x00'), - (r'\U0000ABba', u'\uabba'), - (r'\U0001f3e9', u'\U0001f3e9'), - (r'\U0010FFFF', u'\U0010ffff'), - (r'\U000000e4iti', u'\xe4iti')]: + for inp, exp in [(r'\U00000000', '\x00'), + (r'\U0000ABba', '\uabba'), + (r'\U0001f3e9', '\U0001f3e9'), + (r'\U0010FFFF', '\U0010ffff'), + (r'\U000000e4iti', 'äiti')]: assert_unescape(inp, exp) def test_U_above_valid_range(self): diff --git a/utest/utils/test_etreesource.py b/utest/utils/test_etreesource.py index 19f5fa093ea..9fc63564761 100644 --- a/utest/utils/test_etreesource.py +++ b/utest/utils/test_etreesource.py @@ -44,18 +44,18 @@ def test_string(self): def test_byte_string(self): self._test_string(b'\n<tag>content</tag>') - self._test_string('<tag>hyv\xe4</tag>'.encode('utf8')) + self._test_string('<tag>hyvä</tag>'.encode('utf8')) self._test_string('<?xml version="1.0" encoding="Latin1"?>\n' - '<tag>hyv\xe4</tag>'.encode('latin-1'), 'latin-1') + '<tag>hyvä</tag>'.encode('latin-1'), 'latin-1') def test_unicode_string(self): - self._test_string('\n<tag>hyv\xe4</tag>\n') + self._test_string('\n<tag>hyvä</tag>\n') self._test_string('<?xml version="1.0" encoding="latin1"?>\n' - '<tag>hyv\xe4</tag>', 'latin-1') + '<tag>hyvä</tag>', 'latin-1') self._test_string("<?xml version='1.0' encoding='iso-8859-1' standalone='yes'?>\n" - "<tag>hyv\xe4</tag>", 'latin-1') + "<tag>hyvä</tag>", 'latin-1') - def _test_string(self, xml, encoding='UTF-8'): + def _test_string(self, xml: 'str|bytes', encoding='UTF-8'): source = ETSource(xml) with source as src: content = src.read() @@ -67,11 +67,11 @@ def _test_string(self, xml, encoding='UTF-8'): assert_equal(ET.parse(src).getroot().tag, 'tag') def test_non_ascii_string_repr(self): - self._verify_string_representation(ETSource('\xe4'), '\xe4') + self._verify_string_representation(ETSource('ä'), 'ä') def _verify_string_representation(self, source, expected): assert_equal(str(source), expected) - assert_equal('-%s-' % source, '-%s-' % expected) + assert_equal(f'-{source}-', f'-{source}-') if __name__ == '__main__': diff --git a/utest/utils/test_filereader.py b/utest/utils/test_filereader.py index 03aa8f774e6..a157f574409 100644 --- a/utest/utils/test_filereader.py +++ b/utest/utils/test_filereader.py @@ -11,7 +11,7 @@ TEMPDIR = os.getenv('TEMPDIR') or tempfile.gettempdir() PATH = os.path.join(TEMPDIR, 'filereader.test') -STRING = u'Hyv\xe4\xe4\nty\xf6t\xe4\nC\u043f\u0430\u0441\u0438\u0431\u043e\n' +STRING = 'Hyvää\ntyötä\nCпасибо\n' def assert_reader(reader, name=PATH): diff --git a/utest/utils/test_htmlwriter.py b/utest/utils/test_htmlwriter.py index ef295513b15..d823869a1f9 100644 --- a/utest/utils/test_htmlwriter.py +++ b/utest/utils/test_htmlwriter.py @@ -1,5 +1,5 @@ -from io import StringIO import unittest +from io import StringIO from robot.utils import HtmlWriter from robot.utils.asserts import assert_equal @@ -28,8 +28,8 @@ def test_start_with_attributes(self): self._verify('<test a="z" class="123" x="y">\n') def test_start_with_non_ascii_attributes(self): - self.writer.start('test', {'name': u'\xA7', u'\xE4': u'\xA7'}) - self._verify(u'<test name="\xA7" \xE4="\xA7">\n') + self.writer.start('test', {'name': '§', 'ä': '§'}) + self._verify('<test name="§" ä="§">\n') def test_start_with_quotes_in_attribute_value(self): self.writer.start('x', {'q':'"', 'qs': '""""', 'a': "'"}, False) @@ -64,11 +64,10 @@ def test_content(self): def test_content_with_non_ascii_data(self): self.writer.start('robot', newline=False) - self.writer.content(u'Circle is 360\xB0. ') - self.writer.content(u'Hyv\xE4\xE4 \xFC\xF6t\xE4!') + self.writer.content('Circle is 360°. ') + self.writer.content('Hyvää üötä!') self.writer.end('robot', newline=False) - expected = u'Circle is 360\xB0. Hyv\xE4\xE4 \xFC\xF6t\xE4!' - self._verify('<robot>%s</robot>' % expected) + self._verify('<robot>Circle is 360°. Hyvää üötä!</robot>') def test_multiple_content(self): self.writer.start('robot') @@ -106,11 +105,11 @@ def test_line_separator(self): def test_non_ascii(self): self.output = StringIO() writer = HtmlWriter(self.output) - writer.start(u'p', attrs={'name': u'hyv\xe4\xe4'}, newline=False) - writer.content(u'y\xf6') - writer.element('i', u't\xe4', newline=False) + writer.start('p', attrs={'name': 'hyvää'}, newline=False) + writer.content('yö') + writer.element('i', 'tä', newline=False) writer.end('p', newline=False) - self._verify(u'<p name="hyv\xe4\xe4">y\xf6<i>t\xe4</i></p>') + self._verify('<p name="hyvää">yö<i>tä</i></p>') def _verify(self, expected): assert_equal(self.output.getvalue(), expected) diff --git a/utest/utils/test_importer_util.py b/utest/utils/test_importer_util.py index 35b787b7589..e2c939a9da9 100644 --- a/utest/utils/test_importer_util.py +++ b/utest/utils/test_importer_util.py @@ -1,23 +1,23 @@ -import unittest -import tempfile import inspect -import shutil -import sys import os import re -from os.path import basename, dirname, exists, join, normpath +import shutil +import sys +import tempfile +import unittest +from pathlib import Path from robot.errors import DataError from robot.utils import abspath, WINDOWS -from robot.utils.importer import Importer, ByPathImporter -from robot.utils.asserts import (assert_equal, assert_true, assert_raises, - assert_raises_with_msg) +from robot.utils.asserts import (assert_equal, assert_raises, assert_raises_with_msg, + assert_true) +from robot.utils.importer import ByPathImporter, Importer -CURDIR = dirname(abspath(__file__)) -LIBDIR = normpath(join(CURDIR, '..', '..', 'atest', 'testresources', 'testlibs')) -TEMPDIR = tempfile.gettempdir() -TESTDIR = join(TEMPDIR, 'robot-importer-testing') +CURDIR = Path(__file__).resolve().parent +LIBDIR = (CURDIR / '../../atest/testresources/testlibs').resolve() +TEMPDIR = Path(tempfile.gettempdir()) +TESTDIR = TEMPDIR / 'robot-importer-testing' WINDOWS_PATH_IN_ERROR = re.compile(r"'\w:\\") @@ -29,15 +29,14 @@ def assert_prefix(error, expected): def create_temp_file(name, attr=42, extra_content=''): - if not exists(TESTDIR): - os.mkdir(TESTDIR) - path = join(TESTDIR, name) + TESTDIR.mkdir(exist_ok=True) + path = TESTDIR / name with open(path, 'w') as file: - file.write(''' -attr = %r + file.write(f''' +attr = {attr} def func(): return attr -''' % attr) +''') file.write(extra_content) return path @@ -69,7 +68,7 @@ def setUp(self): self.tearDown() def tearDown(self): - if exists(TESTDIR): + if TESTDIR.exists(): shutil.rmtree(TESTDIR) def test_python_file(self): @@ -79,9 +78,8 @@ def test_python_file(self): def test_python_directory(self): create_temp_file('__init__.py') - module_name = basename(TESTDIR) - self._import_and_verify(TESTDIR, remove=module_name) - self._assert_imported_message(module_name, TESTDIR) + self._import_and_verify(TESTDIR, remove=TESTDIR.name) + self._assert_imported_message(TESTDIR.name, TESTDIR) def test_import_same_file_multiple_times(self): path = create_temp_file('test.py') @@ -96,13 +94,13 @@ def test_import_different_file_and_directory_with_same_name(self): path1 = create_temp_file('test.py', attr=1) self._import_and_verify(path1, attr=1, remove='test') self._assert_imported_message('test', path1) - path2 = join(TESTDIR, 'test') - os.mkdir(path2) - create_temp_file(join(path2, '__init__.py'), attr=2) + path2 = TESTDIR / 'test' + path2.mkdir() + create_temp_file(path2 / '__init__.py', attr=2) self._import_and_verify(path2, attr=2, directory=path2) self._assert_removed_message('test') self._assert_imported_message('test', path2, index=1) - path3 = create_temp_file(join(path2, 'test.py'), attr=3) + path3 = create_temp_file(path2 / 'test.py', attr=3) self._import_and_verify(path3, attr=3, directory=path2) self._assert_removed_message('test') self._assert_imported_message('test', path3, index=1) @@ -122,7 +120,7 @@ def method(self): def test_invalid_python_file(self): path = create_temp_file('test.py', extra_content='invalid content') error = assert_raises(DataError, self._import_and_verify, path, remove='test') - assert_prefix(error, "Importing '%s' failed: SyntaxError:" % path) + assert_prefix(error, f"Importing '{path}' failed: SyntaxError:") def _import_and_verify(self, path, attr=42, directory=TESTDIR, name=None, remove=None): @@ -130,7 +128,7 @@ def _import_and_verify(self, path, attr=42, directory=TESTDIR, assert_equal(module.attr, attr) assert_equal(module.func(), attr) if hasattr(module, '__file__'): - assert_equal(dirname(abspath(module.__file__)), directory) + assert_equal(Path(module.__file__).resolve().parent, directory) def _import(self, path, name=None, remove=None): if remove and remove in sys.modules: @@ -144,11 +142,11 @@ def _import(self, path, name=None, remove=None): assert_equal(sys.path, sys_path_before) def _assert_imported_message(self, name, source, type='module', index=0): - msg = "Imported %s '%s' from '%s'." % (type, name, source) + msg = f"Imported {type} '{name}' from '{source}'." self.logger.assert_message(msg, index=index) def _assert_removed_message(self, name, index=0): - msg = "Removed module '%s' from sys.modules to import fresh module." % name + msg = f"Removed module '{name}' from sys.modules to import fresh module." self.logger.assert_message(msg, index=index) @@ -158,13 +156,13 @@ def test_non_existing(self): path = 'non-existing.py' assert_raises_with_msg( DataError, - "Importing '%s' failed: File or directory does not exist." % path, + f"Importing '{path}' failed: File or directory does not exist.", Importer().import_class_or_module_by_path, path ) path = abspath(path) assert_raises_with_msg( DataError, - "Importing test file '%s' failed: File or directory does not exist." % path, + f"Importing test file '{path}' failed: File or directory does not exist.", Importer('test file').import_class_or_module_by_path, path ) @@ -172,25 +170,25 @@ def test_non_absolute(self): path = os.listdir('.')[0] assert_raises_with_msg( DataError, - "Importing '%s' failed: Import path must be absolute." % path, + f"Importing '{path}' failed: Import path must be absolute.", Importer().import_class_or_module_by_path, path ) assert_raises_with_msg( DataError, - "Importing file '%s' failed: Import path must be absolute." % path, + f"Importing file '{path}' failed: Import path must be absolute.", Importer('file').import_class_or_module_by_path, path ) def test_invalid_format(self): - path = join(CURDIR, '..', '..', 'README.rst') + path = CURDIR / '../../README.rst' assert_raises_with_msg( DataError, - "Importing '%s' failed: Not a valid file or directory to import." % path, + f"Importing '{path}' failed: Not a valid file or directory to import.", Importer().import_class_or_module_by_path, path ) assert_raises_with_msg( DataError, - "Importing xxx '%s' failed: Not a valid file or directory to import." % path, + f"Importing xxx '{path}' failed: Not a valid file or directory to import.", Importer('xxx').import_class_or_module_by_path, path ) @@ -255,37 +253,37 @@ def test_item_from_non_existing_module(self): def test_import_file_by_path(self): import module_library as expected - module = self._import_module(join(LIBDIR, 'module_library.py')) + module = self._import_module(LIBDIR / 'module_library.py') assert_equal(module.__name__, expected.__name__) - assert_equal(dirname(normpath(module.__file__)), - dirname(normpath(expected.__file__))) + assert_equal(Path(module.__file__).resolve().parent, + Path(expected.__file__).resolve().parent) assert_equal(dir(module), dir(expected)) def test_import_class_from_file_by_path(self): - klass = self._import_class(join(LIBDIR, 'ExampleLibrary.py')) + klass = self._import_class(LIBDIR / 'ExampleLibrary.py') assert_equal(klass().return_string_from_library('test'), 'test') def test_invalid_file_by_path(self): - path = join(TEMPDIR, 'robot_import_invalid_test_file.py') + path = TEMPDIR / 'robot_import_invalid_test_file.py' try: with open(path, 'w') as file: file.write('invalid content') error = assert_raises(DataError, self._import, path) - assert_prefix(error, "Importing '%s' failed: SyntaxError:" % path) + assert_prefix(error, f"Importing '{path}' failed: SyntaxError:") finally: os.remove(path) def test_logging_when_importing_module(self): logger = LoggerStub(remove_extension=True) self._import_module('classes', 'test library', logger) - logger.assert_message("Imported test library module 'classes' from '%s'." - % join(LIBDIR, 'classes')) + logger.assert_message(f"Imported test library module 'classes' from " + f"'{LIBDIR / 'classes'}'.") def test_logging_when_importing_python_class(self): logger = LoggerStub(remove_extension=True) self._import_class('ExampleLibrary', logger=logger) - logger.assert_message("Imported class 'ExampleLibrary' from '%s'." - % join(LIBDIR, 'ExampleLibrary')) + logger.assert_message(f"Imported class 'ExampleLibrary' from " + f"'{LIBDIR / 'ExampleLibrary'}'.") def _import_module(self, name, type=None, logger=None): module = self._import(name, type, logger) @@ -310,8 +308,8 @@ def test_import_module(self): def test_logging(self): logger = LoggerStub(remove_extension=True) Importer(logger=logger).import_module('ExampleLibrary') - logger.assert_message("Imported module 'ExampleLibrary' from '%s'." - % join(LIBDIR, 'ExampleLibrary')) + logger.assert_message(f"Imported module 'ExampleLibrary' from " + f"'{LIBDIR / 'ExampleLibrary'}'.") class TestErrorDetails(unittest.TestCase): @@ -327,10 +325,10 @@ def test_traceback(self): error = self._failing_import(path) finally: shutil.rmtree(TESTDIR) - assert_equal(self._get_traceback(error), '''\ + assert_equal(self._get_traceback(error), f'''\ Traceback (most recent call last): - File "%s", line 5, in <module> - import nonex''' % path) + File "{path}", line 5, in <module> + import nonex''') def test_pythonpath(self): error = self._failing_import('NoneExisting') @@ -340,7 +338,7 @@ def test_pythonpath(self): assert_true(line.startswith(' ')) def test_non_ascii_entry_in_pythonpath(self): - sys.path.append('hyv\xe4') + sys.path.append('hyvä') try: error = self._failing_import('NoneExisting') finally: @@ -380,9 +378,9 @@ def _block(self, error, start, end=None): class TestSplitPathToModule(unittest.TestCase): def _verify(self, file_name, expected_name): - path = abspath(file_name) + path = Path(file_name).absolute() actual = ByPathImporter(None)._split_path_to_module(path) - assert_equal(actual, (dirname(path), expected_name)) + assert_equal(actual, (str(path.parent), expected_name)) def test_normal_file(self): self._verify('hello.py', 'hello') @@ -399,7 +397,7 @@ def setUp(self): self.tearDown() def tearDown(self): - if exists(TESTDIR): + if TESTDIR.exists(): shutil.rmtree(TESTDIR) def test_when_importing_by_name(self): @@ -465,17 +463,18 @@ def __init__(self, arg: int): assert_equal(lib.arg, 42) assert_raises_with_msg( DataError, - "Importing xxx '%s' failed: " - "Argument 'arg' got value 'invalid' that cannot be converted to integer." % path, + f"Importing xxx '{path}' failed: " + f"Argument 'arg' got value 'invalid' that cannot be converted to integer.", Importer('XXX').import_class_or_module, path, ['invalid'] ) def test_modules_do_not_take_arguments(self): path = create_temp_file('no_args_allowed.py') - assert_raises_with_msg(DataError, - "Importing '%s' failed: Modules do not take arguments." % path, - Importer().import_class_or_module_by_path, - path, ['invalid']) + assert_raises_with_msg( + DataError, + f"Importing '{path}' failed: Modules do not take arguments.", + Importer().import_class_or_module_by_path, path, ['invalid'] + ) if __name__ == '__main__': diff --git a/utest/utils/test_markuputils.py b/utest/utils/test_markuputils.py index 13298b26fed..62101860e65 100644 --- a/utest/utils/test_markuputils.py +++ b/utest/utils/test_markuputils.py @@ -848,7 +848,7 @@ def test_newlines_and_tabs(self): assert_equal(attribute_escape(inp), exp) def test_illegal_chars_in_xml(self): - for c in u'\x00\x08\x0B\x0C\x0E\x1F\uFFFE\uFFFF': + for c in '\x00\x08\x0B\x0C\x0E\x1F\uFFFE\uFFFF': assert_equal(attribute_escape(c), '') diff --git a/utest/utils/test_misc.py b/utest/utils/test_misc.py index c6cfb2525d1..1de91adc7c1 100644 --- a/utest/utils/test_misc.py +++ b/utest/utils/test_misc.py @@ -18,12 +18,11 @@ def test_empty(self): def test_one_or_more(self): for seq, expected in [(['One'], "'One'"), (['1', '2'], "'1' and '2'"), - (['a', 'b', 'c', 'd'], "'a', 'b', 'c' and 'd'"), - ([u'Unicode', u'ASCII'], "'Unicode' and 'ASCII'")]: + (['a', 'b', 'c', 'd'], "'a', 'b', 'c' and 'd'")]: self._verify(seq, expected) def test_non_ascii_unicode(self): - self._verify([u'hyv\xe4'], u"'hyv\xe4'") + self._verify(['hyvä', 'äiti', '🏆'], "'hyvä', 'äiti' and '🏆'") def test_ascii_bytes(self): self._verify([b'ascii'], "'ascii'") diff --git a/utest/utils/test_normalizing.py b/utest/utils/test_normalizing.py index 55c6d0d0297..33d02f09101 100644 --- a/utest/utils/test_normalizing.py +++ b/utest/utils/test_normalizing.py @@ -28,9 +28,9 @@ def test_caseless(self): self._verify('Fo o BaR', 'foobar', caseless=True) def test_caseless_non_ascii(self): - self._verify('\xc4iti', '\xc4iti', caseless=False) - for mother in ['\xc4ITI', '\xc4iTi', '\xe4iti', '\xe4iTi']: - self._verify(mother, '\xe4iti', caseless=True) + self._verify('Äiti', 'Äiti', caseless=False) + for mother in ['ÄITI', 'ÄiTi', 'äiti', 'äiTi']: + self._verify(mother, 'äiti', caseless=True) def test_spaceless(self): self._verify('Fo o BaR', 'fo o bar', spaceless=False) @@ -119,13 +119,13 @@ def test_caseless_and_spaceless(self): assert_true(key not in nd2) def test_caseless_with_non_ascii(self): - nd1 = NormalizedDict({'\xe4': 1}) - assert_equal(nd1['\xe4'], 1) - assert_equal(nd1['\xc4'], 1) - assert_true('\xc4' in nd1) - nd2 = NormalizedDict({'\xe4': 1}, caseless=False) - assert_equal(nd2['\xe4'], 1) - assert_true('\xc4' not in nd2) + nd1 = NormalizedDict({'ä': 1}) + assert_equal(nd1['ä'], 1) + assert_equal(nd1['Ä'], 1) + assert_true('Ä' in nd1) + nd2 = NormalizedDict({'ä': 1}, caseless=False) + assert_equal(nd2['ä'], 1) + assert_true('Ä' not in nd2) def test_contains(self): nd = NormalizedDict({'Foo': 'bar'}) @@ -206,8 +206,8 @@ def test_repr(self): assert_equal(repr(type('Extend', (NormalizedDict,), {})()), 'Extend()') def test_unicode(self): - nd = NormalizedDict({'a': '\xe4', '\xe4': 'a'}) - assert_equal(str(nd), "{'a': '\xe4', '\xe4': 'a'}") + nd = NormalizedDict({'a': 'ä', 'ä': 'a'}) + assert_equal(str(nd), "{'a': 'ä', 'ä': 'a'}") def test_update(self): nd = NormalizedDict({'a': 1, 'b': 1, 'c': 1}) diff --git a/utest/utils/test_robotenv.py b/utest/utils/test_robotenv.py index bb3ac91baf6..aebbc6b7b67 100644 --- a/utest/utils/test_robotenv.py +++ b/utest/utils/test_robotenv.py @@ -7,8 +7,8 @@ TEST_VAR = 'TeST_EnV_vAR' TEST_VAL = 'original value' -NON_ASCII_VAR = u'\xe4iti' -NON_ASCII_VAL = u'is\xe4' +NON_ASCII_VAR = 'äiti' +NON_ASCII_VAL = 'isä' class TestRobotEnv(unittest.TestCase): diff --git a/utest/utils/test_robotpath.py b/utest/utils/test_robotpath.py index 7996c873640..df69a03f1f1 100644 --- a/utest/utils/test_robotpath.py +++ b/utest/utils/test_robotpath.py @@ -22,7 +22,7 @@ def test_abspath(self): def test_abspath_when_cwd_is_non_ascii(self): orig = abspath('.') - nonasc = u'\xe4' + nonasc = 'ä' os.mkdir(nonasc) os.chdir(nonasc) try: @@ -85,7 +85,7 @@ def _windows_inputs(self): ('C:\\xxx\\..\\yyy\\..\\temp\\.', 'C:\\temp'), ('c:\\Non\\Existing\\..', 'c:\\Non')] for x in 'ABCDEFGHIJKLMNOPQRSTUVXYZ': - base = '%s:\\' % x + base = f'{x}:\\' inputs.append((base, base)) inputs.append((base.lower(), base.lower())) inputs.append((base[:2], base)) @@ -106,8 +106,8 @@ def _generic_inputs(self): ('../..', '../..'), ('foo', 'foo'), ('foo/bar', 'foo/bar'), - (u'\xe4', u'\xe4'), - (u'\xe4/\xf6', u'\xe4/\xf6'), + ('ä', 'ä'), + ('ä/ö', 'ä/ö'), ('./foo', 'foo'), ('foo/.', 'foo'), ('foo/..', '.'), @@ -120,12 +120,11 @@ class TestGetLinkPath(unittest.TestCase): def test_basics(self): for base, target, expected in self._get_basic_inputs(): assert_equal(get_link_path(target, base).replace('R:', 'r:'), - expected, '%s -> %s' % (target, base)) + expected, f'{target} -> {base}') def test_base_is_existing_file(self): assert_equal(get_link_path(os.path.dirname(__file__), __file__), '.') - assert_equal(get_link_path(__file__, __file__), - self._expected_basename(__file__)) + assert_equal(get_link_path(__file__, __file__), os.path.basename(__file__)) def test_non_existing_paths(self): assert_equal(get_link_path('/nonex/target', '/nonex/base'), '../target') @@ -134,12 +133,12 @@ def test_non_existing_paths(self): os.path.relpath('/nonex', os.path.dirname(__file__)).replace(os.sep, '/')) def test_non_ascii_paths(self): - assert_equal(get_link_path(u'\xe4\xf6.txt', ''), '%C3%A4%C3%B6.txt') - assert_equal(get_link_path(u'\xe4/\xf6.txt', u'\xe4'), '%C3%B6.txt') + assert_equal(get_link_path('äö.txt', ''), '%C3%A4%C3%B6.txt') + assert_equal(get_link_path('ä/ö.txt', 'ä'), '%C3%B6.txt') def _get_basic_inputs(self): directory = os.path.dirname(__file__) - inputs = [(directory, __file__, self._expected_basename(__file__)), + inputs = [(directory, __file__, os.path.basename(__file__)), (directory, directory, '.'), (directory, directory + '/', '.'), (directory, directory + '//', '.'), @@ -154,9 +153,6 @@ def _get_basic_inputs(self): self._windows_inputs()) return inputs + platform_inputs - def _expected_basename(self, path): - return os.path.basename(path).replace('$py.class', '%24py.class') - def _posix_inputs(self): return [('/tmp/', '/tmp/bar.txt', 'bar.txt'), ('/tmp', '/tmp/x/bar.txt', 'x/bar.txt'), diff --git a/utest/utils/test_robottypes.py b/utest/utils/test_robottypes.py index 2c19d7afa11..372bf1c3ba3 100644 --- a/utest/utils/test_robottypes.py +++ b/utest/utils/test_robottypes.py @@ -106,7 +106,7 @@ def test_dict_likes(self): assert_equal(is_dict_like(thing), True, thing) def test_others(self): - for thing in ['', u'', 1, None, True, object(), [], (), set()]: + for thing in ['', b'', 1, None, True, object(), [], (), set()]: assert_equal(is_dict_like(thing), False, thing) @@ -114,7 +114,6 @@ class TestTypeName(unittest.TestCase): def test_base_types(self): for item, exp in [('x', 'string'), - (u'x', 'string'), (b'x', 'bytes'), (bytearray(), 'bytearray'), (1, 'integer'), diff --git a/utest/utils/test_text.py b/utest/utils/test_text.py index 39c25ed4f32..68c26f4fc86 100644 --- a/utest/utils/test_text.py +++ b/utest/utils/test_text.py @@ -145,28 +145,31 @@ def test_boundary(self): class TestConsoleWidth(unittest.TestCase): - len16_asian = u'\u6c49\u5b57\u5e94\u8be5\u6b63\u786e\u5bf9\u9f50' - ten_normal = u'1234567890' - mixed_26 = u'012345\u6c49\u5b57\u5e94\u8be5\u6b63\u786e\u5bf9\u9f567890' - nfd = u'A\u030Abo' + ascii_10 = '1234567890' + asian_16 = '汉字应该正确对齐' + combining_3 = 'A\u030Abo' # Åbo in NFD + mixed_27 = '012345汉字应该正确对齖7890A\u030A' - def test_console_width(self): - assert_equal(get_console_length(self.ten_normal), 10) + def test_ascii(self): + assert_equal(get_console_length(self.ascii_10), 10) - def test_east_asian_width(self): - assert_equal(get_console_length(self.len16_asian), 16) + def test_asian(self): + assert_equal(get_console_length(self.asian_16), 16) - def test_combining_width(self): - assert_equal(get_console_length(self.nfd), 3) + def test_combining(self): + assert_equal(get_console_length(self.combining_3), 3) - def test_cut_right(self): - assert_equal(pad_console_length(self.ten_normal, 5), '12...') - assert_equal(pad_console_length(self.ten_normal, 15), self.ten_normal+' '*5) - assert_equal(pad_console_length(self.ten_normal, 10), self.ten_normal) + def test_mixed(self): + assert_equal(get_console_length(self.mixed_27), 27) - def test_cut_east_asian(self): - assert_equal(pad_console_length(self.len16_asian, 10), u'\u6c49\u5b57\u5e94... ') - assert_equal(pad_console_length(self.mixed_26, 11), u'012345\u6c49...') + def test_pad_ascii(self): + assert_equal(pad_console_length(self.ascii_10, 5), '12...') + assert_equal(pad_console_length(self.ascii_10, 15), self.ascii_10 + ' ' * 5) + assert_equal(pad_console_length(self.ascii_10, 10), self.ascii_10) + + def test_pad_asian(self): + assert_equal(pad_console_length(self.asian_16, 10), '汉字应... ') + assert_equal(pad_console_length(self.mixed_27, 11), '012345汉...') class TestDocSplitter(unittest.TestCase): @@ -242,18 +245,18 @@ def test_windows_path_without_args(self): def test_windows_path_with_args(self): assert_equal(self.method('C:\\name.py:arg1'), ('C:\\name.py', ['arg1'])) assert_equal(self.method('D:\\APPS\\listener:v1:b2:z3'), - ('D:\\APPS\\listener', ['v1', 'b2', 'z3'])) + ('D:\\APPS\\listener', ['v1', 'b2', 'z3'])) assert_equal(self.method('C:/varz.py:arg'), ('C:/varz.py', ['arg'])) assert_equal(self.method('C:\\file.py:arg;with;alternative;separator'), - ('C:\\file.py', ['arg;with;alternative;separator'])) + ('C:\\file.py', ['arg;with;alternative;separator'])) def test_windows_path_with_semicolon_separator(self): assert_equal(self.method('C:\\name.py;arg1'), ('C:\\name.py', ['arg1'])) assert_equal(self.method('D:\\APPS\\listener;v1;b2;z3'), - ('D:\\APPS\\listener', ['v1', 'b2', 'z3'])) + ('D:\\APPS\\listener', ['v1', 'b2', 'z3'])) assert_equal(self.method('C:/varz.py;arg'), ('C:/varz.py', ['arg'])) assert_equal(self.method('C:\\file.py;arg:with:alternative:separator'), - ('C:\\file.py', ['arg:with:alternative:separator'])) + ('C:\\file.py', ['arg:with:alternative:separator'])) def test_existing_paths_are_made_absolute(self): path = 'robot-framework-unit-test-file-12q3405909qasf' @@ -297,11 +300,11 @@ class Class: assert_equal(getdoc(Class), 'My doc.\n\nIn multiple lines.') assert_equal(getdoc(Class), getdoc(Class())) - def test_unicode_doc(self): + def test_non_ascii_doc(self): class Class: def meth(self): - u"""Hyv\xe4 \xe4iti!""" - assert_equal(getdoc(Class.meth), u'Hyv\xe4 \xe4iti!') + """Hyvä äiti!""" + assert_equal(getdoc(Class.meth), 'Hyvä äiti!') assert_equal(getdoc(Class.meth), getdoc(Class().meth)) diff --git a/utest/utils/test_unic.py b/utest/utils/test_unic.py index 25d30ce2cfc..d66a8328267 100644 --- a/utest/utils/test_unic.py +++ b/utest/utils/test_unic.py @@ -9,19 +9,19 @@ class TestSafeStr(unittest.TestCase): def test_unicode_nfc_and_nfd_decomposition_equality(self): import unicodedata - text = 'Hyv\xe4' + text = 'Hyvä' assert_equal(safe_str(unicodedata.normalize('NFC', text)), text) # In Mac filesystem umlaut characters are presented in NFD-format. # This is to check that unic normalizes all strings to NFC assert_equal(safe_str(unicodedata.normalize('NFD', text)), text) def test_object_containing_unicode_repr(self): - assert_equal(safe_str(UnicodeRepr()), 'Hyv\xe4') + assert_equal(safe_str(UnicodeRepr()), 'Hyvä') def test_list_with_objects_containing_unicode_repr(self): objects = [UnicodeRepr(), UnicodeRepr()] result = safe_str(objects) - assert_equal(result, '[Hyv\xe4, Hyv\xe4]') + assert_equal(result, '[Hyvä, Hyvä]') def test_bytes_below_128(self): assert_equal(safe_str('\x00-\x01-\x02-\x7f'), '\x00-\x01-\x02-\x7f') @@ -60,10 +60,10 @@ def test_ascii_unicode(self): self._verify("f'o'o", "\"f'o'o\"") def test_non_ascii_unicode(self): - self._verify('hyv\xe4', "'hyv\xe4'") + self._verify('hyvä', "'hyvä'") def test_unicode_in_nfd(self): - self._verify('hyva\u0308', "'hyv\xe4'") + self._verify('hyva\u0308', "'hyvä'") def test_ascii_bytes(self): self._verify(b'ascii', "b'ascii'") @@ -84,7 +84,7 @@ def test_failing_repr(self): def test_unicode_repr(self): obj = UnicodeRepr() - self._verify(obj, 'Hyv\xe4') + self._verify(obj, 'Hyvä') def test_bytes_repr(self): obj = BytesRepr() @@ -94,8 +94,8 @@ def test_collections(self): self._verify(['foo', b'bar', 3], "['foo', b'bar', 3]") self._verify(['foo', b'b\xe4r', ('x', b'y')], "['foo', b'b\\xe4r', ('x', b'y')]") self._verify({'x': b'\xe4'}, "{'x': b'\\xe4'}") - self._verify(['\xe4'], "['\xe4']") - self._verify({'\xe4'}, "{'\xe4'}") + self._verify(['ä'], "['ä']") + self._verify({'ä'}, "{'ä'}") def test_dotdict(self): self._verify(DotDict({'x': b'\xe4'}), "{'x': b'\\xe4'}") @@ -153,10 +153,10 @@ def __init__(self): try: repr(self) except UnicodeEncodeError as err: - self.error = 'UnicodeEncodeError: %s' % err + self.error = f'UnicodeEncodeError: {err}' def __repr__(self): - return 'Hyv\xe4' + return 'Hyvä' class BytesRepr(UnRepr): @@ -165,7 +165,7 @@ def __init__(self): try: repr(self) except TypeError as err: - self.error = 'TypeError: %s' % err + self.error = f'TypeError: {err}' def __repr__(self): return b'Hyv\xe4' diff --git a/utest/utils/test_xmlwriter.py b/utest/utils/test_xmlwriter.py index af47ab6cd67..11f4f106399 100644 --- a/utest/utils/test_xmlwriter.py +++ b/utest/utils/test_xmlwriter.py @@ -93,7 +93,7 @@ def test_newline_insertion(self): assert_equal(len(lines), 5) def test_none_content(self): - self.writer.element(u'robot-log', None) + self.writer.element('robot-log', None) self._verify_node(None, 'robot-log') def test_none_and_empty_attrs(self): @@ -105,25 +105,25 @@ def test_content_with_invalid_command_char(self): self._verify_node(None, 'robot-log', '[31m[32m[33m[m') def test_content_with_invalid_command_char_unicode(self): - self.writer.element('robot-log', u'\x1b[31m\x1b[32m\x1b[33m\x1b[m') + self.writer.element('robot-log', '\x1b[31m\x1b[32m\x1b[33m\x1b[m') self._verify_node(None, 'robot-log', '[31m[32m[33m[m') def test_content_with_non_ascii(self): self.writer.start('root') - self.writer.element(u'e', u'Circle is 360\xB0') - self.writer.element(u'f', u'Hyv\xE4\xE4 \xFC\xF6t\xE4') + self.writer.element('e', 'Circle is 360°') + self.writer.element('f', 'Hyvää üötä') self.writer.end('root') root = self._get_root() - self._verify_node(root.find('e'), 'e', u'Circle is 360\xB0') - self._verify_node(root.find('f'), 'f', u'Hyv\xE4\xE4 \xFC\xF6t\xE4') + self._verify_node(root.find('e'), 'e', 'Circle is 360°') + self._verify_node(root.find('f'), 'f', 'Hyvää üötä') def test_content_with_entities(self): self.writer.element('I', 'Me, Myself & I > you') self._verify_content('<I>Me, Myself & I > you</I>\n') def test_remove_illegal_chars(self): - assert_equal(self.writer._escape(u'\x1b[31m'), '[31m') - assert_equal(self.writer._escape(u'\x00'), '') + assert_equal(self.writer._escape('\x1b[31m'), '[31m') + assert_equal(self.writer._escape('\x00'), '') def test_dataerror_when_file_is_invalid(self): err = assert_raises(DataError, XmlWriter, os.path.dirname(__file__)) diff --git a/utest/variables/test_search.py b/utest/variables/test_search.py index ef218566c9e..8ed62f85cca 100644 --- a/utest/variables/test_search.py +++ b/utest/variables/test_search.py @@ -52,10 +52,10 @@ def test_uneven_curlys(self): for inp in ['${x', '${x:{}', '${y:{{}}', 'xx${z:{}xx', '{${{}{{}}{{', r'${x\}', r'${x\\\}', r'${x\\\\\\\}']: for identifier in '$@&%': + variable = identifier + inp.split('$')[1] assert_raises_with_msg( DataError, - "Variable '%s%s' was not closed properly." - % (identifier, inp.split('$')[1]), + f"Variable '{variable}' was not closed properly.", search_variable, inp.replace('$', identifier) ) self._test(inp.replace('$', identifier), ignore_errors=True) @@ -108,13 +108,13 @@ def test_internal_vars(self): def test_incomplete_internal_vars(self): for inp in ['${var$', '${var${', '${var${int}']: for identifier in '$@&%': + variable = inp.replace('$', identifier) assert_raises_with_msg( DataError, - "Variable '%s' was not closed properly." - % inp.replace('$', identifier), - search_variable, inp.replace('$', identifier) + f"Variable '{variable}' was not closed properly.", + search_variable, variable ) - self._test(inp.replace('$', identifier), ignore_errors=True) + self._test(variable, ignore_errors=True) self._test('}{${xx:{}}}}}', '${xx:{}}', start=2) def test_item_access(self): @@ -151,7 +151,7 @@ def test_item_access_with_matching_squares(self): def test_unclosed_item(self): for inp in ['${x}[0', '${x}[0][key', r'${x}[0\]']: - msg = "Variable item '%s' was not closed properly." % inp + msg = f"Variable item '{inp}' was not closed properly." assert_raises_with_msg(DataError, msg, search_variable, inp) self._test(inp, ignore_errors=True) self._test('[${var}[i]][', '${var}', start=1, items='i') @@ -224,21 +224,21 @@ def _test(self, inp, variable=None, start=0, items=None, end = start + len(variable) is_var = inp == variable if items: - items_str = ''.join('[%s]' % i for i in items) + items_str = ''.join(f'[{i}]' for i in items) end += len(items_str) - is_var = inp == '%s%s' % (variable, items_str) + is_var = inp == f'{variable}{items_str}' is_list_var = is_var and inp[0] == '@' is_dict_var = is_var and inp[0] == '&' is_scal_var = is_var and inp[0] == '$' match = search_variable(inp, identifiers, ignore_errors) - assert_equal(match.base, base, '%r base' % inp) - assert_equal(match.start, start, '%r start' % inp) - assert_equal(match.end, end, '%r end' % inp) + assert_equal(match.base, base, f'{inp!r} base') + assert_equal(match.start, start, f'{inp!r} start') + assert_equal(match.end, end, f'{inp!r} end') assert_equal(match.before, inp[:start] if start != -1 else inp) assert_equal(match.match, inp[start:end] if end != -1 else None) assert_equal(match.after, inp[end:] if end != -1 else None) - assert_equal(match.identifier, identifier, '%r identifier' % inp) - assert_equal(match.items, items, '%r item' % inp) + assert_equal(match.identifier, identifier, f'{inp!r} identifier') + assert_equal(match.items, items, f'{inp!r} item') assert_equal(match.is_variable(), is_var) assert_equal(match.is_scalar_variable(), is_scal_var) assert_equal(match.is_list_variable(), is_list_var) @@ -308,9 +308,9 @@ def test_no_backslash(self): self._test(inp) def test_no_variable(self): - for inp in ['\\', r'\n', r'\d+', r'\u2603', r'\$', r'\@', r'\&']: + for inp in ['\\', r'\n', r'\d+', '☃', r'\$', r'\@', r'\&']: self._test(inp) - self._test('Hello, %s!' % inp) + self._test(f'Hello, {inp}!') def test_unescape_variable(self): for i in '$@&%': From 241bac502e91a9f60cf70a326c40ecd11a2f30ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Mon, 11 Sep 2023 21:54:16 +0300 Subject: [PATCH 0690/1592] Requite `--suite parent.suite` to match the full suite name Fixes #4720. --- atest/robot/core/filter_by_names.robot | 11 ++++---- atest/robot/rebot/filter_by_names.robot | 9 ++++--- .../ConfiguringExecution.rst | 21 ++++++++------- src/robot/model/__init__.py | 1 - src/robot/model/filter.py | 14 +++++----- src/robot/model/namepatterns.py | 26 ++----------------- utest/model/test_filter.py | 8 +++--- 7 files changed, 36 insertions(+), 54 deletions(-) diff --git a/atest/robot/core/filter_by_names.robot b/atest/robot/core/filter_by_names.robot index b90cb13816f..2569ae45af4 100644 --- a/atest/robot/core/filter_by_names.robot +++ b/atest/robot/core/filter_by_names.robot @@ -78,10 +78,11 @@ Parent suite init files are processed Should Contain Tests ${SUITE} Test From Sub Suite 4 Should Not Contain Tests ${SUITE} SubSuite3 First SubSuite3 Second ---suite with end of long name - Run Suites --suite Subsuites.Sub? - Should Contain Suites ${SUITE} Subsuites - Should Contain Tests ${SUITE} SubSuite1 First SubSuite2 First +--suite matching end of long name is not enough anymore + [Documentation] This was supported until RF 7.0. + Run Failing Test + ... Suite 'Suites' contains no tests in suite 'Subsuites.Sub?'. + ... --suite Subsuites.Sub? ${SUITE DIR} --suite with long name when executing multiple suites Run Suites -s "Suite With Prefix & Subsuites.Subsuites.Sub1" misc/suites/01__suite_with_prefix misc/suites/subsuites @@ -129,7 +130,7 @@ Parent suite init files are processed Should Contain Tests ${SUITE} SubSuite1 First --suite with long name and other filters - Run Suites --suite suites.fourth --suite tsuite1 -s Subsuites.Sub1 --test *first* --exclude none + Run Suites --suite suites.fourth --suite tsuite1 -s *.Subsuites.Sub1 --test *first* --exclude none Should Contain Suites ${SUITE} Fourth Subsuites Tsuite1 Should Contain Tests ${SUITE} Suite4 First Suite1 First SubSuite1 First diff --git a/atest/robot/rebot/filter_by_names.robot b/atest/robot/rebot/filter_by_names.robot index 2af7b31b481..f03d9cee2c4 100644 --- a/atest/robot/rebot/filter_by_names.robot +++ b/atest/robot/rebot/filter_by_names.robot @@ -51,10 +51,11 @@ ${INPUT FILE} %{TEMPDIR}${/}robot-test-file.xml Should Contain Suites ${SUITE} Suites Should Contain Suites ${SUITE.suites[0].suites[0]} Sub1 Sub2 ---suite with end of long name - Run And Check Suites --suite suites.subsuites Subsuites - Should Contain Suites ${SUITE} Suites - Should Contain Suites ${SUITE.suites[0].suites[0]} Sub1 Sub2 +--suite matching end of long name is not enough anymore + [Documentation] This was supported until RF 7.0. + Failing Rebot + ... Suite 'Root' contains no tests in suite 'suites.subsuites'. + ... --suite suites.subsuites ${INPUT FILE} --suite not matching Failing Rebot diff --git a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst index 6bbc93175af..5ae7fd3dd33 100644 --- a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst @@ -175,7 +175,7 @@ with a suite name:: Notice that when the given name includes a suite name, it must match the whole suite name starting from the root suite. Using a wildcard as in the last example -above allows matching suites anywhere. +above allows matching tests with a parent suite anywhere. Using the :option:`--test` option is convenient when only a few tests needs to be selected. A common use case is running just the test that is currently @@ -198,13 +198,17 @@ name:: --suite Example # Match only suites with name 'Example'. --suite example* # Match suites starting with 'example'. --suite first --suite second # Match suites with name 'first' or 'second'. - --suite parent.child # Match suite 'child' in suite 'parent'. + --suite root.child # Match suite 'child' in root suite 'root'. + --suite *.parent.child # Match suite 'child' with parent 'parent' anywhere. -Unlike with :option:`--test`, the name does not need to match the whole -suite name, starting from the root suite, when the name contains a parent -suite name. This behavior `will be changed`__ in the future and should not be relied -upon. It is recommended to use the full name like `--suite root.parent.child` -or `--suite *.parent.child`. +If the name contains a parent suite name, it must match the whole suite name +the same way as with :option:`--test`. Using a wildcard as in the last example +above allows matching suites with a parent suite anywhere. + +.. note:: Prior to Robot Framework 7.0, :option:`--suite` with a parent suite + did not need to match the whole suite name. For example, `parent.child` + would match suite `child` with parent `parent` anywhere. The name must + be prefixed with a wildcard if this behavior is desired nowadays. If both :option:`--suite` and :option:`--test` options are used, only the specified tests in specified suites are selected:: @@ -231,11 +235,10 @@ on higher level are not executed:: Prior to Robot Framework 6.1, files not matching the :option:`--suite` option were not parsed at all for performance reasons. This optimization was not possible anymore after suites got a new :setting:`Name` setting that can override -the default suite name got from the file or directory name. New +the default suite name that is got from the file or directory name. New :option:`--parseinclude` option has been added to `explicitly select which files are parsed`__ if this kind of parsing optimization is needed. -__ https://github.com/robotframework/robotframework/issues/4720 __ https://github.com/robotframework/robotframework/issues/4721 __ `Selecting files by name or path`_ diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index 181198899c8..168bd8ecfa7 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -34,7 +34,6 @@ from .message import Message, MessageLevel, Messages from .modelobject import DataDict, ModelObject from .modifier import ModelModifier -from .namepatterns import SuiteNamePatterns, TestNamePatterns from .statistics import Statistics from .tags import Tags, TagPattern, TagPatterns from .testcase import TestCase, TestCases diff --git a/src/robot/model/filter.py b/src/robot/model/filter.py index f4b749ce425..f447be77c47 100644 --- a/src/robot/model/filter.py +++ b/src/robot/model/filter.py @@ -18,7 +18,7 @@ from robot.utils import setter from .tags import TagPatterns -from .namepatterns import SuiteNamePatterns, TestNamePatterns +from .namepatterns import NamePatterns from .visitor import SuiteVisitor if TYPE_CHECKING: @@ -46,8 +46,8 @@ def visit_keyword(self, keyword: 'Keyword'): class Filter(EmptySuiteRemover): def __init__(self, - include_suites: 'SuiteNamePatterns|Sequence[str]|None' = None, - include_tests: 'TestNamePatterns|Sequence[str]|None' = None, + include_suites: 'NamePatterns|Sequence[str]|None' = None, + include_tests: 'NamePatterns|Sequence[str]|None' = None, include_tags: 'TagPatterns|Sequence[str]|None' = None, exclude_tags: 'TagPatterns|Sequence[str]|None' = None): super().__init__() @@ -57,12 +57,12 @@ def __init__(self, self.exclude_tags = exclude_tags @setter - def include_suites(self, suites) -> 'SuiteNamePatterns|None': - return self._patterns_or_none(suites, SuiteNamePatterns) + def include_suites(self, suites) -> 'NamePatterns|None': + return self._patterns_or_none(suites, NamePatterns) @setter - def include_tests(self, tests) -> 'TestNamePatterns|None': - return self._patterns_or_none(tests, TestNamePatterns) + def include_tests(self, tests) -> 'NamePatterns|None': + return self._patterns_or_none(tests, NamePatterns) @setter def include_tags(self, tags) -> 'TagPatterns|None': diff --git a/src/robot/model/namepatterns.py b/src/robot/model/namepatterns.py index b00764b934c..2c1cf297d79 100644 --- a/src/robot/model/namepatterns.py +++ b/src/robot/model/namepatterns.py @@ -24,14 +24,8 @@ def __init__(self, patterns: Sequence[str] = (), ignore: Sequence[str] = '_'): self.matcher = MultiMatcher(patterns, ignore) def match(self, name: str, longname: 'str|None' = None) -> bool: - return bool(self._match(name) or - longname and self._match_longname(longname)) - - def _match(self, name: str) -> bool: - return self.matcher.match(name) - - def _match_longname(self, name: str) -> bool: - raise NotImplementedError + match = self.matcher.match + return bool(match(name) or longname and match(longname)) def __bool__(self) -> bool: return bool(self.matcher) @@ -39,19 +33,3 @@ def __bool__(self) -> bool: def __iter__(self) -> Iterator[str]: for matcher in self.matcher: yield matcher.pattern - - -class SuiteNamePatterns(NamePatterns): - - def _match_longname(self, name): - while '.' in name: - if self._match(name): - return True - name = name.split('.', 1)[1] - return False - - -class TestNamePatterns(NamePatterns): - - def _match_longname(self, name): - return self._match(name) diff --git a/utest/model/test_filter.py b/utest/model/test_filter.py index d85d573ca88..b55b37de50c 100644 --- a/utest/model/test_filter.py +++ b/utest/model/test_filter.py @@ -1,8 +1,8 @@ import unittest -from robot.utils.asserts import assert_equal from robot.model import TestSuite from robot.model.filter import Filter +from robot.utils.asserts import assert_equal class FilterBaseTest(unittest.TestCase): @@ -146,11 +146,11 @@ def test_reuse_filter(self): self._test(filter, [], ['t1']) self._test(filter, [], ['t1']) - def test_parent_name(self): + def test_longname(self): self._test(Filter(include_suites=['s1.s21.s31']), ['t1', 't2', 't3'], []) - self._test(Filter(include_suites=['s2?.s31']), ['t1', 't2', 't3'], []) + self._test(Filter(include_suites=['*.s2?.s31']), ['t1', 't2', 't3'], []) self._test(Filter(include_suites=['*.s22']), [], ['t1']) - self._test(Filter(include_suites=['xxx.s22']), [], []) + self._test(Filter(include_suites=['nonex.s22']), [], []) def test_normalization(self): self._test(Filter(include_suites=['_S 2 2_', 'xxx']), [], ['t1']) From bd8b4889c0adf446bb5451b8b72ca61eb2a82895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Sep 2023 17:47:22 +0300 Subject: [PATCH 0691/1592] Reduce execution time of tests needing elapsed time > 0. 0.001s ought to be enough. Hopefully this doesn't make any test flakey. --- atest/testdata/misc/normal.robot | 2 +- .../suites/01__suite_with_prefix/01__tests_with_prefix.robot | 2 +- atest/testdata/misc/suites/fourth.robot | 2 +- atest/testdata/misc/suites/subsuites/sub1.robot | 2 +- atest/testdata/misc/suites/subsuites/sub2.robot | 2 +- atest/testdata/misc/suites/subsuites2/sub.suite.4.robot | 2 +- atest/testdata/misc/suites/subsuites2/subsuite3.robot | 2 +- .../tests_with_double_underscore__.robot | 2 +- atest/testdata/misc/suites/tsuite1.robot | 2 +- atest/testdata/misc/suites/tsuite2.robot | 2 +- atest/testdata/misc/suites/tsuite3.robot | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/atest/testdata/misc/normal.robot b/atest/testdata/misc/normal.robot index 25be8588d8b..5fd3127296c 100644 --- a/atest/testdata/misc/normal.robot +++ b/atest/testdata/misc/normal.robot @@ -5,7 +5,7 @@ Default Tags d1 d_2 Metadata Something My Value *** Variables *** -${DELAY} 0.01 # Make sure elapsed time > 0 +${DELAY} 0.001 # Make sure elapsed time > 0 *** Test Cases *** First One diff --git a/atest/testdata/misc/suites/01__suite_with_prefix/01__tests_with_prefix.robot b/atest/testdata/misc/suites/01__suite_with_prefix/01__tests_with_prefix.robot index 90f46729c69..bbe1ba6530d 100644 --- a/atest/testdata/misc/suites/01__suite_with_prefix/01__tests_with_prefix.robot +++ b/atest/testdata/misc/suites/01__suite_with_prefix/01__tests_with_prefix.robot @@ -1,4 +1,4 @@ *** Test Cases *** Test With Prefix Log Test With Prefix - Sleep 0.01 Make sure elapsed time > 0 + Sleep 0.001 Make sure elapsed time > 0 diff --git a/atest/testdata/misc/suites/fourth.robot b/atest/testdata/misc/suites/fourth.robot index ae33f47ebb1..6ded655c73d 100644 --- a/atest/testdata/misc/suites/fourth.robot +++ b/atest/testdata/misc/suites/fourth.robot @@ -14,6 +14,6 @@ Suite4 First [Documentation] FAIL Expected [Tags] t1 Log Suite4_First - Sleep 0.01 Make sure elapsed time > 0 + Sleep 0.001 Make sure elapsed time > 0 Fail Expected [Teardown] Log Huhuu diff --git a/atest/testdata/misc/suites/subsuites/sub1.robot b/atest/testdata/misc/suites/subsuites/sub1.robot index 986c8218982..5f4cafd91d1 100644 --- a/atest/testdata/misc/suites/subsuites/sub1.robot +++ b/atest/testdata/misc/suites/subsuites/sub1.robot @@ -7,7 +7,7 @@ Suite Setup ${SETUP} Suite Teardown ${TEARDOWN} *** Variables *** -${SLEEP} 0.1 +${SLEEP} 0.001 ${FAIL} NO ${MESSAGE} Original message ${LEVEL} INFO diff --git a/atest/testdata/misc/suites/subsuites/sub2.robot b/atest/testdata/misc/suites/subsuites/sub2.robot index 048d1dd777d..039226e152d 100644 --- a/atest/testdata/misc/suites/subsuites/sub2.robot +++ b/atest/testdata/misc/suites/subsuites/sub2.robot @@ -5,7 +5,7 @@ Default Tags d1 d2 Metadata Something My Value *** Variables *** -${SLEEP} 0.1 +${SLEEP} 0.001 *** Test Cases *** SubSuite2 First diff --git a/atest/testdata/misc/suites/subsuites2/sub.suite.4.robot b/atest/testdata/misc/suites/subsuites2/sub.suite.4.robot index 3fc92df43cd..bafe368ec35 100644 --- a/atest/testdata/misc/suites/subsuites2/sub.suite.4.robot +++ b/atest/testdata/misc/suites/subsuites2/sub.suite.4.robot @@ -1,3 +1,3 @@ *** Test Cases *** Test From Sub Suite 4 - Sleep 0.01 Make sure elapsed time > 0 + Sleep 0.001 Make sure elapsed time > 0 diff --git a/atest/testdata/misc/suites/subsuites2/subsuite3.robot b/atest/testdata/misc/suites/subsuites2/subsuite3.robot index 9049df50dfa..9486459baf7 100644 --- a/atest/testdata/misc/suites/subsuites2/subsuite3.robot +++ b/atest/testdata/misc/suites/subsuites2/subsuite3.robot @@ -9,7 +9,7 @@ Metadata Something My Value SubSuite3 First [Tags] t1 sub3 Log SubSuite3_First - Sleep 0.01 Make sure elapsed time > 0 + Sleep 0.001 Make sure elapsed time > 0 SubSuite3 Second [Tags] t2 sub3 diff --git a/atest/testdata/misc/suites/suite_with_double_underscore__/tests_with_double_underscore__.robot b/atest/testdata/misc/suites/suite_with_double_underscore__/tests_with_double_underscore__.robot index d6e0f2cf042..22e32605626 100644 --- a/atest/testdata/misc/suites/suite_with_double_underscore__/tests_with_double_underscore__.robot +++ b/atest/testdata/misc/suites/suite_with_double_underscore__/tests_with_double_underscore__.robot @@ -1,4 +1,4 @@ *** Test Cases *** Test With Double Underscore Log Test With Double Underscore - Sleep 0.01 Make sure elapsed time > 0 + Sleep 0.001 Make sure elapsed time > 0 diff --git a/atest/testdata/misc/suites/tsuite1.robot b/atest/testdata/misc/suites/tsuite1.robot index 7c308de6627..9cf3441c1de 100644 --- a/atest/testdata/misc/suites/tsuite1.robot +++ b/atest/testdata/misc/suites/tsuite1.robot @@ -7,7 +7,7 @@ Metadata Something My Value Suite1 First [Tags] t1 Log Suite1_First - Sleep 0.01 Make sure elapsed time > 0 + Sleep 0.001 Make sure elapsed time > 0 Suite1 Second [Tags] t2 diff --git a/atest/testdata/misc/suites/tsuite2.robot b/atest/testdata/misc/suites/tsuite2.robot index 304018632c1..86d7e5a52c0 100644 --- a/atest/testdata/misc/suites/tsuite2.robot +++ b/atest/testdata/misc/suites/tsuite2.robot @@ -7,4 +7,4 @@ Metadata Something My Value Suite2 First [Tags] t1 Log Suite2_First - Sleep 0.01 Make sure elapsed time > 0 + Sleep 0.001 Make sure elapsed time > 0 diff --git a/atest/testdata/misc/suites/tsuite3.robot b/atest/testdata/misc/suites/tsuite3.robot index e88b4470bc5..d54669a4561 100644 --- a/atest/testdata/misc/suites/tsuite3.robot +++ b/atest/testdata/misc/suites/tsuite3.robot @@ -8,4 +8,4 @@ Metadata Something My Value Suite3 First [Tags] t1 Log Suite3_First - Sleep 0.01 Make sure elapsed time > 0 + Sleep 0.001 Make sure elapsed time > 0 From 57a74bc1769ae855d20c2724211af32c04c0607c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Sep 2023 17:49:18 +0300 Subject: [PATCH 0692/1592] Make --test and --cumulative. Fixes #4721. Also enhance tests and documentation related to using --suite together with --test, --include and --exclude. --- atest/robot/core/filter_by_names.robot | 30 ++++++++------ atest/robot/rebot/filter_by_names.robot | 33 ++++++++++----- atest/robot/tags/include_and_exclude.robot | 4 ++ .../tags/include_and_exclude_with_rebot.robot | 7 +++- .../ConfiguringExecution.rst | 29 ++++++++++---- src/robot/model/configurer.py | 26 +++++++----- src/robot/model/filter.py | 40 +++++++++++-------- src/robot/model/tags.py | 2 +- utest/result/test_configurer.py | 13 +++--- 9 files changed, 120 insertions(+), 64 deletions(-) diff --git a/atest/robot/core/filter_by_names.robot b/atest/robot/core/filter_by_names.robot index 2569ae45af4..a85d64e3876 100644 --- a/atest/robot/core/filter_by_names.robot +++ b/atest/robot/core/filter_by_names.robot @@ -17,6 +17,12 @@ ${SUITE DIR} misc/suites Run And Check Tests --test *one --test Fi?st First Second One Third One Run And Check Tests --test [Great]Lob[sterB]estCase[!3-9] GlobTestCase1 GlobTestCase2 +--test is cumulative with --include + Run And Check Tests --test fifth --include t1 First Fifth + +--exclude wins ovet --test + Run And Check Tests --test fi* --exclude t1 Fifth + --test not matching Run Failing Test ... Suite 'Many Tests' contains no tests matching name 'notexists'. @@ -109,10 +115,10 @@ Parent suite init files are processed ... --suite xxx -N Custom ${SUITE DIR} ${SUITE FILE} --suite and --test together - [Documentation] Testing that only tests matching --test which are under suite matching --suite are run. - Run Suites --suite subsuites --suite tsuite3 --test SubSuite1First - Should Contain Suites ${SUITE} Subsuites - Should Contain Tests ${SUITE} SubSuite1 First + [Documentation] Validate that only tests matching --test under suites matching --suite are selected. + Run Suites --suite suites.subsuites.sub2 --suite tsuite3 --test *First + Should Contain Suites ${SUITE} Subsuites Tsuite3 + Should Contain Tests ${SUITE} SubSuite2 First Suite3 First --suite and --test together not matching Run Failing Test @@ -120,14 +126,14 @@ Parent suite init files are processed ... --suite subsuites -s nomatch --test Suite1* -t nomatch ${SUITE DIR} --suite with --include/--exclude - Run Suites --suite tsuite? --include t? --exclude t2 - Should Contain Suites ${SUITE} Tsuite1 Tsuite2 Tsuite3 - Should Contain Tests ${SUITE} Suite1 First Suite2 First Suite3 First - ---suite, --test, --inculde and --exclude - Run Suites --suite sub* --test *first -s nosuite -t notest --include t1 --exclude sub3 - Should Contain Suites ${SUITE} Subsuites - Should Contain Tests ${SUITE} SubSuite1 First + Run Suites --suite tsuite[13] --include t? --exclude t2 + Should Contain Suites ${SUITE} Tsuite1 Tsuite3 + Should Contain Tests ${SUITE} Suite1 First Suite3 First + +--suite, --test, --include and --exclude + Run Suites --suite sub* --suite "custom name *" --test *first -s nomatch -t nomatch --include sub3 --exclude t1 + Should Contain Suites ${SUITE} Custom name for 📂 'subsuites2' Subsuites + Should Contain Tests ${SUITE} SubSuite2 First SubSuite3 Second --suite with long name and other filters Run Suites --suite suites.fourth --suite tsuite1 -s *.Subsuites.Sub1 --test *first* --exclude none diff --git a/atest/robot/rebot/filter_by_names.robot b/atest/robot/rebot/filter_by_names.robot index f03d9cee2c4..2293f80bbec 100644 --- a/atest/robot/rebot/filter_by_names.robot +++ b/atest/robot/rebot/filter_by_names.robot @@ -22,6 +22,12 @@ ${INPUT FILE} %{TEMPDIR}${/}robot-test-file.xml Run And Check Tests --test *one --test Fi?st First Second One Third One Run And Check Tests --test [Great]Lob[sterB]estCase[!3-9] GlobTestCase1 GlobTestCase2 +--test is cumulative with --include + Run And Check Tests --test fifth --include t2 First Fifth Suite1 Second SubSuite3 Second + +--exclude wins ovet --test + Run And Check Tests --test fi* --exclude t1 Fifth + --test not matching Failing Rebot ... Suite 'Root' contains no tests matching name 'nonex'. @@ -71,13 +77,30 @@ ${INPUT FILE} %{TEMPDIR}${/}robot-test-file.xml ... --name CustomName --suite nonex ${INPUT FILE} ${INPUT FILE} --suite and --test together - Run And Check Suites and Tests --suite tsuite1 --suite tsuite3 --test *1first --test nomatch Tsuite1 Suite1 First + [Documentation] Validate that only tests matching --test under suites matching --suite are selected. + Run Suites --suite root.*.tsuite2 --suite manytests --test *first* --test nomatch --log log + Should Contain Suites ${SUITE} Many Tests Suites + Should Contain Tests ${SUITE.suites[0]} First + Should Contain Tests ${SUITE.suites[1]} Suite2 First + Check Stats --suite and --test together not matching Failing Rebot ... Suite 'Root' contains no tests matching name 'first', 'nonex' or '*one' in suites 'nonex' or 'suites'. ... --suite nonex --suite suites --test first --test nonex --test *one ${INPUT FILE} +--suite with --include/--exclude + Run Suites --suite tsuite[13] --include t? --exclude t2 + Should Contain Suites ${SUITE} Suites + Should Contain Suites ${SUITE.suites[0]} Tsuite1 Tsuite3 + Should Contain Tests ${SUITE} Suite1 First Suite3 First + +--suite, --test, --include and --exclude + Run Suites --suite sub* --suite "custom name *" --test *first -s nomatch -t nomatch --include sub3 --exclude t1 + Should Contain Suites ${SUITE} Suites + Should Contain Suites ${SUITE.suites[0]} Custom name for 📂 'subsuites2' Subsuites + Should Contain Tests ${SUITE} SubSuite2 First SubSuite3 Second + Elapsed Time [Documentation] Test setting start, end and elapsed times correctly when filtering by tags # 1) Rebot hand-edited output with predefined times and check that times are read correctly. (A sanity check) @@ -129,14 +152,6 @@ Run and Check Suites Should Contain Suites ${SUITE.suites[0]} @{suites} Check Stats -Run And Check Suites and Tests - [Arguments] ${params} ${subsuite} @{tests} - Run Suites ${params} - Should Contain Suites ${SUITE.suites[0]} ${subsuite} - Should Contain Tests ${SUITE} @{tests} - Should Be True ${SUITE.statistics.passed} == len(@{tests}) - Check Stats - Run Suites [Arguments] ${options} Run Rebot ${options} ${INPUT FILE} diff --git a/atest/robot/tags/include_and_exclude.robot b/atest/robot/tags/include_and_exclude.robot index 80e66b70779..833b0cc3212 100644 --- a/atest/robot/tags/include_and_exclude.robot +++ b/atest/robot/tags/include_and_exclude.robot @@ -1,4 +1,8 @@ *** Settings *** +Documentation Test --include and --exclude with Robot. +... +... These options working together with --suite and --test +... is tested in filter_by_names.robot suite file. Test Template Run And Check Include And Exclude Resource atest_resource.robot diff --git a/atest/robot/tags/include_and_exclude_with_rebot.robot b/atest/robot/tags/include_and_exclude_with_rebot.robot index b8419e2d51c..3e8166b7f81 100644 --- a/atest/robot/tags/include_and_exclude_with_rebot.robot +++ b/atest/robot/tags/include_and_exclude_with_rebot.robot @@ -1,5 +1,8 @@ *** Settings *** -Documentation Testing rebot's include/exclude functionality. Tests also include/exclude first during test execution and then with rebot. +Documentation Test --include and --exclude with Rebot. +... +... These options working together with --suite and --test +... is tested in filter_by_names.robot suite file. Suite Setup Create Input Files Suite Teardown Remove File ${INPUT FILE} Test Template Run And Check Include And Exclude @@ -9,7 +12,7 @@ Resource rebot_resource.robot ${TEST FILE} tags/include_and_exclude.robot ${TEST FILE2} tags/no_force_no_default_tags.robot ${INPUT FILE} %{TEMPDIR}/robot-tags-input.xml -${INPUT FILE 2} %{TEMPDIR}/robot-tags-input-2.xml +${INPUT FILE 2} %{TEMPDIR}/robot-tags-input-2.xml ${INPUT FILES} ${INPUT FILE} @{INCL_ALL} Incl-1 Incl-12 Incl-123 @{EXCL_ALL} excl-1 Excl-12 Excl-123 diff --git a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst index 5ae7fd3dd33..a4ec5026070 100644 --- a/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/ConfiguringExecution.rst @@ -215,12 +215,6 @@ specified tests in specified suites are selected:: --suite mysuite --test mytest # Match test 'mytest' if its inside suite 'mysuite'. -Also this behavior `is likely to change`__ in the future and the above changed to mean -selecting all tests in suite `mysuite` in addition to all tests with name `mytest`. -A more reliable way to select a test in a suite is using `--test *.mysuite.mytest` -or `--test *.mysuite.*.mytest` depending on should the test be directly inside -the suite or not. - Using the :option:`--suite` option is more or less the same as executing the appropriate suite file or directory directly. The main difference is that if a file or directory is run directly, possible suite setups and teardowns @@ -239,7 +233,6 @@ the default suite name that is got from the file or directory name. New :option:`--parseinclude` option has been added to `explicitly select which files are parsed`__ if this kind of parsing optimization is needed. -__ https://github.com/robotframework/robotframework/issues/4721 __ `Selecting files by name or path`_ By tag names @@ -300,6 +293,28 @@ many interesting possibilities: the tests for a certain sprint can be generated (for example, `rebot --include sprint-42 output.xml`). +Options :option:`--include` and :option:`--exclude` can be used in combination +with :option:`--suite` and :option:`--test` discussed in the previous section. +The general rules how they work together are as follows: + +- If :option:`--suite` is used, tests must be in the specified suite in addition + to satisfying other selection criteria. + +- If :option:`--include` is used with :option:`--test`, it is enough for a test + to match either of them. + +- If :option:`--exclude` is used, tests matching it are never selected. + +The above rules are demonstrated in the following examples:: + + --suite example --include tag # Match test if it is in suite 'example' and has tag 'tag'. + --suite example --exclude tag # Match test if it is in suite 'example' and does not have tag 'tag'. + --test example --include tag # Match test if it has name 'example' or it has tag 'tag'. + --test ex* --exclude tag # Match test if its name starts with 'ex' and it does not have tag 'tag'. + +.. note:: Prior to Robot Framework 7.0 using `--include` and `--test` together + required test to have both a matching tag and a matching name. + Re-executing failed test cases ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/model/configurer.py b/src/robot/model/configurer.py index 7c78ec48f7d..cf640521eb9 100644 --- a/src/robot/model/configurer.py +++ b/src/robot/model/configurer.py @@ -66,22 +66,26 @@ def _raise_no_tests_or_tasks_error(self, name, rpa): parts = [{False: 'tests', True: 'tasks', None: 'tests or tasks'}[rpa], self._get_test_selector_msgs(), self._get_suite_selector_msg()] - raise DataError("Suite '%s' contains no %s." - % (name, ' '.join(p for p in parts if p))) + raise DataError(f"Suite '{name}' contains no " + f"{' '.join(p for p in parts if p)}.") def _get_test_selector_msgs(self): parts = [] - for explanation, selector in [('matching tags', self.include_tags), - ('not matching tags', self.exclude_tags), - ('matching name', self.include_tests)]: - if selector: - parts.append(self._format_selector_msg(explanation, selector)) - return seq2str(parts, quote='') + for separator, explanation, selectors in [ + (None, 'matching name', self.include_tests), + ('or', 'matching tags', self.include_tags), + ('and', 'not matching tags', self.exclude_tags) + ]: + if selectors: + if parts: + parts.append(separator) + parts.append(self._format_selector_msg(explanation, selectors)) + return ' '.join(parts) - def _format_selector_msg(self, explanation, selector): - if len(selector) == 1 and explanation[-1] == 's': + def _format_selector_msg(self, explanation, selectors): + if len(selectors) == 1 and explanation[-1] == 's': explanation = explanation[:-1] - return '%s %s' % (explanation, seq2str(selector, lastsep=' or ')) + return f"{explanation} {seq2str(selectors, lastsep=' or ')}" def _get_suite_selector_msg(self): if not self.include_suites: diff --git a/src/robot/model/filter.py b/src/robot/model/filter.py index f447be77c47..3578acbe422 100644 --- a/src/robot/model/filter.py +++ b/src/robot/model/filter.py @@ -81,26 +81,32 @@ def start_suite(self, suite: 'TestSuite'): if not self: return False if hasattr(suite, 'start_time'): - suite.start_time = suite.end_time = None + suite.start_time = suite.end_time = suite.elapsed_time = None if self.include_suites is not None: - if self.include_suites.match(suite.name, suite.longname): - suite.visit(Filter(include_tests=self.include_tests, - include_tags=self.include_tags, - exclude_tags=self.exclude_tags)) - return False - suite.tests = [] - return True - if self.include_tests is not None: - suite.tests = [t for t in suite.tests - if self.include_tests.match(t.name, t.longname)] - if self.include_tags is not None: - suite.tests = [t for t in suite.tests - if self.include_tags.match(t.tags)] - if self.exclude_tags is not None: - suite.tests = [t for t in suite.tests - if not self.exclude_tags.match(t.tags)] + return self._filter_based_on_suite_name(suite) + suite.tests = [t for t in suite.tests if self._test_included(t)] return bool(suite.suites) + def _filter_based_on_suite_name(self, suite: 'TestSuite') -> bool: + if self.include_suites.match(suite.name, suite.longname): + suite.visit(Filter(include_tests=self.include_tests, + include_tags=self.include_tags, + exclude_tags=self.exclude_tags)) + return False + suite.tests = [] + return True + + def _test_included(self, test: 'TestCase') -> bool: + tests, include, exclude \ + = self.include_tests, self.include_tags, self.exclude_tags + if exclude is not None and exclude.match(test.tags): + return False + if include is not None and include.match(test.tags): + return True + if tests is not None and tests.match(test.name, test.longname): + return True + return include is None and tests is None + def __bool__(self) -> bool: return bool(self.include_suites is not None or self.include_tests is not None or diff --git a/src/robot/model/tags.py b/src/robot/model/tags.py index 05ec4e92088..a2e2298a6f8 100644 --- a/src/robot/model/tags.py +++ b/src/robot/model/tags.py @@ -105,7 +105,7 @@ def __add__(self, other: Iterable[str]) -> 'Tags': class TagPatterns(Sequence['TagPattern']): - def __init__(self, patterns: Iterable[str]): + def __init__(self, patterns: Iterable[str] = ()): self._patterns = tuple(TagPattern.from_string(p) for p in Tags(patterns)) def match(self, tags: Iterable[str]) -> bool: diff --git a/utest/result/test_configurer.py b/utest/result/test_configurer.py index d6023d54e78..d69647e26d6 100644 --- a/utest/result/test_configurer.py +++ b/utest/result/test_configurer.py @@ -89,20 +89,23 @@ def test_no_matching_tests_with_one_selector_each(self): include_suites='s', include_tests='t') assert_raises_with_msg( DataError, - "Suite 'root' contains no tests matching tag 'i', " - "not matching tag 'e' and matching name 't' in suite 's'.", + "Suite 'root' contains no tests matching name 't' " + "or matching tag 'i' " + "and not matching tag 'e' " + "in suite 's'.", self.suite.visit, configurer ) def test_no_matching_tests_with_multiple_selectors(self): - configurer = SuiteConfigurer(include_tags=['i1', 'i2'], + configurer = SuiteConfigurer(include_tags=['i1', 'i2', 'i3'], exclude_tags=['e1', 'e2'], include_suites=['s1', 's2', 's3'], include_tests=['t1', 't2']) assert_raises_with_msg( DataError, - "Suite 'root' contains no tests matching tags 'i1' or 'i2', " - "not matching tags 'e1' or 'e2' and matching name 't1' or 't2' " + "Suite 'root' contains no tests matching name 't1' or 't2' " + "or matching tags 'i1', 'i2' or 'i3' " + "and not matching tags 'e1' or 'e2' " "in suites 's1', 's2' or 's3'.", self.suite.visit, configurer ) From 5e5437a8caa78b504ce548f2f7b424e326c85c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Tue, 12 Sep 2023 19:07:27 +0300 Subject: [PATCH 0693/1592] Windows unit test fixes --- utest/utils/test_importer_util.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utest/utils/test_importer_util.py b/utest/utils/test_importer_util.py index e2c939a9da9..392fb7f5753 100644 --- a/utest/utils/test_importer_util.py +++ b/utest/utils/test_importer_util.py @@ -14,8 +14,8 @@ from robot.utils.importer import ByPathImporter, Importer -CURDIR = Path(__file__).resolve().parent -LIBDIR = (CURDIR / '../../atest/testresources/testlibs').resolve() +CURDIR = Path(__file__).absolute().parent +LIBDIR = CURDIR.parent.parent / 'atest/testresources/testlibs' TEMPDIR = Path(tempfile.gettempdir()) TESTDIR = TEMPDIR / 'robot-importer-testing' WINDOWS_PATH_IN_ERROR = re.compile(r"'\w:\\") @@ -128,7 +128,7 @@ def _import_and_verify(self, path, attr=42, directory=TESTDIR, assert_equal(module.attr, attr) assert_equal(module.func(), attr) if hasattr(module, '__file__'): - assert_equal(Path(module.__file__).resolve().parent, directory) + assert_true(Path(module.__file__).parent.samefile(directory)) def _import(self, path, name=None, remove=None): if remove and remove in sys.modules: From dde74573a153b1fc8a7a1ac91dc1c390ba190d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= <peke@iki.fi> Date: Wed, 13 Sep 2023 12:49:49 +0300 Subject: [PATCH 0694/1592] Remove old cruft --- doc/userguide/src/utils/tag_stat_args.txt | 7 - doc/userguide/src/utils/tag_stat_links.html | 238 -------------------- 2 files changed, 245 deletions(-) delete mode 100644 doc/userguide/src/utils/tag_stat_args.txt delete mode 100644 doc/userguide/src/utils/tag_stat_links.html diff --git a/doc/userguide/src/utils/tag_stat_args.txt b/doc/userguide/src/utils/tag_stat_args.txt deleted file mode 100644 index 0187c060b2a..00000000000 --- a/doc/userguide/src/utils/tag_stat_args.txt +++ /dev/null @@ -1,7 +0,0 @@ ---tagstatlink owner-*:mailto:%1@domain.com?subject=Acceptance_Tests:Send_mail ---tagstatlink mytag:http://google.com:Google i ---tagstatlink example-bug-*:http://example.com ---tagstatcombine owner-* ---tagstatcombine smokeANDmytag ---tagstatcombine smokeNOTowner-janne* -tag_stat_links.html diff --git a/doc/userguide/src/utils/tag_stat_links.html b/doc/userguide/src/utils/tag_stat_links.html deleted file mode 100644 index 46e6d44959a..00000000000 --- a/doc/userguide/src/utils/tag_stat_links.html +++ /dev/null @@ -1,238 +0,0 @@ - -<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> -<html> -<head> -<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> -<meta name="generator" content="RobotIDE" /> -<style type="text/css"> -html { - font-family: Arial,Helvetica,sans-serif; - background-color: white; - color: black; -} -p { - max-width: 60em; -} -table { - border-collapse: collapse; - empty-cells: show; - margin: 1em 0em; - border: 0.1em solid black; -} -th, td { - border-style: solid; - border-width: 0.05em 0.1em; - border-color: black; - padding: 0.1em 0.2em; - height: 1.5em; -} -th { - background-color: rgb(192, 192, 192); - color: black; - border-width: 0.1em; - font-weight: bold; - text-align: center; - text-transform: capitalize; - letter-spacing: 0.1em; -} -/* Widths of named columns */ -col.name { - width: 10em; -} -.action, .value, .arg { - width: 15em; -} -/* Properties for the name column -- td:first-child should work in CSS 2.1 avare browsers (tested in Firefox) -- col.name is against specs but works in IE -*/ -td:first-child, col.name { - background-color: rgb(240, 240, 240); - text-transform: capitalize; - letter-spacing: 0.1em; -} -/* required for IE */ -th { - font-style: normal; -} -</style> -<title>Tag Stat Links - - -

Tag Stat Links

- - --- - - - - - - - - - - - - - - - - - - - - - -
SettingValueValueValueValue
Force Tags
- - --- - - - - - - - - - - - - - - -
VariableValueValueValueValue
- - ---- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Test CaseActionArgumentArgumentArgument
My Tag[Tags]mytagsmokeowner-janne.t.harkonen
No Operation
Jython Bug 1[Tags]jython-bug-1777567owner-janne.t.harkonen
No Operation
Jython Bug 2[Tags]jython-bug-1778514owner-janne.t.harkonen
No Operation
Smoke[Tags]smokeowner-laukpe
No Operation
- - ---- - - - - - - - - - - - - - - -
KeywordActionArgumentArgumentArgument
- - - From a702d310441ec4c4a4c4996ef0365e34d539e351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 13 Sep 2023 12:51:02 +0300 Subject: [PATCH 0695/1592] Support removing common tags by using `-tag` syntax with `[Tags]`. Fixes #4374. --- atest/robot/tags/-tag_syntax.robot | 36 ++---- atest/testdata/tags/-tag_syntax.resource | 7 +- atest/testdata/tags/-tag_syntax.robot | 26 ++-- .../CreatingTestData/CreatingTestCases.rst | 113 +++++++++++++----- .../CreatingTestData/CreatingUserKeywords.rst | 36 +++--- src/robot/running/builder/transformers.py | 34 +++--- 6 files changed, 151 insertions(+), 101 deletions(-) diff --git a/atest/robot/tags/-tag_syntax.robot b/atest/robot/tags/-tag_syntax.robot index 3ee42d89bae..6a6dde2564a 100644 --- a/atest/robot/tags/-tag_syntax.robot +++ b/atest/robot/tags/-tag_syntax.robot @@ -3,34 +3,22 @@ Suite Setup Run Tests ${EMPTY} tags/-tag_syntax.robot Resource atest_resource.robot *** Test Cases *** -Deprecation warning with test - Check Test Tags Deprecation warning -literal-with-force -warn-with-test - Check Deprecation Warning 0 tags/-tag_syntax.robot 11 -warn-with-test +Remove from test + Check Test Tags ${TEST NAME} tag1 tag3 tag4 -Deprecation warning with keyword - ${tc} = Check Test Case Deprecation warning - Check Keyword Data ${tc.kws[0]} Keyword tags=-warn-with-keyword - Check Deprecation Warning 1 tags/-tag_syntax.robot 25 -warn-with-keyword +Remove from test using pattern + Check Test Tags ${TEST NAME} -in-settings tag tag3 -Deprecation warning with keyword in resource - ${tc} = Check Test Case Deprecation warning - Check Keyword Data ${tc.kws[1]} -tag_syntax.Keyword In Resource tags=-warn-with-keyword-in-resource - Check Deprecation Warning 2 tags/-tag_syntax.resource 3 -warn-with-keyword-in-resource +Remove from keyword + ${tc} = Check Test Case Remove from test + Check Keyword Data ${tc.kws[0]} ${TEST NAME} tags=-in-settings, kw2 -No deprecation warning from Settings, when escaped, or with variables - Length Should Be ${ERRORS} 3 +Remove from keyword using pattern + ${tc} = Check Test Case Remove from test using pattern + Check Keyword Data ${tc.kws[0]} -tag_syntax.${TEST NAME} tags=r1, r5, r6 Escaped - Check Test Tags ${TESTNAME} -literal-escaped -literal-with-force + Check Test Tags ${TESTNAME} -escaped -in-settings tag tag1 tag2 tag3 Variable - Check Test Tags ${TESTNAME} -literal-with-force -literal-with-variable - -*** Keywords *** -Check Deprecation Warning - [Arguments] ${index} ${source} ${lineno} ${tag} - Error in file ${index} ${source} ${lineno} - ... Settings tags starting with a hyphen using the '[Tags]' setting is deprecated. - ... In Robot Framework 7.0 this syntax will be used for removing tags. - ... Escape '${tag}' like '\\${tag}' to use the literal value and to avoid this warning. - ... level=WARN pattern=False + Check Test Tags ${TESTNAME} -in-settings -variable tag tag1 tag2 tag3 diff --git a/atest/testdata/tags/-tag_syntax.resource b/atest/testdata/tags/-tag_syntax.resource index 32730617134..a4c0a12de83 100644 --- a/atest/testdata/tags/-tag_syntax.resource +++ b/atest/testdata/tags/-tag_syntax.resource @@ -1,4 +1,7 @@ +*** Settings *** +Keyword Tags r1 r2 r3 r4 r5 + *** Keywords *** -Keyword In Resource - [Tags] -warn-with-keyword-in-resource +Remove from keyword using pattern + [Tags] r6 -r[2-4] No Operation diff --git a/atest/testdata/tags/-tag_syntax.robot b/atest/testdata/tags/-tag_syntax.robot index 438e51b1fcc..18f80ae06b6 100644 --- a/atest/testdata/tags/-tag_syntax.robot +++ b/atest/testdata/tags/-tag_syntax.robot @@ -1,26 +1,30 @@ *** Settings *** -Force Tags -literal-with-force -Default Tags -literal-with-default +Test Tags -in-settings tag1 tag2 tag3 ${TAG} +Keyword Tags -in-settings kw1 kw2 Resource -tag_syntax.resource *** Variables *** -${TAG} -literal-with-variable +${TAG} tag +${VAR} -variable *** Test Cases *** -Deprecation warning - [Tags] -warn-with-test - Keyword - Keyword In Resource +Remove from test + [Tags] -tag2 tag4 -${tag} --in-settings + Remove from keyword + +Remove from test using pattern + [Tags] -tag[12] + Remove from keyword using pattern Escaped - [Tags] \-literal-escaped + [Tags] \-escaped No Operation Variable - [Tags] ${TAG} + [Tags] ${VAR} No Operation *** Keywords *** -Keyword - [Tags] -warn-with-keyword +Remove from keyword + [Tags] -kw1 No Operation diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index b725c301ad3..0651f2de867 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -78,12 +78,12 @@ below and explained later in this section. `[Documentation]`:setting: Used for specifying a `test case documentation`_. -`[Tags]`:setting: - Used for `tagging test cases`_. - `[Setup]`:setting:, `[Teardown]`:setting: Specify `test setup and teardown`_. +`[Tags]`:setting: + Used for `tagging test cases`_. + `[Template]`:setting: Specifies the `template keyword`_ to use. The test itself will contain only data to use as arguments to that keyword. @@ -115,12 +115,12 @@ The Setting section can have the following test case related settings. These settings are mainly default values for the test case specific settings listed earlier. -`Force Tags`:setting:, `Default Tags`:setting: - The forced and default values for tags_. - `Test Setup`:setting:, `Test Teardown`:setting: The default values for `test setup and teardown`_. +`Test Tags`:setting: + Tags_ all tests in the suite will get in addition to their possible own tags. + `Test Template`:setting: The default `template keyword`_ to use. @@ -601,14 +601,15 @@ __ `By tag names`_ There are multiple ways how to specify tags for test cases explained below: -`Test Tags`:setting: in the Setting section +`Test Tags`:setting: setting in the Settings section All tests in a test case file with this setting always get specified tags. If this setting is used in a `suite initialization file`_, all tests in child suites get these tags. -`[Tags]`:setting: with each test case - Tests get these tags in addition to tags specified using the - :setting:`Test Tags` setting. +`[Tags]`:setting: setting with each test case + Tests get these tags in addition to tags specified using the :setting:`Test Tags` + setting. The :setting:`[Tags]` setting also allows removing tags set with + :setting:`Test Tags` by using the `-tag` syntax. `--settag`:option: command line option All tests get tags set with this option in addition to tags they got elsewhere. @@ -629,19 +630,29 @@ Example: *** Test Cases *** No own tags - [Documentation] This test has tags 'requirement: 42' and 'smoke'. + [Documentation] Test has tags 'requirement: 42' and 'smoke'. No Operation Own tags - [Documentation] This test has tags 'requirement: 42', 'smoke' and 'not ready'. + [Documentation] Test has tags 'requirement: 42', 'smoke' and 'not ready'. [Tags] not ready No Operation Own tags with variable - [Documentation] This test has tags 'requirement: 42', 'smoke' and 'host: 10.0.1.42'. + [Documentation] Test has tags 'requirement: 42', 'smoke' and 'host: 10.0.1.42'. [Tags] host: ${HOST} No Operation + Remove common tag + [Documentation] Test has only tag 'requirement: 42'. + [Tags] -smoke + No Operation + + Remove common tag using a pattern + [Documentation] Test has only tag 'smoke'. + [Tags] -requirement: * + No Operation + Set Tags and Remove Tags keywords [Documentation] This test has tags 'smoke', 'example' and 'another'. Set Tags example another @@ -652,9 +663,19 @@ preserve the exact name used in the data. When tags are compared, for example, to collect statistics, to select test to be executed, or to remove duplicates, comparisons are case, space and underscore insensitive. +As demonstrated by the above examples, removing tags using `-tag` syntax supports +`simple patterns`_ like `-requirement: *`. Tags starting with a hyphen have no +special meaning otherwise than with the :setting:`[Tags]` setting. If there is +a need to set a tag starting with a hyphen with :setting:`[Tags]`, it is possible +to use the escaped__ format like `\-tag`. + .. note:: The :setting:`Test Tags` setting is new in Robot Framework 6.0. Earlier versions support :setting:`Force Tags` and :setting:`Default Tags` - settings discussed below. + settings discussed in the next section. + +.. note:: The `-tag` syntax for removing common tags is new in Robot Framework 7.0. + +__ escaping Deprecation of :setting:`Force Tags` and :setting:`Default Tags` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -671,23 +692,61 @@ using two different settings: it will not get these tags. Both of these settings still work, but they are considered deprecated. -A visible deprecation warning will be added in the future, possibly -already in Robot Framework 7.0, and eventually these settings will be removed. +A visible deprecation warning will be added in the future, most likely +in Robot Framework 8.0, and eventually these settings will be removed. Tools like Tidy__ can be used to ease transition. -Robot Framework 7.0 will introduce a new way for tests to indicate they -`should not get certain globally specified tags`__. Instead of using a separate -setting that tests can override, tests can use the `-tag` syntax with their -:setting:`[Tags]` setting to tell they should not get a tag named `tag`. -This syntax *does not* yet work in Robot Framework 6.x series, but using -:setting:`[Tags]` with a literal value like `-tag` `is already deprecated`__. -If such tags are needed, it is possible to use the :setting:`Test Tags` -setting or escape__ the hyphen like `\-tag`. +Updating :setting:`Force Tags` requires only renaming it to :setting:`Test Tags`. +The :setting:`Default Tags` setting will be removed altogether, but the `-tag` +functionality introduced in Robot Framework 7.0 provides same underlying +functionality. The following examples demonstrate the needed changes. + +Old syntax: + +.. sourcecode:: robotframework + + *** Settings *** + Force Tags all + Default Tags default + + *** Test Cases *** + Common only + [Documentation] Test has tags 'all' and 'default'. + No Operation + + No default + [Documentation] Test has only tag 'all'. + [Tags] + No Operation + + Own and no default + [Documentation] Test has tags 'all' and 'own'. + [Tags] own + No Operation + +New syntax: + +.. sourcecode:: robotframework + + *** Settings *** + Test Tags all default + + *** Test Cases *** + Common only + [Documentation] Test has tags 'all' and 'default'. + No Operation + + No default + [Documentation] Test has only tag 'all'. + [Tags] -default + No Operation + + Own and no default + [Documentation] Test has tags 'all' and 'own'. + [Tags] own -default + No Operation __ https://robotidy.readthedocs.io -__ https://github.com/robotframework/robotframework/issues/4374 -__ https://github.com/robotframework/robotframework/issues/4380 -__ escaping_ Reserved tags ~~~~~~~~~~~~~ diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index f8ebc4b8fdd..880eda7814a 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -143,31 +143,40 @@ User keyword tags Both user keywords and `library keywords`_ can have tags. Similarly as when `tagging test cases`_, there are two settings affecting user keyword tags: -`Keyword Tags`:setting: in the Setting section +`Keyword Tags`:setting: setting in the Settings section All keywords in a file with this setting always get specified tags. -`[Tags]`:setting: with each keyword +`[Tags]`:setting: setting with each keyword Keywords get these tags in addition to possible tags specified using the - :setting:`Keyword Tags` setting. + :setting:`Keyword Tags` setting. The :setting:`[Tags]` setting also allows + removing tags set with :setting:`Keyword Tags` by using the `-tag` syntax. .. sourcecode:: robotframework *** Settings *** - Keyword Tags gui + Keyword Tags gui html *** Keywords *** No own tags - [Documentation] This keyword has tag 'gui'. + [Documentation] Keyword has tags 'gui' and 'html'. No Operation Own tags - [Documentation] This keyword has tags 'gui', 'own' and 'tags'. + [Documentation] Keyword has tags 'gui', 'html', 'own' and 'tags'. [Tags] own tags No Operation -Additionally, keyword tags can be specified on the last line of the documentation -with `Tags:` prefix so that tags are separated with a comma. For example, -following two keywords get same three tags: + Remove common tag + [Documentation] Test has tags 'gui' and 'own'. + [Tags] own -html + No Operation + +Keyword tags can be specified using variables, the `-tag` syntax supports +patterns, and so on, exactly as `test case tags`_. + +In addition to using the dedicated settings, keyword tags can be specified on +the last line of the documentation with `Tags:` prefix so that tags are separated +with a comma. For example, following two keywords get same three tags: .. sourcecode:: robotframework @@ -197,18 +206,11 @@ reserved tag `robot:flatten`. versions all keyword tags need to be specified using the :setting:`[Tags]` setting. -.. note:: Robot Framework 6.1 will support `removing globally set tags`__ using - the `-tag` syntax with the :setting:`[Tags]` setting. Creating tags - with literal value like `-tag` `is deprecated`__ in Robot Framework 6.0 - and escaped__ syntax `\-tag` must be used if such tags are actually - needed. +.. note:: The `-tag` syntax for removing common tags is new in Robot Framework 7.0. __ `Removing keywords`_ __ `Flattening keywords`_ __ `Reserved tags`_ -__ https://github.com/robotframework/robotframework/issues/4374 -__ https://github.com/robotframework/robotframework/issues/4380 -__ escaping_ User keyword arguments ---------------------- diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index cbfe0345133..a5e56a9363b 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -167,7 +167,7 @@ def __init__(self, suite: TestSuite, settings: FileSettings): self.suite = suite self.settings = settings self.test = None - self.tags = None + self._test_has_tags = False def visit_TestCase(self, node): error = format_error(node.errors + node.header.errors) @@ -183,9 +183,8 @@ def visit_TestCase(self, node): if settings.test_teardown: self.test.teardown.config(**settings.test_teardown) self.generic_visit(node) - tags = self.tags if self.tags is not None else settings.default_tags - if tags: - self.test.tags.add(tags) + if not self._test_has_tags: + self.test.tags.add(settings.default_tags) if self.test.template: self._set_template(self.test, self.test.template) @@ -240,8 +239,12 @@ def visit_Timeout(self, node): self.test.timeout = node.value def visit_Tags(self, node): - deprecate_tags_starting_with_hyphen(node, self.suite.source) - self.tags = node.values + for tag in node.values: + if tag.startswith('-'): + self.test.tags.remove(tag[1:]) + else: + self.test.tags.add(tag) + self._test_has_tags = True def visit_Template(self, node): self.test.template = node.value @@ -292,8 +295,11 @@ def visit_Arguments(self, node): self.kw.error = f'Invalid argument specification: {error}' def visit_Tags(self, node): - deprecate_tags_starting_with_hyphen(node, self.resource.source) - self.kw.tags.add(node.values) + for tag in node.values: + if tag.startswith('-'): + self.kw.tags.remove(tag[1:]) + else: + self.kw.tags.add(tag) def visit_Return(self, node): self.kw.return_ = node.values @@ -602,18 +608,6 @@ def format_error(errors): return '\n- '.join(('Multiple errors:',) + errors) -def deprecate_tags_starting_with_hyphen(node, source): - for tag in node.values: - if tag.startswith('-'): - LOGGER.warn( - f"Error in file '{source}' on line {node.lineno}: " - f"Settings tags starting with a hyphen using the '[Tags]' setting " - f"is deprecated. In Robot Framework 7.0 this syntax will be used " - f"for removing tags. Escape '{tag}' like '\\{tag}' to use the " - f"literal value and to avoid this warning." - ) - - class ErrorReporter(NodeVisitor): def __init__(self, source, raise_on_invalid_header=False): From 313f394c3eb8cfaa8810b70d1f067240005e7d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 14 Sep 2023 01:12:12 +0300 Subject: [PATCH 0696/1592] Process: More visible note about output buffers possibly getting full. Buffers getting full cause processes to hang. Fixes #4864. --- src/robot/libraries/Process.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/robot/libraries/Process.py b/src/robot/libraries/Process.py index 93ddb3d19e3..ee39f91f4d1 100644 --- a/src/robot/libraries/Process.py +++ b/src/robot/libraries/Process.py @@ -334,6 +334,12 @@ def run_process(self, command, *arguments, **configuration): with `Wait For Process` keyword. By default, there is no timeout, and if timeout is defined the default action on timeout is ``terminate``. + Process outputs are, by default, written into in-memory buffers. + If there is a lot of output, these buffers may get full causing + the process to hang. To avoid that, process outputs can be redirected + using the ``stdout`` and ``stderr`` configuration parameters. For more + information see the `Standard output and error streams` section. + Returns a `result object` containing information about the execution. Note that possible equal signs in ``*arguments`` must be escaped @@ -343,7 +349,7 @@ def run_process(self, command, *arguments, **configuration): Examples: | ${result} = | Run Process | python | -c | print('Hello, world!') | | Should Be Equal | ${result.stdout} | Hello, world! | - | ${result} = | Run Process | ${command} | stderr=STDOUT | timeout=10s | + | ${result} = | Run Process | ${command} | stdout=${CURDIR}/stdout.txt | stderr=STDOUT | | ${result} = | Run Process | ${command} | timeout=1min | on_timeout=continue | | ${result} = | Run Process | java -Dname\\=value Example | shell=True | cwd=${EXAMPLE} | @@ -363,11 +369,13 @@ def start_process(self, command, *arguments, **configuration): See `Specifying command and arguments` and `Process configuration` for more information about the arguments, and `Run Process` keyword - for related examples. + for related examples. This includes information about redirecting + process outputs to avoid process handing due to output buffers getting + full. Makes the started process new `active process`. Returns the created [https://docs.python.org/3/library/subprocess.html#popen-constructor | - subprocess.Popen] object which can be be used later to active this + subprocess.Popen] object which can be used later to activate this process. ``Popen`` attributes like ``pid`` can also be accessed directly. Processes are started so that they create a new process group. This @@ -375,7 +383,7 @@ def start_process(self, command, *arguments, **configuration): Examples: - Start process and wait for it to end later using alias: + Start process and wait for it to end later using an alias: | `Start Process` | ${command} | alias=example | | # Other keywords | | ${result} = | `Wait For Process` | example | From fb6ad57cc074a6f0dc65b9771935d9cc8bcd519d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 14 Sep 2023 13:11:09 +0300 Subject: [PATCH 0697/1592] Don't sort dicts when they are pretty-printer with `prepr`. Fixes #4867. --- .../standard_libraries/builtin/log.robot | 8 +++--- .../standard_libraries/builtin/log.robot | 4 +-- .../builtin/should_be_equal.robot | 2 +- .../builtin/variables_to_verify.py | 4 +-- src/robot/utils/unic.py | 4 +-- utest/utils/test_unic.py | 26 +++++++++++++------ 6 files changed, 29 insertions(+), 19 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/log.robot b/atest/robot/standard_libraries/builtin/log.robot index 7b4cb43b7db..46867ea77b8 100644 --- a/atest/robot/standard_libraries/builtin/log.robot +++ b/atest/robot/standard_libraries/builtin/log.robot @@ -58,7 +58,7 @@ CONSOLE pseudo level ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} Hello, info and console! Stdout Should Contain Hello, info and console!\n - + repr=True ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} The 'repr' argument of 'BuiltIn.Log' is deprecated. Use 'formatter=repr' instead. WARN @@ -96,13 +96,13 @@ formatter=str formatter=repr pretty prints ${tc} = Check Test Case ${TEST NAME} ${long string} = Evaluate ' '.join(['Robot Framework'] * 1000) - ${small dict} = Set Variable {3: b'items', 'a': 'sorted', 'small': 'dict'} + ${small dict} = Set Variable {'small': 'dict', 3: b'items', 'NOT': 'sorted'} ${small list} = Set Variable ['small', b'list', 'not sorted', 4] Check Log Message ${tc.kws[1].msgs[0]} '${long string}' Check Log Message ${tc.kws[3].msgs[0]} ${small dict} - Check Log Message ${tc.kws[5].msgs[0]} {'big': 'dict',\n\ 'list': [1, 2, 3],\n\ 'long': '${long string}',\n\ 'nested': ${small dict}} + Check Log Message ${tc.kws[5].msgs[0]} {'big': 'dict',\n 'long': '${long string}',\n 'nested': ${small dict},\n 'list': [1, 2, 3],\n 'sorted': False} Check Log Message ${tc.kws[7].msgs[0]} ${small list} - Check Log Message ${tc.kws[9].msgs[0]} ['big',\n\ 'list',\n\ '${long string}',\n\ b'${long string}',\n\ ['nested', ('tuple', 2)],\n\ ${small dict}] + Check Log Message ${tc.kws[9].msgs[0]} ['big',\n 'list',\n '${long string}',\n b'${long string}',\n ['nested', ('tuple', 2)],\n ${small dict}] Check Log Message ${tc.kws[11].msgs[0]} ['hyvä', b'hyv\\xe4', {'☃': b'\\x00\\xff'}] Stdout Should Contain ${small dict} Stdout Should Contain ${small list} diff --git a/atest/testdata/standard_libraries/builtin/log.robot b/atest/testdata/standard_libraries/builtin/log.robot index 7a822ee55fb..18af0eaee1c 100644 --- a/atest/testdata/standard_libraries/builtin/log.robot +++ b/atest/testdata/standard_libraries/builtin/log.robot @@ -92,9 +92,9 @@ formatter=str formatter=repr pretty prints ${long string} = Evaluate ' '.join(['Robot Framework'] * 1000) Log ${long string} formatter=repr - ${small dict} = Evaluate {'small': 'dict', 3: b'items', 'a': 'sorted'} + ${small dict} = Evaluate {'small': 'dict', 3: b'items', 'NOT': 'sorted'} Log ${small dict} formatter=repr console=TRUE - ${big dict} = Evaluate {'big': 'dict', 'long': '${long string}', 'nested': ${small dict}, 'list': [1, 2, 3]} + ${big dict} = Evaluate {'big': 'dict', 'long': '${long string}', 'nested': ${small dict}, 'list': [1, 2, 3], 'sorted': False} Log ${big dict} html=NO formatter=repr ${small list} = Evaluate ['small', b'list', 'not sorted', 4] Log ${small list} console=gyl formatter=repr diff --git a/atest/testdata/standard_libraries/builtin/should_be_equal.robot b/atest/testdata/standard_libraries/builtin/should_be_equal.robot index 57b51879a4b..45925574feb 100644 --- a/atest/testdata/standard_libraries/builtin/should_be_equal.robot +++ b/atest/testdata/standard_libraries/builtin/should_be_equal.robot @@ -153,7 +153,7 @@ formatter=repr/ascii with non-ASCII characters ... ... 6) '\\xc4' != 'A\\u0308' ... - ... 7) {'A': 2, 'a': 1, 'Ä': 4, 'ä': 3} != {'a': 1} + ... 7) {'a': 1, 'A': 2, 'ä': 3, 'Ä': 4} != {'a': 1} ... ... 8) ${ASCII DICT} != {'a': 1} Ä A diff --git a/atest/testdata/standard_libraries/builtin/variables_to_verify.py b/atest/testdata/standard_libraries/builtin/variables_to_verify.py index f268ef4c891..e17bd1d779c 100644 --- a/atest/testdata/standard_libraries/builtin/variables_to_verify.py +++ b/atest/testdata/standard_libraries/builtin/variables_to_verify.py @@ -15,8 +15,8 @@ def get_variables(): LIST_2=['a', 2], LIST_3=['a', 'b', 'c'], LIST_4=['\ta', '\na', 'b ', 'b \t', '\tc\n'], - DICT={'a': 1, 'A': 2, '\xe4': 3, '\xc4': 4}, - ORDERED_DICT=OrderedDict([('a', 1), ('A', 2), ('\xe4', 3), ('\xc4', 4)]), + DICT={'a': 1, 'A': 2, 'ä': 3, 'Ä': 4}, + ORDERED_DICT=OrderedDict([('a', 1), ('A', 2), ('ä', 3), ('Ä', 4)]), DICT_0={}, DICT_1={'a': 1}, DICT_2={'a': 1, 2: 'b'}, diff --git a/src/robot/utils/unic.py b/src/robot/utils/unic.py index d13ac9a62de..fc879829196 100644 --- a/src/robot/utils/unic.py +++ b/src/robot/utils/unic.py @@ -36,8 +36,8 @@ def _safe_str(item): return _unrepresentable_object(item) -def prepr(item, width=80): - return safe_str(PrettyRepr(width=width).pformat(item)) +def prepr(item, width=80, sort_dicts=False): + return safe_str(PrettyRepr(width=width, sort_dicts=sort_dicts).pformat(item)) class PrettyRepr(PrettyPrinter): diff --git a/utest/utils/test_unic.py b/utest/utils/test_unic.py index d66a8328267..a6fb06ceaee 100644 --- a/utest/utils/test_unic.py +++ b/utest/utils/test_unic.py @@ -16,10 +16,10 @@ def test_unicode_nfc_and_nfd_decomposition_equality(self): assert_equal(safe_str(unicodedata.normalize('NFD', text)), text) def test_object_containing_unicode_repr(self): - assert_equal(safe_str(UnicodeRepr()), 'Hyvä') + assert_equal(safe_str(NonAsciiRepr()), 'Hyvä') def test_list_with_objects_containing_unicode_repr(self): - objects = [UnicodeRepr(), UnicodeRepr()] + objects = [NonAsciiRepr(), NonAsciiRepr()] result = safe_str(objects) assert_equal(result, '[Hyvä, Hyvä]') @@ -55,14 +55,14 @@ def _verify(self, item, expected=None, **config): assert_equal(prepr({item: item}), '{%s: %s}' % (expected, expected)) assert_equal(prepr({item}), '{%s}' % expected) - def test_ascii_unicode(self): + def test_ascii_string(self): self._verify('foo', "'foo'") self._verify("f'o'o", "\"f'o'o\"") - def test_non_ascii_unicode(self): + def test_non_ascii_string(self): self._verify('hyvä', "'hyvä'") - def test_unicode_in_nfd(self): + def test_string_in_nfd(self): self._verify('hyva\u0308', "'hyvä'") def test_ascii_bytes(self): @@ -82,8 +82,8 @@ def test_failing_repr(self): failing = ReprFails() self._verify(failing, failing.unrepr) - def test_unicode_repr(self): - obj = UnicodeRepr() + def test_non_ascii_repr(self): + obj = NonAsciiRepr() self._verify(obj, 'Hyvä') def test_bytes_repr(self): @@ -97,6 +97,16 @@ def test_collections(self): self._verify(['ä'], "['ä']") self._verify({'ä'}, "{'ä'}") + def test_dont_sort_dicts_by_default(self): + self._verify({'x': 1, 'D': 2, 'ä': 3, 'G': 4, 'a': 5}, + "{'x': 1, 'D': 2, 'ä': 3, 'G': 4, 'a': 5}") + self._verify({'a': 1, 1: 'a'}, "{'a': 1, 1: 'a'}") + + def test_allow_sorting_dicts(self): + self._verify({'x': 1, 'D': 2, 'ä': 3, 'G': 4, 'a': 5}, + "{'D': 2, 'G': 4, 'a': 5, 'x': 1, 'ä': 3}", sort_dicts=True) + self._verify({'a': 1, 1: 'a'}, "{1: 'a', 'a': 1}", sort_dicts=True) + def test_dotdict(self): self._verify(DotDict({'x': b'\xe4'}), "{'x': b'\\xe4'}") @@ -147,7 +157,7 @@ def __repr__(self): raise RuntimeError(self.error) -class UnicodeRepr(UnRepr): +class NonAsciiRepr(UnRepr): def __init__(self): try: From 0bfa9f16bcf5e1408f3caac9a36ebffd1e42de86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 14 Sep 2023 17:14:18 +0300 Subject: [PATCH 0698/1592] Remote: Cleanup - Use `use_builtin_types=True` to get custom `Binary` converted to standard `bytes` automatically when sending and reserving. This allowed removing related conversion code. - The above ought to also get returned XML-RPC datetime objects converted to `datetime`, but that needs to be tested separately as part of #4784. - Remove code not anymore needed with Python 3. - f-strings and other misc stuff. --- src/robot/libraries/Remote.py | 61 ++++++++++++++--------------------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/src/robot/libraries/Remote.py b/src/robot/libraries/Remote.py index 945b6e526b6..2f359d10c18 100644 --- a/src/robot/libraries/Remote.py +++ b/src/robot/libraries/Remote.py @@ -59,8 +59,8 @@ def get_keyword_names(self): try: return self._client.get_keyword_names() except TypeError as error: - raise RuntimeError('Connecting remote server at %s failed: %s' - % (self._uri, error)) + raise RuntimeError(f'Connecting remote server at {self._uri} ' + f'failed: {error}') def _is_lib_info_available(self): if not self._lib_info_initialized: @@ -107,38 +107,30 @@ def run_keyword(self, name, args, kwargs): class ArgumentCoercer: binary = re.compile('[\x00-\x08\x0B\x0C\x0E-\x1F]') - non_ascii = re.compile('[\x80-\xff]') def coerce(self, argument): for handles, handler in [(is_string, self._handle_string), - (is_bytes, self._handle_bytes), - (is_number, self._pass_through), + (self._no_conversion_needed, self._pass_through), (is_dict_like, self._coerce_dict), - (is_list_like, self._coerce_list), - (lambda arg: True, self._to_string)]: + (is_list_like, self._coerce_list)]: if handles(argument): return handler(argument) + return self._to_string(argument) + + def _no_conversion_needed(self, arg): + return is_number(arg) or is_bytes(arg) def _handle_string(self, arg): - if self._string_contains_binary(arg): + if self.binary.search(arg): return self._handle_binary_in_string(arg) return arg - def _string_contains_binary(self, arg): - return (self.binary.search(arg) or - is_bytes(arg) and self.non_ascii.search(arg)) - def _handle_binary_in_string(self, arg): try: - if not is_bytes(arg): - # Map Unicode code points to bytes directly - arg = arg.encode('latin-1') + # Map Unicode code points to bytes directly + return arg.encode('latin-1') except UnicodeError: - raise ValueError('Cannot represent %r as binary.' % arg) - return xmlrpc.client.Binary(arg) - - def _handle_bytes(self, arg): - return xmlrpc.client.Binary(arg) + raise ValueError(f'Cannot represent {arg!r} as binary.') def _pass_through(self, arg): return arg @@ -147,7 +139,7 @@ def _coerce_list(self, arg): return [self.coerce(item) for item in arg] def _coerce_dict(self, arg): - return dict((self._to_key(key), self.coerce(arg[key])) for key in arg) + return {self._to_key(key): self.coerce(arg[key]) for key in arg} def _to_key(self, item): item = self._to_string(item) @@ -159,15 +151,15 @@ def _to_string(self, item): return self._handle_string(item) def _validate_key(self, key): - if isinstance(key, xmlrpc.client.Binary): - raise ValueError('Dictionary keys cannot be binary. Got %r.' % (key.data,)) + if isinstance(key, bytes): + raise ValueError(f'Dictionary keys cannot be binary. Got {key!r}.') class RemoteResult: def __init__(self, result): if not (is_dict_like(result) and 'status' in result): - raise RuntimeError('Invalid remote result dictionary: %s' % result) + raise RuntimeError(f'Invalid remote result dictionary: {result!r}') self.status = result['status'] self.output = safe_str(self._get(result, 'output')) self.return_ = self._get(result, 'return') @@ -181,8 +173,6 @@ def _get(self, result, key, default=''): return self._convert(value) def _convert(self, value): - if isinstance(value, xmlrpc.client.Binary): - return bytes(value.data) if is_dict_like(value): return DotDict((k, self._convert(v)) for k, v in value.items()) if is_list_like(value): @@ -204,6 +194,7 @@ def _server(self): else: transport = TimeoutHTTPTransport(timeout=self.timeout) server = xmlrpc.client.ServerProxy(self.uri, encoding='UTF-8', + use_builtin_types=True, transport=transport) try: yield server @@ -244,12 +235,12 @@ def run_keyword(self, name, args, kwargs): except xmlrpc.client.Fault as err: message = err.faultString except socket.error as err: - message = 'Connection to remote server broken: %s' % err + message = f'Connection to remote server broken: {err}' except ExpatError as err: - message = ('Processing XML-RPC return value failed. ' - 'Most often this happens when the return value ' - 'contains characters that are not valid in XML. ' - 'Original error was: ExpatError: %s' % err) + message = (f'Processing XML-RPC return value failed. ' + f'Most often this happens when the return value ' + f'contains characters that are not valid in XML. ' + f'Original error was: ExpatError: {err}') raise RuntimeError(message) @@ -259,11 +250,9 @@ def run_keyword(self, name, args, kwargs): class TimeoutHTTPTransport(xmlrpc.client.Transport): _connection_class = http.client.HTTPConnection - def __init__(self, use_datetime=0, timeout=None): - xmlrpc.client.Transport.__init__(self, use_datetime) - if not timeout: - timeout = socket._GLOBAL_DEFAULT_TIMEOUT - self.timeout = timeout + def __init__(self, timeout=None): + super().__init__(use_builtin_types=True) + self.timeout = timeout or socket._GLOBAL_DEFAULT_TIMEOUT def make_connection(self, host): if self._connection and host == self._connection[0]: From 922f0a12a3b33548c6a757014922707edfe0049e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 14 Sep 2023 17:59:07 +0300 Subject: [PATCH 0699/1592] Remote: Enhance `datetime`, `date` and `timedelta` conversion. - Pass `datetime` as-is because it's supported by XML-RPC natively. - Convert `date` to `datetime` that's then handled automatically. - Convert `timedelta` to seconds a `float`. - Test return value conversion. The previous commit already enabled `use_builtin_types` that causes returned date-times to be represented as standard `datetime` objects. Fixes #4784. --- .../remote/argument_coersion.robot | 9 +++++ .../remote/return_values.robot | 26 ++++++++++++++ .../remote/argument_coersion.robot | 9 +++++ .../standard_libraries/remote/arguments.py | 1 + .../remote/return_values.robot | 34 +++++++++++++++++++ .../standard_libraries/remote/returnvalues.py | 32 +++++++++++++++++ src/robot/libraries/Remote.py | 17 +++++++++- 7 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 atest/robot/standard_libraries/remote/return_values.robot create mode 100644 atest/testdata/standard_libraries/remote/return_values.robot create mode 100644 atest/testdata/standard_libraries/remote/returnvalues.py diff --git a/atest/robot/standard_libraries/remote/argument_coersion.robot b/atest/robot/standard_libraries/remote/argument_coersion.robot index 4cfc2c39d37..e95a8e3feee 100644 --- a/atest/robot/standard_libraries/remote/argument_coersion.robot +++ b/atest/robot/standard_libraries/remote/argument_coersion.robot @@ -33,6 +33,15 @@ Boolean None Check Test Case ${TESTNAME} +Datetime + Check Test Case ${TESTNAME} + +Date + Check Test Case ${TESTNAME} + +Timedelta + Check Test Case ${TESTNAME} + Custom object Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/remote/return_values.robot b/atest/robot/standard_libraries/remote/return_values.robot new file mode 100644 index 00000000000..45c9170692f --- /dev/null +++ b/atest/robot/standard_libraries/remote/return_values.robot @@ -0,0 +1,26 @@ +*** Settings *** +Documentation Note that returning binary/bytes is tested in `binary_result.robot`. +Suite Setup Run Remote Tests return_values.robot returnvalues.py +Resource remote_resource.robot + +*** Test Cases *** +String + Check Test Case ${TEST NAME} + +Integer + Check Test Case ${TEST NAME} + +Float + Check Test Case ${TEST NAME} + +Boolean + Check Test Case ${TEST NAME} + +Datetime + Check Test Case ${TEST NAME} + +List + Check Test Case ${TEST NAME} + +Dict + Check Test Case ${TEST NAME} diff --git a/atest/testdata/standard_libraries/remote/argument_coersion.robot b/atest/testdata/standard_libraries/remote/argument_coersion.robot index 43f7f0a751f..2e6cc1aefc3 100644 --- a/atest/testdata/standard_libraries/remote/argument_coersion.robot +++ b/atest/testdata/standard_libraries/remote/argument_coersion.robot @@ -58,6 +58,15 @@ Boolean None None '' +Datetime + datetime.datetime(2023, 9, 12, 16, 8) datetime(2023, 9, 12, 16, 8) + +Date + datetime.date(2023, 9, 12) datetime(2023, 9, 12) + +Timedelta + datetime.timedelta(seconds=3.14) 3.14 + Custom object [Documentation] Arbitrary objects cannot be transferred over XML-RPC and thus only their string presentation is used MyObject() '' diff --git a/atest/testdata/standard_libraries/remote/arguments.py b/atest/testdata/standard_libraries/remote/arguments.py index 349d0acba47..d5c1d71fe5f 100644 --- a/atest/testdata/standard_libraries/remote/arguments.py +++ b/atest/testdata/standard_libraries/remote/arguments.py @@ -1,5 +1,6 @@ import sys +from datetime import datetime # Needed by `eval()`. from xmlrpc.client import Binary from remoteserver import RemoteServer, keyword diff --git a/atest/testdata/standard_libraries/remote/return_values.robot b/atest/testdata/standard_libraries/remote/return_values.robot new file mode 100644 index 00000000000..daae87a0bf9 --- /dev/null +++ b/atest/testdata/standard_libraries/remote/return_values.robot @@ -0,0 +1,34 @@ +*** Settings *** +Test Template Argument Should Be Returned Correctly +Library Remote 127.0.0.1:${PORT} + +*** Test Cases *** +String + 'Hyvä tulos!' + +Integer + 42 + +Float + 3.14 + +Boolean + False + +Datetime + datetime.datetime(2023, 9, 14, 17, 30, 23) + +List + \[1, 2, 'lolme'] + +Dict + {'a': 1, 'b': [2, 3]} + + +*** Keywords *** +Argument Should Be Returned Correctly + [Arguments] ${expected} + ${expected} = Evaluate ${expected} + ${result} = Run Keyword ${TEST NAME} + Should Be Equal ${result} ${expected} + Should Be True isinstance($result, type($expected)) Result type ${{type($result)}} is wrong. diff --git a/atest/testdata/standard_libraries/remote/returnvalues.py b/atest/testdata/standard_libraries/remote/returnvalues.py new file mode 100644 index 00000000000..229992dcfb8 --- /dev/null +++ b/atest/testdata/standard_libraries/remote/returnvalues.py @@ -0,0 +1,32 @@ +import datetime +import sys + +from remoteserver import RemoteServer + + +class ReturnValues: + + def string(self): + return 'Hyvä tulos!' + + def integer(self): + return 42 + + def float(self): + return 3.14 + + def boolean(self): + return False + + def datetime(self): + return datetime.datetime(2023, 9, 14, 17, 30, 23) + + def list(self): + return [1, 2, 'lolme'] + + def dict(self): + return {'a': 1, 'b': [2, 3]} + + +if __name__ == '__main__': + RemoteServer(ReturnValues(), *sys.argv[1:]) diff --git a/src/robot/libraries/Remote.py b/src/robot/libraries/Remote.py index 2f359d10c18..72cad8e3833 100644 --- a/src/robot/libraries/Remote.py +++ b/src/robot/libraries/Remote.py @@ -20,6 +20,7 @@ import socket import sys import xmlrpc.client +from datetime import date, datetime, timedelta from xml.parsers.expat import ExpatError from robot.errors import RemoteError @@ -111,6 +112,8 @@ class ArgumentCoercer: def coerce(self, argument): for handles, handler in [(is_string, self._handle_string), (self._no_conversion_needed, self._pass_through), + (self._is_date, self._handle_date), + (self._is_timedelta, self._handle_timedelta), (is_dict_like, self._coerce_dict), (is_list_like, self._coerce_list)]: if handles(argument): @@ -118,7 +121,7 @@ def coerce(self, argument): return self._to_string(argument) def _no_conversion_needed(self, arg): - return is_number(arg) or is_bytes(arg) + return is_number(arg) or is_bytes(arg) or isinstance(arg, datetime) def _handle_string(self, arg): if self.binary.search(arg): @@ -135,6 +138,18 @@ def _handle_binary_in_string(self, arg): def _pass_through(self, arg): return arg + def _is_date(self, arg): + return isinstance(arg, date) + + def _handle_date(self, arg): + return datetime(arg.year, arg.month, arg.day) + + def _is_timedelta(self, arg): + return isinstance(arg, timedelta) + + def _handle_timedelta(self, arg): + return arg.total_seconds() + def _coerce_list(self, arg): return [self.coerce(item) for item in arg] From 9e3a7d1ab2b8339fba70b950f9a12decbfae6aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 14 Sep 2023 21:53:27 +0300 Subject: [PATCH 0700/1592] Remove last deprecated items fron Libdoc specs. In JSON each argument still had `types` and `typedocs` that were deprecated as part of #4538. Nowadays `type` has all this information. Everything else that has been deprecated seems to be removed so this fixes #4667. Also made Libdoc schema more script by not accepting extra attributes. --- atest/testdata/libdoc/DataTypesLibrary.json | 105 +------------------- atest/testdata/libdoc/DynamicLibrary.json | 102 +------------------ doc/schema/libdoc.json | 34 +++---- doc/schema/libdoc_json_schema.py | 15 +-- src/robot/libdocpkg/jsonbuilder.py | 6 +- src/robot/libdocpkg/model.py | 2 - src/robot/libdocpkg/xmlwriter.py | 1 - 7 files changed, 31 insertions(+), 234 deletions(-) diff --git a/atest/testdata/libdoc/DataTypesLibrary.json b/atest/testdata/libdoc/DataTypesLibrary.json index 3346ac00a9a..db7fef58a53 100644 --- a/atest/testdata/libdoc/DataTypesLibrary.json +++ b/atest/testdata/libdoc/DataTypesLibrary.json @@ -3,7 +3,7 @@ "name": "DataTypesLibrary", "doc": "

This Library has Data Types.

\n

It has some in __init__ and others in the Keywords.

\n

The DataTypes are the following that should be linked. HttpCredentials , GeoLocation , Small and AssertionOperator.

", "version": "", - "generated": "2023-02-27T14:41:19+00:00", + "generated": "2023-09-14T19:27:00+00:00", "type": "LIBRARY", "scope": "TEST", "docFormat": "HTML", @@ -22,12 +22,6 @@ "nested": [], "union": false }, - "types": [ - "Small" - ], - "typedocs": { - "Small": "Small" - }, "defaultValue": "one", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -48,8 +42,6 @@ { "name": "value", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -76,14 +68,6 @@ ], "union": true }, - "types": [ - "AssertionOperator", - "None" - ], - "typedocs": { - "AssertionOperator": "AssertionOperator", - "None": "None" - }, "defaultValue": "None", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -97,12 +81,6 @@ "nested": [], "union": false }, - "types": [ - "str" - ], - "typedocs": { - "str": "string" - }, "defaultValue": "something?", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -126,12 +104,6 @@ "nested": [], "union": false }, - "types": [ - "CustomType" - ], - "typedocs": { - "CustomType": "CustomType" - }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -145,12 +117,6 @@ "nested": [], "union": false }, - "types": [ - "CustomType2" - ], - "typedocs": { - "CustomType2": "CustomType2" - }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -164,12 +130,6 @@ "nested": [], "union": false }, - "types": [ - "CustomType" - ], - "typedocs": { - "CustomType": "CustomType" - }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -183,10 +143,6 @@ "nested": [], "union": false }, - "types": [ - "Unknown" - ], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -259,26 +215,6 @@ ], "union": true }, - "types": [ - "bool", - "int", - "float", - "str", - "AssertionOperator", - "Small", - "GeoLocation", - "None" - ], - "typedocs": { - "bool": "boolean", - "int": "integer", - "float": "float", - "str": "string", - "AssertionOperator": "AssertionOperator", - "Small": "Small", - "GeoLocation": "GeoLocation", - "None": "None" - }, "defaultValue": "equal", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -302,12 +238,6 @@ "nested": [], "union": false }, - "types": [ - "GeoLocation" - ], - "typedocs": { - "GeoLocation": "GeoLocation" - }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -338,13 +268,6 @@ ], "union": false }, - "types": [ - "List[str]" - ], - "typedocs": { - "List": "list", - "str": "string" - }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -371,14 +294,6 @@ ], "union": false }, - "types": [ - "Dict[str, int]" - ], - "typedocs": { - "Dict": "dictionary", - "str": "string", - "int": "integer" - }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -392,12 +307,6 @@ "nested": [], "union": false }, - "types": [ - "Any" - ], - "typedocs": { - "Any": "Any" - }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -418,13 +327,6 @@ ], "union": false }, - "types": [ - "List[Any]" - ], - "typedocs": { - "List": "list", - "Any": "Any" - }, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -556,7 +458,8 @@ "Set Location" ], "accepts": [ - "string" + "string", + "Mapping" ], "items": [ { @@ -658,4 +561,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/atest/testdata/libdoc/DynamicLibrary.json b/atest/testdata/libdoc/DynamicLibrary.json index 86d8dc08127..3315c406537 100644 --- a/atest/testdata/libdoc/DynamicLibrary.json +++ b/atest/testdata/libdoc/DynamicLibrary.json @@ -3,7 +3,7 @@ "name": "DynamicLibrary", "doc": "

Dummy documentation for __intro__.

", "version": "0.1", - "generated": "2023-02-27T15:47:24+00:00", + "generated": "2023-09-14T19:25:28+00:00", "type": "LIBRARY", "scope": "TEST", "docFormat": "HTML", @@ -22,8 +22,6 @@ { "name": "arg1", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -32,8 +30,6 @@ { "name": "arg2", "type": null, - "types": [], - "typedocs": {}, "defaultValue": "These args are shown in docs", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -63,8 +59,6 @@ { "name": "old", "type": null, - "types": [], - "typedocs": {}, "defaultValue": "style", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -73,8 +67,6 @@ { "name": "new", "type": null, - "types": [], - "typedocs": {}, "defaultValue": "style", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -83,8 +75,6 @@ { "name": "cool", "type": null, - "types": [], - "typedocs": {}, "defaultValue": "True", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -121,8 +111,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -131,8 +119,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -151,8 +137,6 @@ { "name": "arg1", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -171,8 +155,6 @@ { "name": "", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "NAMED_ONLY_MARKER", "required": false, @@ -181,8 +163,6 @@ { "name": "kwo", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "NAMED_ONLY", "required": true, @@ -191,8 +171,6 @@ { "name": "another", "type": null, - "types": [], - "typedocs": {}, "defaultValue": "default", "kind": "NAMED_ONLY", "required": false, @@ -211,8 +189,6 @@ { "name": "arg1", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -221,8 +197,6 @@ { "name": "arg2", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -241,8 +215,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -251,8 +223,6 @@ { "name": "a", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "NAMED_ONLY", "required": true, @@ -261,8 +231,6 @@ { "name": "b", "type": null, - "types": [], - "typedocs": {}, "defaultValue": "2", "kind": "NAMED_ONLY", "required": false, @@ -271,8 +239,6 @@ { "name": "c", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "NAMED_ONLY", "required": true, @@ -281,8 +247,6 @@ { "name": "kws", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -301,8 +265,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -311,8 +273,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -331,8 +291,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -341,8 +299,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -361,8 +317,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -371,8 +325,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -391,8 +343,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -401,8 +351,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -424,8 +372,6 @@ { "name": "arg1", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -434,8 +380,6 @@ { "name": "arg2", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -444,8 +388,6 @@ { "name": "arg3", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -454,8 +396,6 @@ { "name": "arg4", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -464,8 +404,6 @@ { "name": "arg5", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -474,8 +412,6 @@ { "name": "arg6", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -484,8 +420,6 @@ { "name": "arg7", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -494,8 +428,6 @@ { "name": "arg8", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -517,8 +449,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -527,8 +457,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -547,8 +475,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -557,8 +483,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -577,8 +501,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -587,8 +509,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -607,8 +527,6 @@ { "name": "varargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_POSITIONAL", "required": false, @@ -617,8 +535,6 @@ { "name": "kwargs", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "VAR_NAMED", "required": false, @@ -645,12 +561,6 @@ "nested": [], "union": false }, - "types": [ - "int" - ], - "typedocs": { - "int": "integer" - }, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -659,8 +569,6 @@ { "name": "no type", "type": null, - "types": [], - "typedocs": {}, "defaultValue": null, "kind": "POSITIONAL_OR_NAMED", "required": true, @@ -674,12 +582,6 @@ "nested": [], "union": false }, - "types": [ - "bool" - ], - "typedocs": { - "bool": "boolean" - }, "defaultValue": "True", "kind": "POSITIONAL_OR_NAMED", "required": false, @@ -721,4 +623,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/doc/schema/libdoc.json b/doc/schema/libdoc.json index 7e0e5c3d788..b2666df6828 100644 --- a/doc/schema/libdoc.json +++ b/doc/schema/libdoc.json @@ -88,6 +88,7 @@ "keywords", "typedocs" ], + "additionalProperties": false, "$schema": "https://json-schema.org/draft/2020-12/schema", "definitions": { "SpecVersion": { @@ -165,7 +166,8 @@ "name", "nested", "union" - ] + ], + "additionalProperties": false }, "ArgumentKind": { "title": "ArgumentKind", @@ -201,19 +203,6 @@ } ] }, - "types": { - "title": "Types", - "description": "Deprecated. Use 'type' instead.", - "type": "array", - "items": { - "type": "string" - } - }, - "typedocs": { - "title": "Typedocs", - "description": "Deprecated. Use 'type' instead.", - "type": "object" - }, "defaultValue": { "title": "Defaultvalue", "description": "Possible default value or 'null'.", @@ -240,12 +229,11 @@ }, "required": [ "name", - "types", - "typedocs", "kind", "required", "repr" - ] + ], + "additionalProperties": false }, "Keyword": { "title": "Keyword", @@ -317,7 +305,8 @@ "tags", "source", "lineno" - ] + ], + "additionalProperties": false }, "TypeDocType": { "title": "TypeDocType", @@ -346,7 +335,8 @@ "required": [ "name", "value" - ] + ], + "additionalProperties": false }, "TypedDictItem": { "title": "TypedDictItem", @@ -375,7 +365,8 @@ "required": [ "key", "type" - ] + ], + "additionalProperties": false }, "TypeDoc": { "title": "TypeDoc", @@ -445,7 +436,8 @@ "doc", "usages", "accepts" - ] + ], + "additionalProperties": false } } } \ No newline at end of file diff --git a/doc/schema/libdoc_json_schema.py b/doc/schema/libdoc_json_schema.py index 838f69cfe99..de125c53367 100755 --- a/doc/schema/libdoc_json_schema.py +++ b/doc/schema/libdoc_json_schema.py @@ -13,14 +13,17 @@ from pathlib import Path from typing import List, Optional, Union -from pydantic import BaseModel as PydanticBaseModel, Field, PositiveInt +from pydantic import BaseModel as PydanticBaseModel, Extra, Field, PositiveInt class BaseModel(PydanticBaseModel): - # Workaround for Pydantic not supporting nullable types. - # https://github.com/pydantic/pydantic/issues/1270#issuecomment-729555558 class Config: + # Do not allow extra attributes. + extra = Extra.forbid + + # Workaround for Pydantic not supporting nullable types. + # https://github.com/pydantic/pydantic/issues/1270#issuecomment-729555558 @staticmethod def schema_extra(schema, model): for prop, value in schema.get('properties', {}).items(): @@ -88,8 +91,6 @@ class Argument(BaseModel): """Keyword argument.""" name: str type: Union[ArgumentType, None] - types: List[str] = Field(description="Deprecated. Use 'type' instead.") - typedocs: dict = Field(description="Deprecated. Use 'type' instead.") defaultValue: Union[str, None] = Field(description="Possible default value or 'null'.") kind: ArgumentKind required: bool @@ -157,9 +158,9 @@ class Libdoc(BaseModel): keywords: List[Keyword] typedocs: List[TypeDoc] - # pydantic doesn't add schema version automatically. - # https://github.com/samuelcolvin/pydantic/issues/1478 class Config: + # pydantic doesn't add schema version automatically. + # https://github.com/samuelcolvin/pydantic/issues/1478 schema_extra = { '$schema': 'https://json-schema.org/draft/2020-12/schema' } diff --git a/src/robot/libdocpkg/jsonbuilder.py b/src/robot/libdocpkg/jsonbuilder.py index 9062cd98dc9..673b306ceed 100644 --- a/src/robot/libdocpkg/jsonbuilder.py +++ b/src/robot/libdocpkg/jsonbuilder.py @@ -83,10 +83,10 @@ def _create_arguments(self, arguments, kw: KeywordDoc): default = arg.get('defaultValue') if default is not None: spec.defaults[name] = default - if arg.get('type'): + if 'type' in arg: # RF >= 6.1 type_docs = {} type_info = self._parse_modern_type_info(arg['type'], type_docs) - else: # Compatibility with RF < 6.1. + else: # RF < 6.1 type_docs = arg.get('typedocs', {}) type_info = tuple(arg['types']) if type_info: @@ -96,6 +96,8 @@ def _create_arguments(self, arguments, kw: KeywordDoc): kw.type_docs[name] = type_docs def _parse_modern_type_info(self, data, type_docs): + if not data: + return {} if data.get('typedoc'): type_docs[data['name']] = data['typedoc'] return {'name': data['name'], diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 4f43540ac55..c992eeb7103 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -196,8 +196,6 @@ def _arg_to_dict(self, arg: ArgInfo): return { 'name': arg.name, 'type': self._type_to_dict(arg.type, type_docs), - 'types': arg.types_reprs, - 'typedocs': type_docs, 'defaultValue': arg.default_repr, 'kind': arg.kind, 'required': arg.required, diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index b5795453f5f..e7dad69fab4 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -26,7 +26,6 @@ def write(self, libdoc, outfile): self._write_start(libdoc, writer) self._write_keywords('inits', 'init', libdoc.inits, libdoc.source, writer) self._write_keywords('keywords', 'kw', libdoc.keywords, libdoc.source, writer) - # Write new '' element. self._write_type_docs(libdoc.type_docs, writer) self._write_end(writer) From 3b8fbf83f93b09fc966ff269aee58b11dd63871c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 14 Sep 2023 22:17:39 +0300 Subject: [PATCH 0701/1592] Remove deprecated internal code and unused import. --- src/robot/libdocpkg/builder.py | 8 -------- utest/libdoc/test_libdoc.py | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/robot/libdocpkg/builder.py b/src/robot/libdocpkg/builder.py index 6ffe5b59253..d604f8b51f6 100644 --- a/src/robot/libdocpkg/builder.py +++ b/src/robot/libdocpkg/builder.py @@ -71,14 +71,6 @@ class DocumentationBuilder: instead. """ - def __init__(self, library_or_resource=None): - """`library_or_resource` is accepted for backwards compatibility reasons. - - It is not used for anything internally and passing it to the builder is - considered deprecated starting from RF 6.0.1. - """ - pass - def build(self, source): # Source can contain arguments separated with `::` so we cannot convert # it to Path and instead need to make sure it's a string. It would be diff --git a/utest/libdoc/test_libdoc.py b/utest/libdoc/test_libdoc.py index 6551884d4bd..00e58d1ee0c 100644 --- a/utest/libdoc/test_libdoc.py +++ b/utest/libdoc/test_libdoc.py @@ -10,7 +10,7 @@ from robot.utils.asserts import assert_equal from robot.libdocpkg import LibraryDocumentation from robot.libdocpkg.model import LibraryDoc, KeywordDoc -from robot.libdocpkg.htmlutils import HtmlToText, DocToHtml +from robot.libdocpkg.htmlutils import HtmlToText get_shortdoc = HtmlToText().get_shortdoc_from_html get_text = HtmlToText().html_to_plain_text From 6ab62b793118f762b8ab6a812979cac80736a3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 14 Sep 2023 22:52:09 +0300 Subject: [PATCH 0702/1592] Nicer formatting of nested types in Libdoc XML specs --- atest/testdata/libdoc/DataTypesLibrary.xml | 136 ++++++++++----------- src/robot/libdocpkg/xmlwriter.py | 13 +- 2 files changed, 71 insertions(+), 78 deletions(-) diff --git a/atest/testdata/libdoc/DataTypesLibrary.xml b/atest/testdata/libdoc/DataTypesLibrary.xml index d8ee629bf6f..59a34484ead 100644 --- a/atest/testdata/libdoc/DataTypesLibrary.xml +++ b/atest/testdata/libdoc/DataTypesLibrary.xml @@ -1,5 +1,5 @@ - + This Library has Data Types. @@ -10,11 +10,11 @@ The DataTypes are the following that should be linked. - + credentials -Small + one @@ -25,142 +25,123 @@ It links to `Set Location` keyword and to `GeoLocation` data type. - + value operator -AssertionOperator -None + + + + None exp -str + something? This links to `AssertionOperator` . -This is the next Line that links to 'Set Location` . +This is the next Line that links to `Set Location` . This links to `AssertionOperator` . - - + + arg -CustomType + arg2 -CustomType2 + arg3 -CustomType + + + +arg4 + - + funny -bool -int -float -str -AssertionOperator -Small -GeoLocation -None + + + + + + + + + + equal - + location -GeoLocation + - + list_of_str -List[str] + + + dict_str_int -Dict[str, int] + + + + whatever -Any + args -List[Any] + + + - - - -This is some Doc - -This has was defined by assigning to __doc__. - - - - - - - - - - -This is the Documentation. - -This was defined within the class definition. - - - - - - - - - - -Defines the geolocation. - -- ``latitude`` Latitude between -90 and 90. -- ``longitude`` Longitude between -180 and 180. -- ``accuracy`` *Optional* Non-negative accuracy value. Defaults to 0. - -Example usage: ``{'latitude': 59.95, 'longitude': 30.31667}`` - - - - - - - - + +Any value is accepted. No conversion is done. + + +Any + + +Typing Types + + This is some Doc @@ -227,6 +208,9 @@ literals. They are converted to actual dictionaries using the function. They can contain any values ``ast.literal_eval`` supports, including dictionaries and other containers. +If the type has nested types like ``dict[str, int]``, items are converted +to those types automatically. This in new in Robot Framework 6.0. + Examples: ``{'a': 1, 'b': 2}``, ``{'key': 1, 'nested': {'key': 2}}`` @@ -264,6 +248,7 @@ Examples: ``3.14``, ``2.9979e8``, ``10 000.000 01`` Example usage: ``{'latitude': 59.95, 'longitude': 30.31667}`` string +Mapping Funny Unions @@ -295,6 +280,7 @@ Examples: ``42``, ``-1``, ``0b1010``, ``10 000 000``, ``0xBAD_C0FFEE`` Funny Unions +Typing Types @@ -304,6 +290,9 @@ literals. They are converted to actual lists using the function. They can contain any values ``ast.literal_eval`` supports, including lists and other containers. +If the type has nested types like ``list[int]``, items are converted +to those types automatically. This in new in Robot Framework 6.0. + Examples: ``['one', 'two']``, ``[('one', 1), ('two', 2)]`` @@ -353,6 +342,7 @@ This was defined within the class definition. Assert Something Funny Unions +Typing Types diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index e7dad69fab4..d94dad0e8b9 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -82,16 +82,19 @@ def _write_arguments(self, kw, writer): writer.end('arg') writer.end('arguments') - def _write_type_info(self, type_info: TypeInfo, type_docs: dict, writer, top=True): + def _write_type_info(self, type_info: TypeInfo, type_docs: dict, writer): attrs = {'name': type_info.name} if type_info.is_union: attrs['union'] = 'true' if type_info.name in type_docs: attrs['typedoc'] = type_docs[type_info.name] - writer.start('type', attrs) - for nested in type_info.nested: - self._write_type_info(nested, type_docs, writer, top=False) - writer.end('type', newline=top) + if type_info.nested: + writer.start('type', attrs) + for nested in type_info.nested: + self._write_type_info(nested, type_docs, writer) + writer.end('type') + else: + writer.element('type', attrs=attrs) def _get_start_attrs(self, kw, lib_source): attrs = {'name': kw.name} From e81977bfcd5609f7d8c504f04449bfcd84e557d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 14 Sep 2023 23:02:17 +0300 Subject: [PATCH 0703/1592] Add user keyword teardown to running model JSON schema. Also make the schema more script so that no object accepts extra attributes. Fixes #4870. --- doc/schema/running.json | 51 +++++++++++++++++++++---------- doc/schema/running_json_schema.py | 12 ++++++-- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/doc/schema/running.json b/doc/schema/running.json index b1d565ef620..f216909f436 100644 --- a/doc/schema/running.json +++ b/doc/schema/running.json @@ -34,7 +34,8 @@ }, "required": [ "name" - ] + ], + "additionalProperties": false }, "Error": { "title": "Error", @@ -65,7 +66,8 @@ "required": [ "error", "values" - ] + ], + "additionalProperties": false }, "Break": { "title": "Break", @@ -85,7 +87,8 @@ "const": "BREAK", "type": "string" } - } + }, + "additionalProperties": false }, "Continue": { "title": "Continue", @@ -105,7 +108,8 @@ "const": "CONTINUE", "type": "string" } - } + }, + "additionalProperties": false }, "Return": { "title": "Return", @@ -135,7 +139,8 @@ }, "required": [ "values" - ] + ], + "additionalProperties": false }, "TryBranch": { "title": "TryBranch", @@ -213,7 +218,8 @@ "required": [ "type", "body" - ] + ], + "additionalProperties": false }, "Try": { "title": "Try", @@ -243,7 +249,8 @@ }, "required": [ "body" - ] + ], + "additionalProperties": false }, "IfBranch": { "title": "IfBranch", @@ -309,7 +316,8 @@ "required": [ "type", "body" - ] + ], + "additionalProperties": false }, "If": { "title": "If", @@ -339,7 +347,8 @@ }, "required": [ "body" - ] + ], + "additionalProperties": false }, "While": { "title": "While", @@ -413,7 +422,8 @@ }, "required": [ "body" - ] + ], + "additionalProperties": false }, "For": { "title": "For", @@ -504,7 +514,8 @@ "flavor", "values", "body" - ] + ], + "additionalProperties": false }, "TestCase": { "title": "TestCase", @@ -577,7 +588,8 @@ "required": [ "name", "body" - ] + ], + "additionalProperties": false }, "Import": { "title": "Import", @@ -615,7 +627,8 @@ "required": [ "type", "name" - ] + ], + "additionalProperties": false }, "Variable": { "title": "Variable", @@ -644,7 +657,8 @@ "required": [ "name", "value" - ] + ], + "additionalProperties": false }, "UserKeyword": { "title": "UserKeyword", @@ -691,6 +705,9 @@ "title": "Error", "type": "string" }, + "teardown": { + "$ref": "#/definitions/Keyword" + }, "body": { "title": "Body", "type": "array", @@ -724,7 +741,8 @@ "required": [ "name", "body" - ] + ], + "additionalProperties": false }, "Resource": { "title": "Resource", @@ -760,7 +778,8 @@ "$ref": "#/definitions/UserKeyword" } } - } + }, + "additionalProperties": false }, "TestSuite": { "title": "TestSuite", diff --git a/doc/schema/running_json_schema.py b/doc/schema/running_json_schema.py index b49be48ce22..beeb9534882 100755 --- a/doc/schema/running_json_schema.py +++ b/doc/schema/running_json_schema.py @@ -12,7 +12,14 @@ from pathlib import Path from typing import Literal -from pydantic import BaseModel, Extra, Field +from pydantic import BaseModel as PydanticBaseModel, Extra, Field + + +class BaseModel(PydanticBaseModel): + + class Config: + # Do not allow extra attributes. + extra = Extra.forbid class BodyItem(BaseModel): @@ -119,8 +126,6 @@ class TestSuite(BaseModel): resource: 'Resource | None' class Config: - # Do not allow extra attributes. - extra = Extra.forbid # pydantic doesn't add schema version automatically. # https://github.com/samuelcolvin/pydantic/issues/1478 schema_extra = { @@ -152,6 +157,7 @@ class UserKeyword(BaseModel): timeout: str | None lineno: int | None error: str | None + teardown: Keyword | None body: list[Keyword | For | While | If | Try | Error | Return] From 67482cb5768b1dfa3861715911ef15f90ecfda00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 15 Sep 2023 00:17:14 +0300 Subject: [PATCH 0704/1592] Dev requirements: Require Pydantic 1.x and Sphinx Our JSON schema definitions aren't compatible with Pydantic 2 and some of the changes seem to cause issues. Let's keep using Pydantic 1 at least for now. Also require Sphinx because it's needed with API docs. --- doc/schema/libdoc_json_schema.py | 6 +++--- doc/schema/running_json_schema.py | 4 ++-- requirements-dev.txt | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/doc/schema/libdoc_json_schema.py b/doc/schema/libdoc_json_schema.py index de125c53367..3a03028379b 100755 --- a/doc/schema/libdoc_json_schema.py +++ b/doc/schema/libdoc_json_schema.py @@ -2,10 +2,10 @@ """Libdoc JSON schema model definition. -The schema is modeled using pydantic in this file. After updating the model, -execute this file to regenerate the actual schema file in libdoc.json. +The schema is modeled using Pydantic in this file. After updating the model, +execute this file to regenerate the actual schema file in ``libdoc.json``. -https://pydantic-docs.helpmanual.io/usage/schema/ +Requires Pydantic 1.10. https://docs.pydantic.dev/1.10/ """ from datetime import datetime diff --git a/doc/schema/running_json_schema.py b/doc/schema/running_json_schema.py index beeb9534882..3ac83743586 100755 --- a/doc/schema/running_json_schema.py +++ b/doc/schema/running_json_schema.py @@ -2,10 +2,10 @@ """JSON schema for ``robot.running.TestSuite`` model structure. -The schema is modeled using pydantic in this file. After updating the model, +The schema is modeled using Pydantic in this file. After updating the model, execute this file to regenerate the actual schema file in ``running.json``. -https://pydantic-docs.helpmanual.io/usage/schema/ +Requires Pydantic 1.10. https://docs.pydantic.dev/1.10/ """ from collections.abc import Sequence diff --git a/requirements-dev.txt b/requirements-dev.txt index 67b837aa8f9..4cc9de672ee 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,5 @@ twine >= 1.12 wheel docutils pygments >= 2.8 -pydantic +sphinx +pydantic < 2 From 50751e7b53f9020f19f863128cced9da04e2a221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 15 Sep 2023 12:36:55 +0300 Subject: [PATCH 0705/1592] Small enhancements to visitor documentation --- src/robot/model/visitor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 34abec88224..4c0119a92de 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -80,13 +80,13 @@ Visitor methods have type hints to give more information about the model objects they receive to editors. Because visitors can be used with both running and result -models, the types that are used are base classes from the :mod:`robot.model` -module. Actual visitors may want to import appropriate types from -:mod:`robot.running.model` or from :mod:`robot.result.model` modules instead. -For example, this code that prints failed tests uses result side model objects:: +models, the types that are used as type hints are base classes from the +:mod:`robot.model` module. Actual visitor implementations can import appropriate +types from the :mod:`robot.running` or the :mod:`robot.result` module instead. +For example, this visitor uses the result side model objects:: from robot.api import SuiteVisitor - from robot.result.model import TestCase, TestSuite + from robot.result import TestCase, TestSuite class FailurePrinter(SuiteVisitor): From 819136ab52aa8dd89deae3b825c22ab2c2feb867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 18 Sep 2023 10:11:53 +0300 Subject: [PATCH 0706/1592] Remove dead code --- src/robot/running/arguments/argumentspec.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index bd2ba967dc0..93ce4f57fb1 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -140,15 +140,6 @@ def required(self): return self.default is self.NOTSET return False - @property - def types_reprs(self): - """Deprecated. Use :attr:`type` instead.""" - if not self.type: - return [] - if self.type.is_union: - return [str(t) for t in self.type.nested] - return [str(self.type)] - @property def default_repr(self): if self.default is self.NOTSET: From e34b370fbc3550b2d9ac4040a3b27bf00142f144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 18 Sep 2023 13:56:19 +0300 Subject: [PATCH 0707/1592] Introduce `robot.utils.NOT_SET` constant. Can be used as a default value in cases where the standard `None` is itself a valid value. --- atest/robot/libdoc/LibDocLib.py | 10 +++---- src/robot/libdocpkg/xmlwriter.py | 4 +-- src/robot/libraries/Collections.py | 11 ++----- src/robot/running/arguments/argumentspec.py | 31 +++++++++---------- src/robot/utils/__init__.py | 1 + src/robot/utils/notset.py | 33 +++++++++++++++++++++ src/robot/variables/store.py | 6 ++-- 7 files changed, 59 insertions(+), 37 deletions(-) create mode 100644 src/robot/utils/notset.py diff --git a/atest/robot/libdoc/LibDocLib.py b/atest/robot/libdoc/LibDocLib.py index cc3c3d61004..33cfab07bc6 100644 --- a/atest/robot/libdoc/LibDocLib.py +++ b/atest/robot/libdoc/LibDocLib.py @@ -9,7 +9,7 @@ from xmlschema import XMLSchema from robot.api import logger -from robot.utils import SYSTEM_ENCODING +from robot.utils import NOT_SET, SYSTEM_ENCODING from robot.running.arguments import ArgInfo @@ -66,11 +66,11 @@ def validate_json_spec(self, path): def get_repr_from_arg_model(self, model): return str(ArgInfo(kind=model['kind'], name=model['name'], - type=model['type'] or ArgInfo.NOTSET, - default=model['default'] or ArgInfo.NOTSET)) + type=model['type'] or NOT_SET, + default=model['default'] or NOT_SET)) def get_repr_from_json_arg_model(self, model): return str(ArgInfo(kind=model['kind'], name=model['name'], - type=model['type'] or ArgInfo.NOTSET, - default=model['defaultValue'] or ArgInfo.NOTSET)) + type=model['type'] or NOT_SET, + default=model['defaultValue'] or NOT_SET)) diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index d94dad0e8b9..db8ae164833 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -14,7 +14,7 @@ # limitations under the License. from robot.running import TypeInfo -from robot.utils import XmlWriter +from robot.utils import NOT_SET, XmlWriter from .output import get_generation_time @@ -77,7 +77,7 @@ def _write_arguments(self, kw, writer): writer.element('name', arg.name) if arg.type: self._write_type_info(arg.type, kw.type_docs[arg.name], writer) - if arg.default is not arg.NOTSET: + if arg.default is not NOT_SET: writer.element('default', arg.default_repr) writer.end('arg') writer.end('arguments') diff --git a/src/robot/libraries/Collections.py b/src/robot/libraries/Collections.py index 8ddce82960a..fcecc9c2cff 100644 --- a/src/robot/libraries/Collections.py +++ b/src/robot/libraries/Collections.py @@ -18,19 +18,12 @@ from robot.api import logger from robot.utils import (get_error_message, is_dict_like, is_list_like, is_truthy, - Matcher, plural_or_not as s, seq2str, seq2str2, type_name) + Matcher, NOT_SET, plural_or_not as s, seq2str, seq2str2, + type_name) from robot.utils.asserts import assert_equal from robot.version import get_version -class NotSet: - def __repr__(self): - return "" - - -NOT_SET = NotSet() - - class _List: def convert_to_list(self, item): diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index 93ce4f57fb1..0d865b85ba6 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -17,7 +17,7 @@ from enum import Enum from typing import Union, Tuple -from robot.utils import has_args, is_union, safe_str, setter, type_repr +from robot.utils import has_args, is_union, NOT_SET, safe_str, setter, type_repr from .argumentconverter import ArgumentConverter from .argumentmapper import ArgumentMapper @@ -84,28 +84,27 @@ def map(self, positional, named, replace_defaults=True): return mapper.map(positional, named, replace_defaults) def __iter__(self): - notset = ArgInfo.NOTSET get_type = (self.types or {}).get get_default = self.defaults.get for arg in self.positional_only: yield ArgInfo(ArgInfo.POSITIONAL_ONLY, arg, - get_type(arg, notset), get_default(arg, notset)) + get_type(arg, NOT_SET), get_default(arg, NOT_SET)) if self.positional_only: yield ArgInfo(ArgInfo.POSITIONAL_ONLY_MARKER) for arg in self.positional_or_named: yield ArgInfo(ArgInfo.POSITIONAL_OR_NAMED, arg, - get_type(arg, notset), get_default(arg, notset)) + get_type(arg, NOT_SET), get_default(arg, NOT_SET)) if self.var_positional: yield ArgInfo(ArgInfo.VAR_POSITIONAL, self.var_positional, - get_type(self.var_positional, notset)) + get_type(self.var_positional, NOT_SET)) elif self.named_only: yield ArgInfo(ArgInfo.NAMED_ONLY_MARKER) for arg in self.named_only: yield ArgInfo(ArgInfo.NAMED_ONLY, arg, - get_type(arg, notset), get_default(arg, notset)) + get_type(arg, NOT_SET), get_default(arg, NOT_SET)) if self.var_named: yield ArgInfo(ArgInfo.VAR_NAMED, self.var_named, - get_type(self.var_named, notset)) + get_type(self.var_named, NOT_SET)) def __bool__(self): return any([self.positional_only, self.positional_or_named, self.var_positional, @@ -117,7 +116,6 @@ def __str__(self): class ArgInfo: """Contains argument information. Only used by Libdoc.""" - NOTSET = object() POSITIONAL_ONLY = 'POSITIONAL_ONLY' POSITIONAL_ONLY_MARKER = 'POSITIONAL_ONLY_MARKER' POSITIONAL_OR_NAMED = 'POSITIONAL_OR_NAMED' @@ -126,7 +124,7 @@ class ArgInfo: NAMED_ONLY = 'NAMED_ONLY' VAR_NAMED = 'VAR_NAMED' - def __init__(self, kind, name='', type=NOTSET, default=NOTSET): + def __init__(self, kind, name='', type=NOT_SET, default=NOT_SET): self.kind = kind self.name = name self.type = TypeInfo.from_type(type) @@ -137,12 +135,12 @@ def required(self): if self.kind in (self.POSITIONAL_ONLY, self.POSITIONAL_OR_NAMED, self.NAMED_ONLY): - return self.default is self.NOTSET + return self.default is NOT_SET return False @property def default_repr(self): - if self.default is self.NOTSET: + if self.default is NOT_SET: return None if isinstance(self.default, Enum): return self.default.name @@ -163,12 +161,12 @@ def __str__(self): default_sep = ' = ' else: default_sep = '=' - if self.default is not self.NOTSET: + if self.default is not NOT_SET: ret = f'{ret}{default_sep}{self.default_repr}' return ret -Type = Union[type, str, tuple, type(ArgInfo.NOTSET)] +Type = Union[type, str, tuple, type(NOT_SET)] class TypeInfo: @@ -176,9 +174,8 @@ class TypeInfo: With unions and parametrized types, :attr:`nested` contains nested types. """ - NOTSET = ArgInfo.NOTSET - def __init__(self, type: Type = NOTSET, nested: Tuple['TypeInfo'] = ()): + def __init__(self, type: Type = NOT_SET, nested: Tuple['TypeInfo'] = ()): self.type = type self.nested = nested @@ -196,7 +193,7 @@ def is_union(self) -> bool: @classmethod def from_type(cls, type: Type) -> 'TypeInfo': - if type is cls.NOTSET: + if type is NOT_SET: return cls() if isinstance(type, dict): return cls.from_dict(type) @@ -230,4 +227,4 @@ def __str__(self): return type_repr(self.type) def __bool__(self): - return self.type is not self.NOTSET + return self.type is not NOT_SET diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 684686c96ec..2236fd1d024 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -54,6 +54,7 @@ from .misc import (classproperty, isatty, parse_re_flags, plural_or_not, printable_name, seq2str, seq2str2, test_or_task) from .normalizing import normalize, normalize_whitespace, NormalizedDict +from .notset import NOT_SET from .platform import PY_VERSION, PYPY, UNIXY, WINDOWS, RERAISED_EXCEPTIONS from .recommendations import RecommendationFinder from .robotenv import get_env_var, set_env_var, del_env_var, get_env_vars diff --git a/src/robot/utils/notset.py b/src/robot/utils/notset.py new file mode 100644 index 00000000000..4562bdc0653 --- /dev/null +++ b/src/robot/utils/notset.py @@ -0,0 +1,33 @@ +# 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. + +class NotSet: + """Represents value that is not set. + + Can be used instead of the standard ``None`` in cases where ``None`` + itself is a valid value. + + Use the constant ``robot.utils.NOT_SET`` instead of creating new instances + of the class. + + New in Robot Framework 7.0. + """ + + def __repr__(self): + return '' + + +NOT_SET = NotSet() + diff --git a/src/robot/variables/store.py b/src/robot/variables/store.py index cd8e3d65c3f..94e6f96c25d 100644 --- a/src/robot/variables/store.py +++ b/src/robot/variables/store.py @@ -14,16 +14,14 @@ # limitations under the License. from robot.errors import DataError, VariableError -from robot.utils import DotDict, is_dict_like, is_list_like, NormalizedDict, type_name +from robot.utils import (DotDict, is_dict_like, is_list_like, NormalizedDict, NOT_SET, + type_name) from .notfound import variable_not_found from .resolvable import GlobalVariableValue, Resolvable from .search import is_assign -NOT_SET = object() - - class VariableStore: def __init__(self, variables): From aa0d98e4c31ef9517aaa5d1facdd517166e9d046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 18 Sep 2023 14:54:46 +0300 Subject: [PATCH 0708/1592] Hopefully fix flakey test --- utest/output/test_stdout_splitter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utest/output/test_stdout_splitter.py b/utest/output/test_stdout_splitter.py index a4776f70893..36879605519 100644 --- a/utest/output/test_stdout_splitter.py +++ b/utest/output/test_stdout_splitter.py @@ -64,7 +64,7 @@ def test_timestamp_given_as_integer(self): assert_equal(len(splitter), 3) def test_timestamp_given_as_float(self): - now = float(time.time()) + now = round(time.time(), 6) splitter = Splitter(f'*INFO:1x2* No timestamp\n' f'*HTML:1000.123456789* X\n' f'*INFO:12345678.9*X\n' @@ -81,7 +81,7 @@ def _verify_message(self, message, msg='X', level='INFO', html=False, assert_equal(message.level, level) assert_equal(message.html, html) if timestamp: - assert_equal(message.timestamp, datetime.fromtimestamp(timestamp)) + assert_equal(message.timestamp, datetime.fromtimestamp(timestamp), timestamp) if __name__ == '__main__': From 2a2feac03b2f918e09cdf1bc8267b53f77bea148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 18 Sep 2023 14:55:17 +0300 Subject: [PATCH 0709/1592] Move `TypeInfo` to its own module. Eases implementing #4711. --- src/robot/running/arguments/argumentspec.py | 68 +---------------- src/robot/running/arguments/typeinfo.py | 82 +++++++++++++++++++++ 2 files changed, 84 insertions(+), 66 deletions(-) create mode 100644 src/robot/running/arguments/typeinfo.py diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index 0d865b85ba6..dd3cce023f2 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -15,13 +15,13 @@ import sys from enum import Enum -from typing import Union, Tuple -from robot.utils import has_args, is_union, NOT_SET, safe_str, setter, type_repr +from robot.utils import NOT_SET, safe_str, setter from .argumentconverter import ArgumentConverter from .argumentmapper import ArgumentMapper from .argumentresolver import ArgumentResolver +from .typeinfo import TypeInfo from .typevalidator import TypeValidator @@ -164,67 +164,3 @@ def __str__(self): if self.default is not NOT_SET: ret = f'{ret}{default_sep}{self.default_repr}' return ret - - -Type = Union[type, str, tuple, type(NOT_SET)] - - -class TypeInfo: - """Represents argument type. Only used by Libdoc. - - With unions and parametrized types, :attr:`nested` contains nested types. - """ - - def __init__(self, type: Type = NOT_SET, nested: Tuple['TypeInfo'] = ()): - self.type = type - self.nested = nested - - @property - def name(self) -> str: - if isinstance(self.type, str): - return self.type - return type_repr(self.type, nested=False) - - @property - def is_union(self) -> bool: - if isinstance(self.type, str): - return self.type == 'Union' - return is_union(self.type, allow_tuple=True) - - @classmethod - def from_type(cls, type: Type) -> 'TypeInfo': - if type is NOT_SET: - return cls() - if isinstance(type, dict): - return cls.from_dict(type) - if isinstance(type, (tuple, list)): - if not type: - return cls() - if len(type) == 1: - return cls(type[0]) - nested = tuple(cls.from_type(t) for t in type) - return cls('Union', nested) - if has_args(type): - nested = tuple(cls.from_type(t) for t in type.__args__) - return cls(type, nested) - return cls(type) - - @classmethod - def from_dict(cls, data: dict) -> 'TypeInfo': - if not data: - return cls() - nested = tuple(cls.from_dict(n) for n in data['nested']) - return cls(data['name'], nested) - - def __str__(self): - if self.is_union: - return ' | '.join(str(n) for n in self.nested) - if isinstance(self.type, str): - if self.nested: - nested = ', '.join(str(n) for n in self.nested) - return f'{self.name}[{nested}]' - return self.name - return type_repr(self.type) - - def __bool__(self): - return self.type is not NOT_SET diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py new file mode 100644 index 00000000000..fbf7e5a51cc --- /dev/null +++ b/src/robot/running/arguments/typeinfo.py @@ -0,0 +1,82 @@ +# 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 typing import Union + +from robot.utils import has_args, is_union, NOT_SET, type_repr + + +Type = Union[type, str, tuple, type(NOT_SET)] + + +class TypeInfo: + """Represents argument type. Only used by Libdoc. + + With unions and parametrized types, :attr:`nested` contains nested types. + """ + + def __init__(self, type: Type = NOT_SET, nested: 'tuple[TypeInfo]' = ()): + self.type = type + self.nested = nested + + @property + def name(self) -> str: + if isinstance(self.type, str): + return self.type + return type_repr(self.type, nested=False) + + @property + def is_union(self) -> bool: + if isinstance(self.type, str): + return self.type == 'Union' + return is_union(self.type, allow_tuple=True) + + @classmethod + def from_type(cls, type: Type) -> 'TypeInfo': + if type is NOT_SET: + return cls() + if isinstance(type, dict): + return cls.from_dict(type) + if isinstance(type, (tuple, list)): + if not type: + return cls() + if len(type) == 1: + return cls(type[0]) + nested = tuple(cls.from_type(t) for t in type) + return cls('Union', nested) + if has_args(type): + nested = tuple(cls.from_type(t) for t in type.__args__) + return cls(type, nested) + return cls(type) + + @classmethod + def from_dict(cls, data: dict) -> 'TypeInfo': + if not data: + return cls() + nested = tuple(cls.from_dict(n) for n in data['nested']) + return cls(data['name'], nested) + + def __str__(self): + if self.is_union: + return ' | '.join(str(n) for n in self.nested) + if isinstance(self.type, str): + if self.nested: + nested = ', '.join(str(n) for n in self.nested) + return f'{self.name}[{nested}]' + return self.name + return type_repr(self.type) + + def __bool__(self): + return self.type is not NOT_SET From 0c6209353f4e366e1e589eb1977f1a4bc3a49e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 18 Sep 2023 16:44:03 +0300 Subject: [PATCH 0710/1592] Type hints, f-strings, cleanup --- .../running/arguments/argumentconverter.py | 43 +++++++++----- src/robot/running/arguments/argumentmapper.py | 30 +++++----- .../running/arguments/argumentresolver.py | 58 ++++++++++--------- .../running/arguments/argumentvalidator.py | 53 +++++++++-------- src/robot/running/arguments/typeconverters.py | 2 +- src/robot/running/arguments/typevalidator.py | 30 +++++----- 6 files changed, 120 insertions(+), 96 deletions(-) diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index ff7be2ad032..5f21a4963b0 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -13,42 +13,52 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import TYPE_CHECKING + from robot.variables import contains_variable from .typeconverters import TypeConverter +if TYPE_CHECKING: + from robot.conf import LanguagesLike + + from .argumentspec import ArgumentSpec + from .customconverters import CustomArgumentConverters + class ArgumentConverter: - def __init__(self, argspec, converters, dry_run=False, languages=None): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec - self._converters = converters - self._dry_run = dry_run - self._languages = languages + def __init__(self, arg_spec: 'ArgumentSpec', + custom_converters: 'CustomArgumentConverters', + dry_run: bool = False, + languages: 'LanguagesLike' = None): + self.arg_spec = arg_spec + self.custom_converters = custom_converters + self.dry_run = dry_run + self.languages = languages def convert(self, positional, named): return self._convert_positional(positional), self._convert_named(named) def _convert_positional(self, positional): - names = self._argspec.positional + names = self.arg_spec.positional converted = [self._convert(name, value) for name, value in zip(names, positional)] - if self._argspec.var_positional: - converted.extend(self._convert(self._argspec.var_positional, value) + if self.arg_spec.var_positional: + converted.extend(self._convert(self.arg_spec.var_positional, value) for value in positional[len(names):]) return converted def _convert_named(self, named): - names = set(self._argspec.positional) | set(self._argspec.named_only) - var_named = self._argspec.var_named + names = set(self.arg_spec.positional) | set(self.arg_spec.named_only) + var_named = self.arg_spec.var_named return [(name, self._convert(name if name in names else var_named, value)) for name, value in named] def _convert(self, name, value): - spec = self._argspec + spec = self.arg_spec if (spec.types is None - or self._dry_run and contains_variable(value, identifiers='$@&%')): + or self.dry_run and contains_variable(value, identifiers='$@&%')): return value conversion_error = None # Don't convert None if argument has None as a default value. @@ -58,8 +68,9 @@ def _convert(self, name, value): if value is None and name in spec.defaults and spec.defaults[name] is None: return value if name in spec.types: - converter = TypeConverter.converter_for(spec.types[name], self._converters, - self._languages) + converter = TypeConverter.converter_for(spec.types[name], + self.custom_converters, + self.languages) if converter: try: return converter.convert(name, value) @@ -67,7 +78,7 @@ def _convert(self, name, value): conversion_error = err if name in spec.defaults: converter = TypeConverter.converter_for(type(spec.defaults[name]), - languages=self._languages) + languages=self.languages) if converter: try: return converter.convert(name, value, explicit_type=False, diff --git a/src/robot/running/arguments/argumentmapper.py b/src/robot/running/arguments/argumentmapper.py index b31fb57d1fc..e50196cfd2c 100644 --- a/src/robot/running/arguments/argumentmapper.py +++ b/src/robot/running/arguments/argumentmapper.py @@ -13,17 +13,21 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import TYPE_CHECKING + from robot.errors import DataError +if TYPE_CHECKING: + from .argumentspec import ArgumentSpec + class ArgumentMapper: - def __init__(self, argspec): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec + def __init__(self, arg_spec: 'ArgumentSpec'): + self.arg_spec = arg_spec def map(self, positional, named, replace_defaults=True): - template = KeywordCallTemplate(self._argspec) + template = KeywordCallTemplate(self.arg_spec) template.fill_positional(positional) template.fill_named(named) if replace_defaults: @@ -33,19 +37,18 @@ def map(self, positional, named, replace_defaults=True): class KeywordCallTemplate: - def __init__(self, argspec): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec - self.args = [None if arg not in argspec.defaults - else DefaultValue(argspec.defaults[arg]) - for arg in argspec.positional] + def __init__(self, arg_spec: 'ArgumentSpec'): + self.arg_spec = arg_spec + self.args = [None if arg not in arg_spec.defaults + else DefaultValue(arg_spec.defaults[arg]) + for arg in arg_spec.positional] self.kwargs = [] def fill_positional(self, positional): self.args[:len(positional)] = positional def fill_named(self, named): - spec = self._argspec + spec = self.arg_spec for name, value in named: if name in spec.positional_or_named: index = spec.positional_or_named.index(name) @@ -53,7 +56,7 @@ def fill_named(self, named): elif spec.var_named or name in spec.named_only: self.kwargs.append((name, value)) else: - raise DataError("Non-existing named argument '%s'." % name) + raise DataError(f"Non-existing named argument '{name}'.") named_names = {name for name, _ in named} for name in spec.named_only: if name not in named_names: @@ -77,5 +80,4 @@ def resolve(self, variables): try: return variables.replace_scalar(self.value) except DataError as err: - raise DataError('Resolving argument default values failed: %s' - % err.message) + raise DataError(f'Resolving argument default values failed: {err}') diff --git a/src/robot/running/arguments/argumentresolver.py b/src/robot/running/arguments/argumentresolver.py index 75b4edf8066..17ea3086bb4 100644 --- a/src/robot/running/arguments/argumentresolver.py +++ b/src/robot/running/arguments/argumentresolver.py @@ -13,37 +13,43 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import TYPE_CHECKING + from robot.errors import DataError from robot.utils import is_string, is_dict_like, split_from_equals from robot.variables import is_dict_variable from .argumentvalidator import ArgumentValidator +if TYPE_CHECKING: + from .argumentspec import ArgumentSpec + class ArgumentResolver: - def __init__(self, argspec, resolve_named=True, - resolve_variables_until=None, dict_to_kwargs=False): - self._named_resolver = NamedArgumentResolver(argspec) \ - if resolve_named else NullNamedArgumentResolver() - self._variable_replacer = VariableReplacer(resolve_variables_until) - self._dict_to_kwargs = DictToKwargs(argspec, dict_to_kwargs) - self._argument_validator = ArgumentValidator(argspec) + def __init__(self, arg_spec: 'ArgumentSpec', + resolve_named: bool = True, + resolve_variables_until: 'int|None' = None, + dict_to_kwargs: bool = False): + self.named_resolver = NamedArgumentResolver(arg_spec) \ + if resolve_named else NullNamedArgumentResolver() + self.variable_replacer = VariableReplacer(resolve_variables_until) + self.dict_to_kwargs = DictToKwargs(arg_spec, dict_to_kwargs) + self.argument_validator = ArgumentValidator(arg_spec) def resolve(self, arguments, variables=None): - positional, named = self._named_resolver.resolve(arguments, variables) - positional, named = self._variable_replacer.replace(positional, named, variables) - positional, named = self._dict_to_kwargs.handle(positional, named) - self._argument_validator.validate(positional, named, - dryrun=variables is None) + positional, named = self.named_resolver.resolve(arguments, variables) + positional, named = self.variable_replacer.replace(positional, named, variables) + positional, named = self.dict_to_kwargs.handle(positional, named) + self.argument_validator.validate(positional, named, + dryrun=variables is None) return positional, named class NamedArgumentResolver: - def __init__(self, argspec): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec + def __init__(self, arg_spec: 'ArgumentSpec'): + self.arg_spec = arg_spec def resolve(self, arguments, variables=None): positional = [] @@ -68,15 +74,15 @@ def _is_named(self, arg, previous_named, variables=None): name = variables.replace_scalar(name) except DataError: return False - spec = self._argspec + spec = self.arg_spec return bool(previous_named or spec.var_named or name in spec.positional_or_named or name in spec.named_only) def _raise_positional_after_named(self): - raise DataError("%s '%s' got positional argument after named arguments." - % (self._argspec.type.capitalize(), self._argspec.name)) + raise DataError(f"{self.arg_spec.type.capitalize()} '{self.arg_spec.name}' " + f"got positional argument after named arguments.") class NullNamedArgumentResolver: @@ -87,30 +93,30 @@ def resolve(self, arguments, variables=None): class DictToKwargs: - def __init__(self, argspec, enabled=False): - self._maxargs = argspec.maxargs - self._enabled = enabled and bool(argspec.var_named) + def __init__(self, arg_spec: 'ArgumentSpec', enabled: bool = False): + self.maxargs = arg_spec.maxargs + self.enabled = enabled and bool(arg_spec.var_named) def handle(self, positional, named): - if self._enabled and self._extra_arg_has_kwargs(positional, named): + if self.enabled and self._extra_arg_has_kwargs(positional, named): named = positional.pop().items() return positional, named def _extra_arg_has_kwargs(self, positional, named): - if named or len(positional) != self._maxargs + 1: + if named or len(positional) != self.maxargs + 1: return False return is_dict_like(positional[-1]) class VariableReplacer: - def __init__(self, resolve_until=None): - self._resolve_until = resolve_until + def __init__(self, resolve_until: 'int|None' = None): + self.resolve_until = resolve_until def replace(self, positional, named, variables=None): # `variables` is None in dry-run mode and when using Libdoc. if variables: - positional = variables.replace_list(positional, self._resolve_until) + positional = variables.replace_list(positional, self.resolve_until) named = list(self._replace_named(named, variables.replace_scalar)) else: # If `var` isn't a tuple, it's a &{dict} variables. diff --git a/src/robot/running/arguments/argumentvalidator.py b/src/robot/running/arguments/argumentvalidator.py index 3048ba35393..634e7993b3c 100644 --- a/src/robot/running/arguments/argumentvalidator.py +++ b/src/robot/running/arguments/argumentvalidator.py @@ -13,44 +13,48 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import TYPE_CHECKING + from robot.errors import DataError -from robot.utils import plural_or_not, seq2str +from robot.utils import plural_or_not as s, seq2str from robot.variables import is_dict_variable, is_list_variable +if TYPE_CHECKING: + from .argumentspec import ArgumentSpec + class ArgumentValidator: - def __init__(self, argspec): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec + def __init__(self, arg_spec: 'ArgumentSpec'): + self.arg_spec = arg_spec def validate(self, positional, named, dryrun=False): named = set(name for name, value in named) if dryrun and (any(is_list_variable(arg) for arg in positional) or any(is_dict_variable(arg) for arg in named)): return - self._validate_no_multiple_values(positional, named, self._argspec) - self._validate_no_positional_only_as_named(named, self._argspec) - self._validate_positional_limits(positional, named, self._argspec) - self._validate_no_mandatory_missing(positional, named, self._argspec) - self._validate_no_named_only_missing(named, self._argspec) - self._validate_no_extra_named(named, self._argspec) + self._validate_no_multiple_values(positional, named, self.arg_spec) + self._validate_no_positional_only_as_named(named, self.arg_spec) + self._validate_positional_limits(positional, named, self.arg_spec) + self._validate_no_mandatory_missing(positional, named, self.arg_spec) + self._validate_no_named_only_missing(named, self.arg_spec) + self._validate_no_extra_named(named, self.arg_spec) def _validate_no_multiple_values(self, positional, named, spec): for name in spec.positional[:len(positional)]: if name in named and name not in spec.positional_only: - self._raise_error("got multiple values for argument '%s'" % name) + self._raise_error(f"got multiple values for argument '{name}'") def _raise_error(self, message): - raise DataError("%s '%s' %s." % (self._argspec.type.capitalize(), - self._argspec.name, message)) + spec = self.arg_spec + raise DataError(f"{spec.type.capitalize()} '{spec.name}' {message}.") def _validate_no_positional_only_as_named(self, named, spec): if not spec.var_named: for name in named: if name in spec.positional_only: - self._raise_error("does not accept argument '%s' as named " - "argument" % name) + self._raise_error(f"does not accept argument '{name}' as named " + f"argument") def _validate_positional_limits(self, positional, named, spec): count = len(positional) + self._named_positionals(named, spec) @@ -61,32 +65,31 @@ def _named_positionals(self, named, spec): return sum(1 for n in named if n in spec.positional_or_named) def _raise_wrong_count(self, count, spec): - minend = plural_or_not(spec.minargs) if spec.minargs == spec.maxargs: - expected = '%d argument%s' % (spec.minargs, minend) + expected = f'{spec.minargs} argument{s(spec.minargs)}' elif not spec.var_positional: - expected = '%d to %d arguments' % (spec.minargs, spec.maxargs) + expected = f'{spec.minargs} to {spec.maxargs} arguments' else: - expected = 'at least %d argument%s' % (spec.minargs, minend) + expected = f'at least {spec.minargs} argument{s(spec.minargs)}' if spec.var_named or spec.named_only: expected = expected.replace('argument', 'non-named argument') - self._raise_error("expected %s, got %d" % (expected, count)) + self._raise_error(f"expected {expected}, got {count}") def _validate_no_mandatory_missing(self, positional, named, spec): for name in spec.positional[len(positional):]: if name not in spec.defaults and name not in named: - self._raise_error("missing value for argument '%s'" % name) + self._raise_error(f"missing value for argument '{name}'") def _validate_no_named_only_missing(self, named, spec): defined = set(named) | set(spec.defaults) missing = [arg for arg in spec.named_only if arg not in defined] if missing: - self._raise_error("missing named-only argument%s %s" - % (plural_or_not(missing), seq2str(sorted(missing)))) + self._raise_error(f"missing named-only argument{s(missing)} " + f"{seq2str(sorted(missing))}") def _validate_no_extra_named(self, named, spec): if not spec.var_named: extra = set(named) - set(spec.positional_or_named) - set(spec.named_only) if extra: - self._raise_error("got unexpected named argument%s %s" - % (plural_or_not(extra), seq2str(sorted(extra)))) + self._raise_error(f"got unexpected named argument{s(extra)} " + f"{seq2str(sorted(extra))}") diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index c71033a97dc..bb98f116103 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -22,7 +22,7 @@ from numbers import Integral, Real from os import PathLike from pathlib import Path, PurePath -from typing import Any, Tuple, TypeVar, Union +from typing import Any, TypeVar, Union from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time diff --git a/src/robot/running/arguments/typevalidator.py b/src/robot/running/arguments/typevalidator.py index 94a728e797e..5b46abbd67b 100644 --- a/src/robot/running/arguments/typevalidator.py +++ b/src/robot/running/arguments/typevalidator.py @@ -13,16 +13,20 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import TYPE_CHECKING + from robot.errors import DataError from robot.utils import (is_dict_like, is_list_like, plural_or_not as s, seq2str, type_name) +if TYPE_CHECKING: + from .argumentspec import ArgumentSpec + class TypeValidator: - def __init__(self, argspec): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec + def __init__(self, arg_spec: 'ArgumentSpec'): + self.arg_spec = arg_spec def validate(self, types): if types is None: @@ -33,24 +37,22 @@ def validate(self, types): return self.validate_type_dict(types) if is_list_like(types): return self.convert_type_list_to_dict(types) - raise DataError('Type information must be given as a dictionary or ' - 'a list, got %s.' % type_name(types)) + raise DataError(f'Type information must be given as a dictionary or ' + f'a list, got {type_name(types)}.') def validate_type_dict(self, types): - # 'return' isn't used for anything yet but it may be shown by Libdoc + # 'return' isn't used for anything yet, but it may be shown by Libdoc # in the future. Trying to be forward compatible. - names = set(self._argspec.argument_names + ['return']) + names = set(self.arg_spec.argument_names + ['return']) extra = [t for t in types if t not in names] if extra: - raise DataError('Type information given to non-existing ' - 'argument%s %s.' - % (s(extra), seq2str(sorted(extra)))) + raise DataError(f'Type information given to non-existing ' + f'argument{s(extra)} {seq2str(sorted(extra))}.') return types def convert_type_list_to_dict(self, types): - names = self._argspec.argument_names + names = self.arg_spec.argument_names if len(types) > len(names): - raise DataError('Type information given to %d argument%s but ' - 'keyword has only %d argument%s.' - % (len(types), s(types), len(names), s(names))) + raise DataError(f'Type information given to {len(types)} argument{s(types)} ' + f'but keyword has only {len(names)} argument{s(names)}.') return {name: value for name, value in zip(names, types) if value} From 077a6ed24f7354a99c6442987227ab42db49f4aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 21 Sep 2023 19:12:32 +0300 Subject: [PATCH 0711/1592] Refactor type conversion. 1. Convert types used as type hints to `TypeInfo` objects. 2. Pass `TypeInfo` objects to type converters. 3. Move some of the type inspection logic from converters to `TypeInfo`. Motivation for this change is adding support for stringly typed `'list[int]'` and `'int | float'` constructs (#4711). The next step is making it possible to construct `TypeInfo` objects from such strings. We also should refactor type conversion even more. All type inspection logic should be moved from converters to `TypeInfo` and converters should only do conversion and error handling. It's probably a good idea also to add `TypeInfo.convert()` as a new entry point for conversion. --- .../keywords/type_conversion/unions.robot | 3 + atest/robot/libdoc/type_annotations.robot | 4 +- .../type_conversion/CustomConverters.py | 1 - .../annotations_with_typing.robot | 34 +-- .../keywords/type_conversion/unions.robot | 11 +- .../keywords/type_conversion/unionsugar.robot | 2 +- src/robot/libdocpkg/datatypes.py | 12 +- src/robot/libdocpkg/robotbuilder.py | 4 +- .../running/arguments/argumentconverter.py | 5 +- src/robot/running/arguments/argumentspec.py | 4 +- .../running/arguments/customconverters.py | 16 +- src/robot/running/arguments/typeconverters.py | 223 +++++++++--------- src/robot/running/arguments/typeinfo.py | 53 +++-- src/robot/running/arguments/typevalidator.py | 24 +- src/robot/running/context.py | 1 - 15 files changed, 211 insertions(+), 186 deletions(-) diff --git a/atest/robot/keywords/type_conversion/unions.robot b/atest/robot/keywords/type_conversion/unions.robot index 98da904562f..a83b7ef248c 100644 --- a/atest/robot/keywords/type_conversion/unions.robot +++ b/atest/robot/keywords/type_conversion/unions.robot @@ -74,3 +74,6 @@ Tuple with invalid types Union without types Check Test Case ${TESTNAME} + +Empty tuple + Check Test Case ${TESTNAME} diff --git a/atest/robot/libdoc/type_annotations.robot b/atest/robot/libdoc/type_annotations.robot index 8013d6b7136..160a0932ce2 100644 --- a/atest/robot/libdoc/type_annotations.robot +++ b/atest/robot/libdoc/type_annotations.robot @@ -29,14 +29,14 @@ Non-type annotations ... *varargs: But surely feels odd... Drop `typing.` prefix - Keyword Arguments Should Be 7 a: Any b: List c: Any | List + Keyword Arguments Should Be 7 a: Any b: list c: Any | list Union from typing Keyword Arguments Should Be 8 a: int | str | list | tuple Keyword Arguments Should Be 9 a: int | str | list | tuple | None = None Nested - Keyword Arguments Should Be 10 a: List[int] b: List[int | float] c: Tuple[Tuple[UnknownType], Dict[str, Tuple[float]]] + Keyword Arguments Should Be 10 a: list[int] b: list[int | float] c: tuple[tuple[UnknownType], dict[str, tuple[float]]] Union syntax [Tags] require-py3.10 diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index ee2caea2af9..85fcfa7b032 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -88,7 +88,6 @@ def __init__(self, *varargs): raise AssertionError(f'Expected library to be instance of {ModuleType}, was {type(library)}') - class Strict: pass diff --git a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot index 34e23ffc5d9..b7f0fa6f3bf 100644 --- a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot @@ -20,16 +20,16 @@ List with types List with incompatible types [Template] Conversion Should Fail - List with types ['foo', 'bar'] type=List[int] error=Item '0' got value 'foo' that cannot be converted to integer. - List with types [0, 1, 2, 3, 4, 5, 6.1] type=List[int] error=Item '6' got value '6.1' (float) that cannot be converted to integer: Conversion would lose precision. - List with types ${{[0.0, 1.1]}} type=List[int] error=Item '1' got value '1.1' (float) that cannot be converted to integer: Conversion would lose precision. + List with types ['foo', 'bar'] type=list[int] error=Item '0' got value 'foo' that cannot be converted to integer. + List with types [0, 1, 2, 3, 4, 5, 6.1] type=list[int] error=Item '6' got value '6.1' (float) that cannot be converted to integer: Conversion would lose precision. + List with types ${{[0.0, 1.1]}} type=list[int] error=Item '1' got value '1.1' (float) that cannot be converted to integer: Conversion would lose precision. ... arg_type=list Invalid list [Template] Conversion Should Fail List [1, oops] error=Invalid expression. List () error=Value is tuple, not list. - List with types ooops type=List[int] error=Invalid expression. + List with types ooops type=list[int] error=Invalid expression. Tuple Tuple () () @@ -55,21 +55,21 @@ Tuple with homogenous types Tuple with incompatible types [Template] Conversion Should Fail - Tuple with types ('bad', 'values') type=Tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. - Homogenous tuple ('bad', 'values') type=Tuple[int, ...] error=Item '0' got value 'bad' that cannot be converted to integer. - Tuple with types ${{('bad', 'values')}} type=Tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. + Tuple with types ('bad', 'values') type=tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. + Homogenous tuple ('bad', 'values') type=tuple[int, ...] error=Item '0' got value 'bad' that cannot be converted to integer. + Tuple with types ${{('bad', 'values')}} type=tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. ... arg_type=tuple Tuple with wrong number of values [Template] Conversion Should Fail - Tuple with types ('false',) type=Tuple[bool, int] error=Expected 2 items, got 1. - Tuple with types ('too', 'many', '!') type=Tuple[bool, int] error=Expected 2 items, got 3. + Tuple with types ('false',) type=tuple[bool, int] error=Expected 2 items, got 1. + Tuple with types ('too', 'many', '!') type=tuple[bool, int] error=Expected 2 items, got 3. Invalid tuple [Template] Conversion Should Fail Tuple (1, oops) error=Invalid expression. - Tuple with types [] type=Tuple[bool, int] error=Value is list, not tuple. - Homogenous tuple ooops type=Tuple[int, ...] error=Invalid expression. + Tuple with types [] type=tuple[bool, int] error=Value is list, not tuple. + Homogenous tuple ooops type=tuple[int, ...] error=Invalid expression. Sequence Sequence [] [] @@ -109,16 +109,16 @@ Dict with types Dict with incompatible types [Template] Conversion Should Fail - Dict with types {1: 2, 'bad': 3} type=Dict[int, float] error=Key 'bad' cannot be converted to integer. - Dict with types {None: 0} type=Dict[int, float] error=Key 'None' (None) cannot be converted to integer. - Dict with types {666: 'bad'} type=Dict[int, float] error=Item '666' got value 'bad' that cannot be converted to float. - Dict with types {0: None} type=Dict[int, float] error=Item '0' got value 'None' (None) that cannot be converted to float. + Dict with types {1: 2, 'bad': 3} type=dict[int, float] error=Key 'bad' cannot be converted to integer. + Dict with types {None: 0} type=dict[int, float] error=Key 'None' (None) cannot be converted to integer. + Dict with types {666: 'bad'} type=dict[int, float] error=Item '666' got value 'bad' that cannot be converted to float. + Dict with types {0: None} type=dict[int, float] error=Item '0' got value 'None' (None) that cannot be converted to float. Invalid dictionary [Template] Conversion Should Fail Dict {1: ooops} type=dictionary error=Invalid expression. Dict [] type=dictionary error=Value is list, not dict. - Dict with types ooops type=Dict[int, float] error=Invalid expression. + Dict with types ooops type=dict[int, float] error=Invalid expression. Mapping Mapping {} {} @@ -192,7 +192,7 @@ Set with types Set with incompatible types [Template] Conversion Should Fail - Set with types {1, 2.0, 'three'} type=Set[int] error=Item 'three' cannot be converted to integer. + Set with types {1, 2.0, 'three'} type=set[int] error=Item 'three' cannot be converted to integer. Mutable set with types {1, 2.0, 'three'} type=MutableSet[float] error=Item 'three' cannot be converted to float. Invalid Set diff --git a/atest/testdata/keywords/type_conversion/unions.robot b/atest/testdata/keywords/type_conversion/unions.robot index 987b1e4ab00..97f309f86bf 100644 --- a/atest/testdata/keywords/type_conversion/unions.robot +++ b/atest/testdata/keywords/type_conversion/unions.robot @@ -64,7 +64,7 @@ Argument not matching union Union of int and float ${CUSTOM} type=integer or float arg_type=Custom Union with int and None invalid type=integer or None Union with int and None ${1.1} type=integer or None arg_type=float - Union with subscripted generics invalid type=list or integer + Union with subscripted generics invalid type=list[int] or integer Union with unrecognized type ${myobject}= Create my object @@ -164,6 +164,9 @@ Tuple with invalid types ${42} ${42} Union without types - [Template] Conversion should fail - Union without types whatever error=Cannot have union without types. type=union - Empty tuple ${666} error=Cannot have union without types. type=union arg_type=integer + [Documentation] FAIL TypeError: Union used as a type hint cannot be empty. + Union without types whatever + +Empty tuple + [Documentation] FAIL TypeError: Union used as a type hint cannot be empty. + Empty tuple ${666} diff --git a/atest/testdata/keywords/type_conversion/unionsugar.robot b/atest/testdata/keywords/type_conversion/unionsugar.robot index 19a8bb46275..d17e8d6c5af 100644 --- a/atest/testdata/keywords/type_conversion/unionsugar.robot +++ b/atest/testdata/keywords/type_conversion/unionsugar.robot @@ -63,7 +63,7 @@ Argument not matching union Union of int and float ${NONE} type=integer or float arg_type=None Union of int and float ${CUSTOM} type=integer or float arg_type=Custom Union with int and None invalid type=integer or None - Union with subscripted generics invalid type=list or integer + Union with subscripted generics invalid type=list[int] or integer Union with unrecognized type ${myobject}= Create my object diff --git a/src/robot/libdocpkg/datatypes.py b/src/robot/libdocpkg/datatypes.py index e2482c31525..6af8dde66ca 100644 --- a/src/robot/libdocpkg/datatypes.py +++ b/src/robot/libdocpkg/datatypes.py @@ -47,12 +47,12 @@ def _sort_key(self): return self.name.lower() @classmethod - def for_type(cls, type_hint, converters): - if isinstance(type_hint, EnumType): - return cls.for_enum(type_hint) - if isinstance(type_hint, typeddict_types): - return cls.for_typed_dict(type_hint) - converter = TypeConverter.converter_for(type_hint, converters) + def for_type(cls, type_info, converters): + if isinstance(type_info.type, EnumType): + return cls.for_enum(type_info.type) + if isinstance(type_info.type, typeddict_types): + return cls.for_typed_dict(type_info.type) + converter = TypeConverter.converter_for(type_info, converters) if not converter: return None elif not converter.type: diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index 25ee01d7422..ce2319044d9 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -18,7 +18,7 @@ import re from robot.errors import DataError -from robot.running import (ArgInfo, ResourceFileBuilder, TestLibrary, TestSuiteBuilder, +from robot.running import (ResourceFileBuilder, TestLibrary, TestSuiteBuilder, TypeInfo, UserLibrary, UserErrorHandler) from robot.utils import is_string, split_tags_from_doc, unescape from robot.variables import search_variable @@ -71,7 +71,7 @@ def _get_type_docs(self, keywords, custom_converters): for arg in kw.args: kw.type_docs[arg.name] = {} for type_info in self._yield_type_info(arg.type): - type_doc = TypeDoc.for_type(type_info.type, custom_converters) + type_doc = TypeDoc.for_type(type_info, custom_converters) if type_doc: kw.type_docs[arg.name][type_info.name] = type_doc.name type_docs.setdefault(type_doc, set()).add(kw.name) diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 5f21a4963b0..4f01af54e00 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -17,6 +17,7 @@ from robot.variables import contains_variable +from .typeinfo import TypeInfo from .typeconverters import TypeConverter if TYPE_CHECKING: @@ -77,8 +78,8 @@ def _convert(self, name, value): except ValueError as err: conversion_error = err if name in spec.defaults: - converter = TypeConverter.converter_for(type(spec.defaults[name]), - languages=self.languages) + type_info = TypeInfo.from_type_hint(type(spec.defaults[name])) + converter = TypeConverter.converter_for(type_info, languages=self.languages) if converter: try: return converter.convert(name, value, explicit_type=False, diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index dd3cce023f2..323e635694a 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -41,7 +41,7 @@ def __init__(self, name=None, type='Keyword', positional_only=None, self.types = types @setter - def types(self, types): + def types(self, types) -> 'dict[str, TypeInfo]': return TypeValidator(self).validate(types) @property @@ -127,7 +127,7 @@ class ArgInfo: def __init__(self, kind, name='', type=NOT_SET, default=NOT_SET): self.kind = kind self.name = name - self.type = TypeInfo.from_type(type) + self.type = TypeInfo.from_type_hint(type) self.default = default @property diff --git a/src/robot/running/arguments/customconverters.py b/src/robot/running/arguments/customconverters.py index 1a3eff12af1..f93b5f8e030 100644 --- a/src/robot/running/arguments/customconverters.py +++ b/src/robot/running/arguments/customconverters.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import getdoc, is_union, seq2str, type_name +from robot.utils import getdoc, seq2str, type_name from .argumentparser import PythonArgumentParser @@ -78,15 +78,15 @@ def converter(arg): raise TypeError(f'Custom converters must be callable, converter for ' f'{type_name(type_)} is {type_name(converter)}.') spec = cls._get_arg_spec(converter) - arg_type = spec.types.get(spec.positional and spec.positional[0] or spec.var_positional) - if arg_type is None: + type_info = spec.types.get(spec.positional[0] if spec.positional + else spec.var_positional) + if type_info is None: accepts = () - elif is_union(arg_type): - accepts = arg_type.__args__ - elif hasattr(arg_type, '__origin__'): - accepts = (arg_type.__origin__,) + elif type_info.is_union: + accepts = type_info.nested else: - accepts = (arg_type,) + accepts = (type_info,) + accepts = tuple(info.type for info in accepts) pass_library = spec.minargs == 2 or spec.var_positional return cls(type_, converter, accepts, library if pass_library else None) diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index bb98f116103..68a6d5dd7a8 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -22,13 +22,17 @@ from numbers import Integral, Real from os import PathLike from pathlib import Path, PurePath -from typing import Any, TypeVar, Union +from typing import Any, TYPE_CHECKING, Union -from robot.conf import Languages +from robot.conf import Languages, LanguagesLike from robot.libraries.DateTime import convert_date, convert_time -from robot.utils import (eq, get_error_message, has_args, is_string, is_union, - plural_or_not as s, safe_str, seq2str, type_name, type_repr, - typeddict_types) +from robot.utils import (eq, get_error_message, is_string, plural_or_not as s, + safe_str, seq2str, type_name, typeddict_types) + +from .typeinfo import TypeInfo + +if TYPE_CHECKING: + from .customconverters import ConverterInfo, CustomArgumentConverters NoneType = type(None) @@ -44,8 +48,10 @@ class TypeConverter: _converters = OrderedDict() _type_aliases = {} - def __init__(self, used_type, custom_converters=None, languages=None): - self.used_type = used_type + def __init__(self, type_info: TypeInfo, + custom_converters: 'CustomArgumentConverters|None' = None, + languages: LanguagesLike = None): + self.type_info = type_info self.custom_converters = custom_converters self.languages = languages or Languages() @@ -58,34 +64,36 @@ def register(cls, converter): return converter @classmethod - def converter_for(cls, type_, custom_converters=None, languages=None): + def converter_for(cls, type_info: TypeInfo, + custom_converters: 'CustomArgumentConverters|None' = None, + languages: LanguagesLike = None): + # TODO: Move some/most of type inspection logic to TypeInfo + if not type_info: + return None try: - hash(type_) + hash(type_info.type) except TypeError: return None - if isinstance(type_, str): + if isinstance(type_info.type, str) and not type_info.is_union: try: - type_ = cls._type_aliases[type_.lower()] + type_info.type = cls._type_aliases[type_info.type.lower()] except KeyError: return None - used_type = type_ - if getattr(type_, '__origin__', None) and type_.__origin__ is not Union: - type_ = type_.__origin__ if custom_converters: - info = custom_converters.get_converter_info(type_) + info = custom_converters.get_converter_info(type_info.type) if info: - return CustomConverter(used_type, info) - if type_ in cls._converters: - return cls._converters[type_](used_type, custom_converters, languages) + return CustomConverter(type_info, info) + if type_info.type in cls._converters: + return cls._converters[type_info.type](type_info, custom_converters, languages) for converter in cls._converters.values(): - if converter.handles(type_): - return converter(used_type, custom_converters, languages) + if converter.handles(type_info): + return converter(type_info, custom_converters, languages) return None @classmethod - def handles(cls, type_): + def handles(cls, type_info: TypeInfo): handled = (cls.type, cls.abc) if cls.abc else cls.type - return isinstance(type_, type) and issubclass(type_, handled) + return isinstance(type_info.type, type) and issubclass(type_info.type, handled) def convert(self, name, value, explicit_type=True, strict=True, kind='Argument'): if self.no_conversion_needed(value): @@ -100,12 +108,11 @@ def convert(self, name, value, explicit_type=True, strict=True, kind='Argument') return self._handle_error(name, value, kind, error, strict) def no_conversion_needed(self, value): - used_type = getattr(self.used_type, '__origin__', self.used_type) try: - return isinstance(value, used_type) + return isinstance(value, self.type_info.type) except TypeError: # Used type wasn't a class. Compare to generic type instead. - if self.type and self.type is not self.used_type: + if self.type and self.type is not self.type_info.type: return isinstance(value, self.type) raise @@ -149,19 +156,14 @@ def _literal_eval(self, value, expected): raise ValueError(f'Value is {type_name(value)}, not {expected.__name__}.') return value - def _get_nested_types(self, type_hint, expected_count=None): - types = getattr(type_hint, '__args__', ()) - # `__args__` contains TypeVars when accessed directly from `typing.List` and - # other such types with Python 3.8. Python 3.9+ don't have `__args__` at all. - # Parameterize usages like `List[int].__args__` always work the same way. - # The TypeVar check can be removed when we don't support Python 3.8 anymore. - if not types or all(isinstance(a, TypeVar) for a in types): - return () - if expected_count and len(types) != expected_count: - raise TypeError(f'{type_hint.__name__}[] construct used as a type hint ' + def _get_nested_types(self, type_info: TypeInfo, + expected_count: 'int|None' = None): + nested = type_info.nested + if nested and expected_count and len(nested) != expected_count: + raise TypeError(f'{type_info.name}[] construct used as a type hint ' f'requires exactly {expected_count} nested ' - f'type{s(expected_count)}, got {len(types)}.') - return types + f'type{s(expected_count)}, got {len(nested)}.') + return nested def _remove_number_separators(self, value): if is_string(value): @@ -177,14 +179,14 @@ class EnumConverter(TypeConverter): @property def type_name(self): - return self.used_type.__name__ + return self.type_info.name @property def value_types(self): - return (str, int) if issubclass(self.used_type, int) else (str,) + return (str, int) if issubclass(self.type_info.type, int) else (str,) def _convert(self, value, explicit_type=True): - enum = self.used_type + enum = self.type_info.type if isinstance(value, int): return self._find_by_int_value(enum, value) try: @@ -201,7 +203,7 @@ def _find_by_normalized_name_or_int_value(self, enum, value): raise ValueError(f"{self.type_name} has multiple members matching " f"'{value}'. Available: {seq2str(matches)}") try: - if issubclass(self.used_type, int): + if issubclass(self.type_info.type, int): return self._find_by_int_value(enum, value) except ValueError: members = [f'{m} ({getattr(enum, m)})' for m in members] @@ -226,8 +228,8 @@ class AnyConverter(TypeConverter): value_types = (Any,) @classmethod - def handles(cls, type_): - return type_ is Any + def handles(cls, type_info: TypeInfo): + return type_info.type is Any def no_conversion_needed(self, value): return True @@ -430,8 +432,8 @@ class NoneConverter(TypeConverter): type_name = 'None' @classmethod - def handles(cls, type_): - return type_ in (NoneType, None) + def handles(cls, type_info: TypeInfo) -> bool: + return type_info.type in (NoneType, None) def _convert(self, value, explicit_type=True): if value.upper() == 'NONE': @@ -446,18 +448,16 @@ class ListConverter(TypeConverter): abc = Sequence value_types = (str, Sequence) - def __init__(self, used_type, custom_converters=None, languages=None): - super().__init__(used_type, custom_converters, languages) - types = self._get_nested_types(used_type, expected_count=1) - if not types: + def __init__(self, type_info: TypeInfo, + custom_converters: 'CustomArgumentConverters|None' = None, + languages: LanguagesLike = None): + super().__init__(type_info, custom_converters, languages) + nested = self._get_nested_types(type_info, expected_count=1) + if not nested: self.converter = None else: - self.type_name = type_repr(used_type) - self.converter = self.converter_for(types[0], custom_converters, languages) - - @classmethod - def handles(cls, type_): - return super().handles(type_) + self.type_name = str(type_info) + self.converter = self.converter_for(nested[0], custom_converters, languages) def no_conversion_needed(self, value): if isinstance(value, str) or not super().no_conversion_needed(value): @@ -485,22 +485,24 @@ class TupleConverter(TypeConverter): type_name = 'tuple' value_types = (str, Sequence) - def __init__(self, used_type, custom_converters=None, languages=None): - super().__init__(used_type, custom_converters, languages) + def __init__(self, type_info: TypeInfo, + custom_converters: 'CustomArgumentConverters|None' = None, + languages: LanguagesLike = None): + super().__init__(type_info, custom_converters, languages) self.converters = () self.homogenous = False - types = self._get_nested_types(used_type) - if not types: + nested = self._get_nested_types(type_info) + if not nested: return - if types[-1] is Ellipsis: - types = types[:-1] - if len(types) != 1: + if nested[-1].type is Ellipsis: + nested = nested[:-1] + if len(nested) != 1: raise TypeError(f'Homogenous tuple used as a type hint requires ' - f'exactly one nested type, got {len(types)}.') + f'exactly one nested type, got {len(nested)}.') self.homogenous = True - self.type_name = type_repr(used_type) + self.type_name = str(type_info) self.converters = tuple(self.converter_for(t, custom_converters, languages) - or NullConverter() for t in types) + or NullConverter() for t in nested) def no_conversion_needed(self, value): if isinstance(value, str) or not super().no_conversion_needed(value): @@ -538,17 +540,20 @@ class TypedDictConverter(TypeConverter): type = 'TypedDict' value_types = (str, Mapping) - def __init__(self, used_type, custom_converters, languages=None): - super().__init__(used_type, custom_converters, languages) - self.converters = {n: self.converter_for(t, custom_converters, languages) - for n, t in used_type.__annotations__.items()} - self.type_name = used_type.__name__ + def __init__(self, type_info: TypeInfo, + custom_converters: 'CustomArgumentConverters|None' = None, + languages: LanguagesLike = None): + super().__init__(type_info, custom_converters, languages) + self.converters = {n: self.converter_for(TypeInfo.from_type_hint(t), + custom_converters, languages) + for n, t in type_info.type.__annotations__.items()} + self.type_name = type_info.name # __required_keys__ is new in Python 3.9. - self.required_keys = getattr(used_type, '__required_keys__', frozenset()) + self.required_keys = getattr(type_info.type, '__required_keys__', frozenset()) @classmethod - def handles(cls, type_): - return isinstance(type_, typeddict_types) + def handles(cls, type_info: TypeInfo) -> bool: + return isinstance(type_info.type, typeddict_types) def no_conversion_needed(self, value): return False @@ -590,15 +595,17 @@ class DictionaryConverter(TypeConverter): aliases = ('dict', 'map') value_types = (str, Mapping) - def __init__(self, used_type, custom_converters=None, languages=None): - super().__init__(used_type, custom_converters, languages) - types = self._get_nested_types(used_type, expected_count=2) - if not types: + def __init__(self, type_info: TypeInfo, + custom_converters: 'CustomArgumentConverters|None' = None, + languages: LanguagesLike = None): + super().__init__(type_info, custom_converters, languages) + nested = self._get_nested_types(type_info, expected_count=2) + if not nested: self.converters = () else: - self.type_name = type_repr(used_type) + self.type_name = str(type_info) self.converters = tuple(self.converter_for(t, custom_converters, languages) - or NullConverter() for t in types) + or NullConverter() for t in nested) def no_conversion_needed(self, value): if isinstance(value, str) or not super().no_conversion_needed(value): @@ -616,8 +623,7 @@ def _non_string_convert(self, value, explicit_type=True): return self._convert_items(value, explicit_type) def _used_type_is_dict(self): - used_type = getattr(self.used_type, '__origin__', self.used_type) - return issubclass(used_type, dict) + return issubclass(self.type_info.type, dict) def _convert(self, value, explicit_type=True): return self._convert_items(self._literal_eval(value, dict), explicit_type) @@ -641,14 +647,16 @@ class SetConverter(TypeConverter): type_name = 'set' value_types = (str, Container) - def __init__(self, used_type, custom_converters=None, languages=None): - super().__init__(used_type, custom_converters, languages) - types = self._get_nested_types(used_type, expected_count=1) - if not types: + def __init__(self, type_info: TypeInfo, + custom_converters: 'CustomArgumentConverters|None' = None, + languages: LanguagesLike = None): + super().__init__(type_info, custom_converters, languages) + nested = self._get_nested_types(type_info, expected_count=1) + if not nested: self.converter = None else: - self.type_name = type_repr(used_type) - self.converter = self.converter_for(types[0], custom_converters, languages) + self.type_name = str(type_info) + self.converter = self.converter_for(nested[0], custom_converters, languages) def no_conversion_needed(self, value): if isinstance(value, str) or not super().no_conversion_needed(value): @@ -689,49 +697,42 @@ def _convert(self, value, explicit_type=True): class CombinedConverter(TypeConverter): type = Union - def __init__(self, union, custom_converters, languages=None): - super().__init__(self._get_types(union)) - self.converters = tuple(self.converter_for(t, custom_converters, languages) - for t in self.used_type) - - def _get_types(self, union): - if not union: - return () - if isinstance(union, tuple): - return union - if has_args(union): - return union.__args__ - return () + def __init__(self, type_info: TypeInfo, + custom_converters: 'CustomArgumentConverters|None' = None, + languages: LanguagesLike = None): + super().__init__(type_info, custom_converters, languages) + self.converters = tuple(self.converter_for(info, custom_converters, languages) + for info in self.type_info.nested) + if not self.converters: + raise TypeError('Union used as a type hint cannot be empty.') @property def type_name(self): - if not self.used_type: + if not self.converters: return 'union' - return ' or '.join(type_name(t) for t in self.used_type) + return ' or '.join(c.type_name for c in self.converters) @classmethod - def handles(cls, type_): - return is_union(type_, allow_tuple=True) + def handles(cls, type_info: TypeInfo) -> bool: + return type_info.is_union def _handles_value(self, value): return True def no_conversion_needed(self, value): - for converter, type_ in zip(self.converters, self.used_type): + for converter, info in zip(self.converters, self.type_info.nested): if converter: if converter.no_conversion_needed(value): return True else: try: - if isinstance(value, type_): + if isinstance(value, info.type): return True except TypeError: pass return False def _convert(self, value, explicit_type=True): - if not self.used_type: - raise ValueError('Cannot have union without types.') unrecognized_types = False for converter in self.converters: if converter: @@ -748,8 +749,10 @@ def _convert(self, value, explicit_type=True): class CustomConverter(TypeConverter): - def __init__(self, used_type, converter_info, languages=None): - super().__init__(used_type, languages=languages) + def __init__(self, type_info: TypeInfo, + converter_info: 'ConverterInfo', + languages: LanguagesLike = None): + super().__init__(type_info, languages=languages) self.converter_info = converter_info @property diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index fbf7e5a51cc..37315bee8a5 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -26,6 +26,7 @@ class TypeInfo: With unions and parametrized types, :attr:`nested` contains nested types. """ + __slots__ = ('type', 'nested') def __init__(self, type: Type = NOT_SET, nested: 'tuple[TypeInfo]' = ()): self.type = type @@ -37,6 +38,7 @@ def name(self) -> str: return self.type return type_repr(self.type, nested=False) + # TODO: Add `union=False` to `__init__` and remove this property. @property def is_union(self) -> bool: if isinstance(self.type, str): @@ -44,22 +46,35 @@ def is_union(self) -> bool: return is_union(self.type, allow_tuple=True) @classmethod - def from_type(cls, type: Type) -> 'TypeInfo': - if type is NOT_SET: + def from_type_hint(cls, hint: Type) -> 'TypeInfo': + if isinstance(hint, TypeInfo): + return hint + if hint is NOT_SET: return cls() - if isinstance(type, dict): - return cls.from_dict(type) - if isinstance(type, (tuple, list)): - if not type: - return cls() - if len(type) == 1: - return cls(type[0]) - nested = tuple(cls.from_type(t) for t in type) + if isinstance(hint, str): + return cls.from_sting(hint) + if isinstance(hint, dict): + return cls.from_dict(hint) + if isinstance(hint, (tuple, list)): + if len(hint) == 1: + return cls(hint[0]) + nested = tuple(cls.from_type_hint(t) for t in hint) return cls('Union', nested) - if has_args(type): - nested = tuple(cls.from_type(t) for t in type.__args__) - return cls(type, nested) - return cls(type) + return cls.from_type(hint) + + @classmethod + def from_type(cls, hint: type): + if has_args(hint): + nested = tuple(cls.from_type_hint(t) for t in hint.__args__) + else: + nested = () + if hasattr(hint, '__origin__') and not is_union(hint): + hint = hint.__origin__ + return cls(hint, nested) + + @classmethod + def from_sting(cls, hint: str) -> 'TypeInfo': + return cls(hint) @classmethod def from_dict(cls, data: dict) -> 'TypeInfo': @@ -71,12 +86,10 @@ def from_dict(cls, data: dict) -> 'TypeInfo': def __str__(self): if self.is_union: return ' | '.join(str(n) for n in self.nested) - if isinstance(self.type, str): - if self.nested: - nested = ', '.join(str(n) for n in self.nested) - return f'{self.name}[{nested}]' - return self.name - return type_repr(self.type) + if self.nested: + nested = ', '.join(str(n) for n in self.nested) + return f'{self.name}[{nested}]' + return self.name def __bool__(self): return self.type is not NOT_SET diff --git a/src/robot/running/arguments/typevalidator.py b/src/robot/running/arguments/typevalidator.py index 5b46abbd67b..73e4f243948 100644 --- a/src/robot/running/arguments/typevalidator.py +++ b/src/robot/running/arguments/typevalidator.py @@ -13,12 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING from robot.errors import DataError from robot.utils import (is_dict_like, is_list_like, plural_or_not as s, seq2str, type_name) +from .typeinfo import TypeInfo + if TYPE_CHECKING: from .argumentspec import ArgumentSpec @@ -28,19 +31,21 @@ class TypeValidator: def __init__(self, arg_spec: 'ArgumentSpec'): self.arg_spec = arg_spec - def validate(self, types): + def validate(self, types: 'Mapping|Sequence|None') -> 'dict[str, TypeInfo]|None': if types is None: return None if not types: return {} if is_dict_like(types): - return self.validate_type_dict(types) - if is_list_like(types): - return self.convert_type_list_to_dict(types) - raise DataError(f'Type information must be given as a dictionary or ' - f'a list, got {type_name(types)}.') - - def validate_type_dict(self, types): + self._validate_type_dict(types) + elif is_list_like(types): + types = self._type_list_to_dict(types) + else: + raise DataError(f'Type information must be given as a dictionary or ' + f'a list, got {type_name(types)}.') + return {k: TypeInfo.from_type_hint(types[k]) for k in types} + + def _validate_type_dict(self, types: Mapping): # 'return' isn't used for anything yet, but it may be shown by Libdoc # in the future. Trying to be forward compatible. names = set(self.arg_spec.argument_names + ['return']) @@ -48,9 +53,8 @@ def validate_type_dict(self, types): if extra: raise DataError(f'Type information given to non-existing ' f'argument{s(extra)} {seq2str(sorted(extra))}.') - return types - def convert_type_list_to_dict(self, types): + def _type_list_to_dict(self, types: Sequence) -> dict: names = self.arg_spec.argument_names if len(types) > len(names): raise DataError(f'Type information given to {len(types)} argument{s(types)} ' diff --git a/src/robot/running/context.py b/src/robot/running/context.py index c5764aa1f65..f2d33884c63 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys import inspect import asyncio from contextlib import contextmanager From e48edd3e4653ba96b3cc62ced3cf1596bd2b213d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 22 Sep 2023 02:37:57 +0300 Subject: [PATCH 0712/1592] Argument conversion with `'list[int]'` and `'int | float'` This is the core part of #4711. Things to do: - Unit tests for TokenInfoParser and TokenInfoTokenizer. - Tests for type aliases like `'integer'`. - Cleanup argument conversion so that all type inspection logic, handling aliases, etc. is taken care by TypeInfo. This isn't directly related to the aforementioned issue but worth doing as part of it anyway. --- .../type_conversion/stringly_types.robot | 25 +++ .../keywords/type_conversion/StringlyTypes.py | 26 +++ .../type_conversion/stringly_types.robot | 61 +++++++ .../CreatingTestLibraries.rst | 19 ++- src/robot/running/arguments/typeinfo.py | 157 +++++++++++++++++- 5 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 atest/robot/keywords/type_conversion/stringly_types.robot create mode 100644 atest/testdata/keywords/type_conversion/StringlyTypes.py create mode 100644 atest/testdata/keywords/type_conversion/stringly_types.robot diff --git a/atest/robot/keywords/type_conversion/stringly_types.robot b/atest/robot/keywords/type_conversion/stringly_types.robot new file mode 100644 index 00000000000..771b7835a06 --- /dev/null +++ b/atest/robot/keywords/type_conversion/stringly_types.robot @@ -0,0 +1,25 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/stringly_types.robot +Resource atest_resource.robot + +*** Test Cases *** +Parameterized list + Check Test Case ${TESTNAME} + +Parameterized dict + Check Test Case ${TESTNAME} + +Parameterized set + Check Test Case ${TESTNAME} + +Parameterized tuple + Check Test Case ${TESTNAME} + +Homogenous tuple + Check Test Case ${TESTNAME} + +Union + Check Test Case ${TESTNAME} + +Nested + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/StringlyTypes.py b/atest/testdata/keywords/type_conversion/StringlyTypes.py new file mode 100644 index 00000000000..9b7fe202089 --- /dev/null +++ b/atest/testdata/keywords/type_conversion/StringlyTypes.py @@ -0,0 +1,26 @@ +def parameterized_list(argument: 'list[int]', expected=None): + assert argument == eval(expected), repr(argument) + + +def parameterized_dict(argument: 'dict[int, float]', expected=None): + assert argument == eval(expected), repr(argument) + + +def parameterized_set(argument: 'set[float]', expected=None): + assert argument == eval(expected), repr(argument) + + +def parameterized_tuple(argument: 'tuple[int,float, str ]', expected=None): + assert argument == eval(expected), repr(argument) + + +def homogenous_tuple(argument: 'tuple[int, ...]', expected=None): + assert argument == eval(expected), repr(argument) + + +def union(argument: 'int | float', expected=None): + assert argument == eval(expected), repr(argument) + + +def nested(argument: 'dict[int|float, tuple[int, ...] | tuple[int, float]]', expected=None): + assert argument == eval(expected), repr(argument) diff --git a/atest/testdata/keywords/type_conversion/stringly_types.robot b/atest/testdata/keywords/type_conversion/stringly_types.robot new file mode 100644 index 00000000000..be326fee427 --- /dev/null +++ b/atest/testdata/keywords/type_conversion/stringly_types.robot @@ -0,0 +1,61 @@ +*** Settings *** +Library StringlyTypes.py +Resource conversion.resource + +*** Test Cases *** +Parameterized list + Parameterized list [] [] + Parameterized list [1, '2', 3] [1, 2, 3] + Conversion should fail + ... Parameterized list [1, 'kaksi'] + ... type=list[int] + ... error=Item '1' got value 'kaksi' that cannot be converted to integer. + + + +Parameterized dict + Parameterized dict {} {} + Parameterized dict {1: 2, 3.0: 4.5} {1: 2.0, 3: 4.5} + Conversion should fail + ... Parameterized dict {1.1: 2} + ... type=dict[int, float] + ... error=Key '1.1' (float) cannot be converted to integer: Conversion would lose precision. + +Parameterized set + Parameterized set set() set() + Parameterized set {1, 2.3, '4.5'} {1.0, 2.3, 4.5} + Conversion should fail + ... Parameterized set [1, 2] + ... type=set[float] + ... error=Value is list, not set. + +Parameterized tuple + Parameterized tuple (1, 2.3, 'xxx') (1, 2.3, 'xxx') + Conversion should fail + ... Parameterized tuple (1, 2, 'too', 'many') + ... type=tuple[int, float, str] + ... error=Expected 3 items, got 4. + +Homogenous tuple + Homogenous tuple () () + Homogenous tuple (1,) (1,) + Homogenous tuple (1, 2.0, '3') (1, 2, 3) + Conversion should fail + ... Homogenous tuple ('bad', 'values') + ... type=tuple[int, ...] + ... error=Item '0' got value 'bad' that cannot be converted to integer. + +Union + Union 1 1 + Union 1.2 1.2 + Conversion should fail + ... Union bad + ... type=integer or float + +Nested + Nested {} {} + Nested {1: (1, 2, 3), 2.3: (2, 3.4)} {1: (1, 2, 3), 2.3: (2, 3.4)} + Conversion should fail + ... Nested {1: (), 2: (1.1, 2.2, 3.3)} + ... type=dict[int | float, tuple[int, ...] | tuple[int, float]] + ... error=Item '2' got value '(1.1, 2.2, 3.3)' (tuple) that cannot be converted to tuple[int, ...] or tuple[int, float]. diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index ed28faa5889..d32fbd4fa21 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1379,6 +1379,15 @@ syntax instead: def example(length: int | float, padding: int | str | None = None): ... +Robot Framework 7.0 enhanced the support for the union syntax so that also +"stringly typed" unions like `'type1 | type2'` work. This syntax works also +with older Python versions: + +.. sourcecode:: python + + def example(length: 'int | float', padding: 'int | str | None' = None): + ... + An alternative is specifying types as a tuple. It is not recommended with annotations, because that syntax is not supported by other tools, but it works well with the `@keyword` decorator: @@ -1470,14 +1479,18 @@ with different generic types works according to these rules: - With sets there can be exactly one type like `set[float]`. Conversion logic is the same as with lists. +Using the native `list[int]` syntax requires `Python 3.9`__ or newer. If there +is a need to support also earlier Python versions, it is possible to either use +matching types from the typing_ module like `List[int]` or use the "stringly typed" +syntax like `'list[int]'`. + .. note:: Support for converting nested types with generics is new in Robot Framework 6.0. Same syntax works also with earlier versions, but arguments are only converted to the base type and nested types are not used for anything. -.. note:: Using generics with Python standard types like `list[int]` is new - in `Python 3.9`__. With earlier versions matching types from - the typing_ module can be used like `List[int]`. +.. note:: Support for "stringly typed" parameterized generics is new in + Robot Framework 7.0. __ https://peps.python.org/pep-0585/ diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index 37315bee8a5..7df74defaf2 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from enum import auto, Enum +from collections.abc import Sequence +from dataclasses import dataclass from typing import Union from robot.utils import has_args, is_union, NOT_SET, type_repr @@ -28,9 +31,11 @@ class TypeInfo: """ __slots__ = ('type', 'nested') - def __init__(self, type: Type = NOT_SET, nested: 'tuple[TypeInfo]' = ()): - self.type = type - self.nested = nested + def __init__(self, type: Type = NOT_SET, nested: 'Sequence[TypeInfo]' = ()): + # TODO: Fix type hint of `type`. + # TODO: Handle type aliases here. + self.type = type if type != '...' else Ellipsis + self.nested = tuple(nested) @property def name(self) -> str: @@ -74,13 +79,17 @@ def from_type(cls, hint: type): @classmethod def from_sting(cls, hint: str) -> 'TypeInfo': - return cls(hint) + try: + return TypeInfoParser(hint).parse() + except ValueError: + # Would be nice to report the error somewhere. + return cls(hint) @classmethod def from_dict(cls, data: dict) -> 'TypeInfo': if not data: return cls() - nested = tuple(cls.from_dict(n) for n in data['nested']) + nested = [cls.from_dict(n) for n in data['nested']] return cls(data['name'], nested) def __str__(self): @@ -93,3 +102,141 @@ def __str__(self): def __bool__(self): return self.type is not NOT_SET + + +class TypeInfoTokenType(Enum): + NAME = auto() + LEFT_SQUARE = auto() + RIGHT_SQUARE = auto() + PIPE = auto() + COMMA = auto() + + def __repr__(self): + return str(self) + + +@dataclass +class TypeInfoToken: + type: TypeInfoTokenType + value: str + position: int = -1 + + +class TypeInfoTokenizer: + markers = { + '[': TypeInfoTokenType.LEFT_SQUARE, + ']': TypeInfoTokenType.RIGHT_SQUARE, + '|': TypeInfoTokenType.PIPE, + ',': TypeInfoTokenType.COMMA, + } + + def __init__(self, source: str): + self.source = source + self.tokens: 'list[TypeInfoToken]' = [] + self.start = 0 + self.current = 0 + + @property + def at_end(self) -> bool: + return self.current >= len(self.source) + + def tokenize(self) -> 'list[TypeInfoToken]': + while not self.at_end: + self.start = self.current + char = self.advance() + if char in self.markers: + self.add_token(self.markers[char]) + elif char.strip(): + self.name() + return self.tokens + + def advance(self) -> str: + char = self.source[self.current] + self.current += 1 + return char + + def peek(self) -> 'str|None': + try: + return self.source[self.current] + except IndexError: + return None + + def name(self): + end_at = set(self.markers) | {None} + while self.peek() not in end_at: + self.current += 1 + self.add_token(TypeInfoTokenType.NAME) + + def add_token(self, type: TypeInfoTokenType): + value = self.source[self.start:self.current].strip() + self.tokens.append(TypeInfoToken(type, value, self.start)) + + +class TypeInfoParser: + + def __init__(self, source: str): + self.source = source + self.tokens: 'list[TypeInfoToken]' = [] + self.current = 0 + + @property + def at_end(self) -> bool: + return self.peek() is None + + def parse(self) -> 'TypeInfo': + self.tokens = TypeInfoTokenizer(self.source).tokenize() + info = self.type() + if not self.at_end: + self.error('Tokens remaining.') + return info + + def type(self) -> 'TypeInfo': + if not self.check(TypeInfoTokenType.NAME): + self.error('Token name missing.') + info = TypeInfo(self.advance().value) + if self.match(TypeInfoTokenType.LEFT_SQUARE): + info.nested = tuple(self.nested()) + if self.check(TypeInfoTokenType.PIPE): + nested = [info] + while self.match(TypeInfoTokenType.PIPE): + nested.append(self.type()) + info = TypeInfo('Union', nested) + return info + + def nested(self) -> 'list[TypeInfo]': + nested = [] + while not nested or self.match(TypeInfoTokenType.COMMA): + nested.append(self.type()) + if not self.check(TypeInfoTokenType.RIGHT_SQUARE): + self.error("']' missing.") + self.advance() + return nested + + def match(self, *types: TypeInfoTokenType) -> bool: + for typ in types: + if self.check(typ): + self.advance() + return True + return False + + def check(self, expected: TypeInfoTokenType) -> bool: + peeked = self.peek() + return peeked and peeked.type == expected + + def advance(self) -> 'TypeInfoToken|None': + token = self.peek() + if token: + self.current += 1 + return token + + def peek(self) -> 'TypeInfoToken|None': + try: + return self.tokens[self.current] + except IndexError: + return None + + def error(self, message: str): + token = self.peek() + position = f'in index {token.position}' if token else 'at end' + raise ValueError(f"Parsing type info {self.source!r} failed: " + f"Error {position}: {message}") From 7b8c50d351d9f11e60232b6cddf31c06a2b8208e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 28 Sep 2023 15:52:34 +0300 Subject: [PATCH 0713/1592] Enhance TypeInfoParser. Part of #4711. - Better error messages. Invalid type infos aren't yet reported to users, but there's a FIXME to change that. - Fix handling unions with more than two types. - Unit tests. --- src/robot/running/arguments/typeinfo.py | 43 +++++++----- utest/running/test_typeinfo.py | 88 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 utest/running/test_typeinfo.py diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index 7df74defaf2..c62fe9f618a 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -82,7 +82,7 @@ def from_sting(cls, hint: str) -> 'TypeInfo': try: return TypeInfoParser(hint).parse() except ValueError: - # Would be nice to report the error somewhere. + # FIXME: Make it an error to use invalid string as type hint. return cls(hint) @classmethod @@ -187,30 +187,37 @@ def parse(self) -> 'TypeInfo': self.tokens = TypeInfoTokenizer(self.source).tokenize() info = self.type() if not self.at_end: - self.error('Tokens remaining.') + self.error(f"Extra content after '{info}'.") return info def type(self) -> 'TypeInfo': if not self.check(TypeInfoTokenType.NAME): - self.error('Token name missing.') + self.error('Type name missing.') info = TypeInfo(self.advance().value) if self.match(TypeInfoTokenType.LEFT_SQUARE): - info.nested = tuple(self.nested()) - if self.check(TypeInfoTokenType.PIPE): - nested = [info] - while self.match(TypeInfoTokenType.PIPE): - nested.append(self.type()) + info.nested = self.params() + if self.match(TypeInfoTokenType.PIPE): + nested = [info] + self.union() info = TypeInfo('Union', nested) return info - def nested(self) -> 'list[TypeInfo]': - nested = [] - while not nested or self.match(TypeInfoTokenType.COMMA): - nested.append(self.type()) - if not self.check(TypeInfoTokenType.RIGHT_SQUARE): - self.error("']' missing.") - self.advance() - return nested + def params(self) -> 'list[TypeInfo]': + params = [] + while not params or self.match(TypeInfoTokenType.COMMA): + params.append(self.type()) + if not self.match(TypeInfoTokenType.RIGHT_SQUARE): + self.error("Closing ']' missing.") + return params + + def union(self) -> 'list[TypeInfo]': + types = [] + while not types or self.match(TypeInfoTokenType.PIPE): + info = self.type() + if info.is_union: + types.extend(info.nested) + else: + types.append(info) + return types def match(self, *types: TypeInfoTokenType) -> bool: for typ in types: @@ -237,6 +244,6 @@ def peek(self) -> 'TypeInfoToken|None': def error(self, message: str): token = self.peek() - position = f'in index {token.position}' if token else 'at end' + position = f'index {token.position}' if token else 'end' raise ValueError(f"Parsing type info {self.source!r} failed: " - f"Error {position}: {message}") + f"Error at {position}: {message}") diff --git a/utest/running/test_typeinfo.py b/utest/running/test_typeinfo.py new file mode 100644 index 00000000000..5f85f717eed --- /dev/null +++ b/utest/running/test_typeinfo.py @@ -0,0 +1,88 @@ +import unittest + +from robot.running.arguments.typeinfo import TypeInfoParser +from robot.utils.asserts import assert_equal, assert_raises_with_msg + + +class TestTypeInfoParser(unittest.TestCase): + + def test_simple(self): + for name in 'str', 'Integer', 'whatever', 'two parts': + info = TypeInfoParser(name).parse() + assert_equal(info.name, name) + + def test_parameterized(self): + info = TypeInfoParser('list[int]').parse() + assert_equal(info.name, 'list') + assert_equal(info.nested[0].name, 'int') + + def test_multiple_parameters(self): + info = TypeInfoParser('Mapping[str, int]').parse() + assert_equal(info.name, 'Mapping') + assert_equal(info.nested[0].name, 'str') + assert_equal(info.nested[1].name, 'int') + + def test_union(self): + info = TypeInfoParser('int | float').parse() + assert_equal(info.name, 'Union') + assert_equal(info.nested[0].name, 'int') + assert_equal(info.nested[1].name, 'float') + + def test_union_with_multiple_types(self): + types = list('abcdefg') + info = TypeInfoParser('|'.join(types)).parse() + assert_equal(info.name, 'Union') + assert_equal(len(info.nested), len(types)) + for nested, name in zip(info.nested, types): + assert_equal(nested.name, name) + + def test_mixed(self): + info = TypeInfoParser('int | list[int] |tuple[int,int|tuple[int, int|str]]').parse() + assert_equal(info.name, 'Union') + assert_equal(info.nested[0].name, 'int') + assert_equal(info.nested[1].name, 'list') + assert_equal(info.nested[1].nested[0].name, 'int') + assert_equal(info.nested[2].name, 'tuple') + assert_equal(info.nested[2].nested[0].name, 'int') + assert_equal(info.nested[2].nested[1].name, 'Union') + assert_equal(info.nested[2].nested[1].nested[0].name, 'int') + assert_equal(info.nested[2].nested[1].nested[1].name, 'tuple') + assert_equal(info.nested[2].nested[1].nested[1].nested[0].name, 'int') + assert_equal(info.nested[2].nested[1].nested[1].nested[1].name, 'Union') + assert_equal(info.nested[2].nested[1].nested[1].nested[1].nested[0].name, 'int') + assert_equal(info.nested[2].nested[1].nested[1].nested[1].nested[1].name, 'str') + + def test_errors(self): + for info, position, error in [ + ('', 'end', 'Type name missing.'), + ('[', 0, 'Type name missing.'), + (']', 0, 'Type name missing.'), + (',', 0, 'Type name missing.'), + ('|', 0, 'Type name missing.'), + ('x[', 'end', 'Type name missing.'), + ('x]', 1, "Extra content after 'x'."), + ('x,', 1, "Extra content after 'x'."), + ('x|', 'end', 'Type name missing.'), + ('x[y][', 4, "Extra content after 'x[y]'."), + ('x[y]]', 4, "Extra content after 'x[y]'."), + ('x[y],', 4, "Extra content after 'x[y]'."), + ('x[y]|', 'end', 'Type name missing.'), + ('x[y]z', 4, "Extra content after 'x[y]'."), + ('x[y', 'end', "Closing ']' missing."), + ('x[y,', 'end', 'Type name missing.'), + ('x[y,z', 'end', "Closing ']' missing."), + ('x[,', 2, 'Type name missing.'), + ('x[[y]]', 2, 'Type name missing.'), + ('x | ,', 4, 'Type name missing.'), + ('x|||', 2, 'Type name missing.'), + ]: + position = f'index {position}' if isinstance(position, int) else position + assert_raises_with_msg( + ValueError, + f"Parsing type info '{info}' failed: Error at {position}: {error}", + TypeInfoParser(info).parse + ) + + +if __name__ == '__main__': + unittest.main() From bdb46482e395684c0824e9488e631e86c8ed527a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 28 Sep 2023 17:38:15 +0300 Subject: [PATCH 0714/1592] Argument conversion: Make using invalid string type info an error. In practice characters `[`, `]`, `,` and `|` that have a special meaning with types cause problems (unless used as expected). Using non-type strings is otherwise still supported. For example, `arg: bad[info` and `arg: foo, bar` will now cause an error, but `arg: Hello world!` is still ok. This change makes #4711 somewhat backwards incompatible. --- atest/robot/keywords/type_conversion/stringly_types.robot | 6 ++++++ atest/testdata/keywords/type_conversion/Annotations.py | 2 +- .../testdata/keywords/type_conversion/CustomConverters.py | 2 +- .../testdata/keywords/type_conversion/KeywordDecorator.py | 2 +- atest/testdata/keywords/type_conversion/StringlyTypes.py | 4 ++++ .../keywords/type_conversion/stringly_types.robot | 6 ++++-- src/robot/running/arguments/typeinfo.py | 8 ++++---- utest/running/test_typeinfo.py | 2 +- 8 files changed, 22 insertions(+), 10 deletions(-) diff --git a/atest/robot/keywords/type_conversion/stringly_types.robot b/atest/robot/keywords/type_conversion/stringly_types.robot index 771b7835a06..1c1c144edb7 100644 --- a/atest/robot/keywords/type_conversion/stringly_types.robot +++ b/atest/robot/keywords/type_conversion/stringly_types.robot @@ -23,3 +23,9 @@ Union Nested Check Test Case ${TESTNAME} + +Invalid + Check Test Case ${TESTNAME} + Check Log Message ${ERRORS[0]} + ... Error in library 'StringlyTypes': Adding keyword 'invalid' failed: Parsing type 'bad[info' failed: Error at end: Closing ']' missing. + ... ERROR diff --git a/atest/testdata/keywords/type_conversion/Annotations.py b/atest/testdata/keywords/type_conversion/Annotations.py index 6734b64a317..c13b5724999 100644 --- a/atest/testdata/keywords/type_conversion/Annotations.py +++ b/atest/testdata/keywords/type_conversion/Annotations.py @@ -183,7 +183,7 @@ def unknown(argument: Unknown, expected=None): _validate_type(argument, expected) -def non_type(argument: 'this is string, not type', expected=None): +def non_type(argument: 'this is just a random string', expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/CustomConverters.py b/atest/testdata/keywords/type_conversion/CustomConverters.py index 85fcfa7b032..5b334484c9c 100644 --- a/atest/testdata/keywords/type_conversion/CustomConverters.py +++ b/atest/testdata/keywords/type_conversion/CustomConverters.py @@ -210,7 +210,7 @@ def invalid(a: Invalid, b: TooFewArgs, c: TooManyArgs, d: KwOnlyNotOk): assert (a, b, c, d) == ('a', 'b', 'c', 'd') -def non_type_annotation(arg1: 'Hello, world!', arg2: 2 = 2): +def non_type_annotation(arg1: 'Hello world!', arg2: 2 = 2): assert arg1 == arg2 diff --git a/atest/testdata/keywords/type_conversion/KeywordDecorator.py b/atest/testdata/keywords/type_conversion/KeywordDecorator.py index b88dca4820d..c02ee21dafa 100644 --- a/atest/testdata/keywords/type_conversion/KeywordDecorator.py +++ b/atest/testdata/keywords/type_conversion/KeywordDecorator.py @@ -208,7 +208,7 @@ def unknown(argument, expected=None): _validate_type(argument, expected) -@keyword(types={'argument': 'this is string, not type'}) +@keyword(types={'argument': 'this is just a random string'}) def non_type(argument, expected=None): _validate_type(argument, expected) diff --git a/atest/testdata/keywords/type_conversion/StringlyTypes.py b/atest/testdata/keywords/type_conversion/StringlyTypes.py index 9b7fe202089..45f954fd2b5 100644 --- a/atest/testdata/keywords/type_conversion/StringlyTypes.py +++ b/atest/testdata/keywords/type_conversion/StringlyTypes.py @@ -24,3 +24,7 @@ def union(argument: 'int | float', expected=None): def nested(argument: 'dict[int|float, tuple[int, ...] | tuple[int, float]]', expected=None): assert argument == eval(expected), repr(argument) + + +def invalid(argument: 'bad[info'): + assert False diff --git a/atest/testdata/keywords/type_conversion/stringly_types.robot b/atest/testdata/keywords/type_conversion/stringly_types.robot index be326fee427..3ead3575f45 100644 --- a/atest/testdata/keywords/type_conversion/stringly_types.robot +++ b/atest/testdata/keywords/type_conversion/stringly_types.robot @@ -11,8 +11,6 @@ Parameterized list ... type=list[int] ... error=Item '1' got value 'kaksi' that cannot be converted to integer. - - Parameterized dict Parameterized dict {} {} Parameterized dict {1: 2, 3.0: 4.5} {1: 2.0, 3: 4.5} @@ -59,3 +57,7 @@ Nested ... Nested {1: (), 2: (1.1, 2.2, 3.3)} ... type=dict[int | float, tuple[int, ...] | tuple[int, float]] ... error=Item '2' got value '(1.1, 2.2, 3.3)' (tuple) that cannot be converted to tuple[int, ...] or tuple[int, float]. + +Invalid + [Documentation] FAIL No keyword with name 'Invalid' found. + Invalid whatever diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index c62fe9f618a..ac9fa2cbbee 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -18,6 +18,7 @@ from dataclasses import dataclass from typing import Union +from robot.errors import DataError from robot.utils import has_args, is_union, NOT_SET, type_repr @@ -81,9 +82,8 @@ def from_type(cls, hint: type): def from_sting(cls, hint: str) -> 'TypeInfo': try: return TypeInfoParser(hint).parse() - except ValueError: - # FIXME: Make it an error to use invalid string as type hint. - return cls(hint) + except ValueError as err: + raise DataError(str(err)) @classmethod def from_dict(cls, data: dict) -> 'TypeInfo': @@ -245,5 +245,5 @@ def peek(self) -> 'TypeInfoToken|None': def error(self, message: str): token = self.peek() position = f'index {token.position}' if token else 'end' - raise ValueError(f"Parsing type info {self.source!r} failed: " + raise ValueError(f"Parsing type {self.source!r} failed: " f"Error at {position}: {message}") diff --git a/utest/running/test_typeinfo.py b/utest/running/test_typeinfo.py index 5f85f717eed..666c1448129 100644 --- a/utest/running/test_typeinfo.py +++ b/utest/running/test_typeinfo.py @@ -79,7 +79,7 @@ def test_errors(self): position = f'index {position}' if isinstance(position, int) else position assert_raises_with_msg( ValueError, - f"Parsing type info '{info}' failed: Error at {position}: {error}", + f"Parsing type '{info}' failed: Error at {position}: {error}", TypeInfoParser(info).parse ) From 4648b4d7d4832cb2265719e81afa4092c367e0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 29 Sep 2023 15:04:04 +0300 Subject: [PATCH 0715/1592] Fix `get_timestamp` millisecond rounding --- src/robot/utils/robottime.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 7951653cc13..d789d6a48c7 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -370,7 +370,8 @@ def get_timestamp(daysep='', daytimesep=' ', timesep=':', millissep='.'): parts = [str(dt.year), daysep, f'{dt.month:02}', daysep, f'{dt.day:02}', daytimesep, f'{dt.hour:02}', timesep, f'{dt.minute:02}', timesep, f'{dt.second:02}'] if millissep: - millis = round(dt.microsecond, -3) // 1000 + # Make sure milliseconds is < 1000. Good enough for a deprecated function. + millis = min(round(dt.microsecond, -3) // 1000, 999) parts.extend([millissep, f'{millis:03}']) return ''.join(parts) From 46ff3479e3a880568668f1695964b9661e11cb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 29 Sep 2023 19:43:00 +0300 Subject: [PATCH 0716/1592] regen --- .../testdata/libdoc/DataTypesLibrary.libspec | 292 ++++++++++++++---- 1 file changed, 229 insertions(+), 63 deletions(-) diff --git a/atest/testdata/libdoc/DataTypesLibrary.libspec b/atest/testdata/libdoc/DataTypesLibrary.libspec index 26e8a113ed8..e9696f2dd5b 100644 --- a/atest/testdata/libdoc/DataTypesLibrary.libspec +++ b/atest/testdata/libdoc/DataTypesLibrary.libspec @@ -1,101 +1,151 @@ - - + + <p>This Library has Data Types.</p> <p>It has some in <code>__init__</code> and others in the <a href="#Keywords" class="name">Keywords</a>.</p> -<p>The DataTypes are the following that should be linked. <span class="name">HttpCredentials</span> , <a href="#GeoLocation" class="name">GeoLocation</a> , <a href="#Small" class="name">Small</a> and <a href="#AssertionOperator" class="name">AssertionOperator</a>.</p> - +<p>The DataTypes are the following that should be linked. <span class="name">HttpCredentials</span> , <a href="#type-GeoLocation" class="name">GeoLocation</a> , <a href="#type-Small" class="name">Small</a> and <a href="#type-AssertionOperator" class="name">AssertionOperator</a>.</p> + + - + credentials -Small + one <p>This is the init Docs.</p> -<p>It links to <a href="#Set%20Location" class="name">Set Location</a> keyword and to <a href="#GeoLocation" class="name">GeoLocation</a> data type.</p> +<p>It links to <a href="#Set%20Location" class="name">Set Location</a> keyword and to <a href="#type-GeoLocation" class="name">GeoLocation</a> data type.</p> This is the init Docs. - + value operator -AssertionOperator -None + + + + None exp -str + something? - - +<p>This links to <a href="#type-AssertionOperator" class="name">AssertionOperator</a> .</p> +<p>This is the next Line that links to <a href="#Set%20Location" class="name">Set Location</a> .</p> +This links to `AssertionOperator` . + + + + +arg + + + +arg2 + + + +arg3 + + + +arg4 + + + + + - + funny -bool -int -float -str -AssertionOperator -Small -GeoLocation -None + + + + + + + + + + equal - - + + - + location -GeoLocation + - - + + - - - + + + list_of_str -List[str] + + + - + dict_str_int -Dict[str, int] + + + + - -Whatever -Any + +whatever + - + args -List[typing.Any] + + + - - + + - - - + + +<p>Any value is accepted. No conversion is done.</p> + +Any + + +Typing Types + + + <p>This is some Doc</p> <p>This has was defined by assigning to __doc__.</p> + +string + + +Assert Something +Funny Unions + @@ -104,32 +154,148 @@ - - -<p>This is the Documentation.</p> -<p>This was defined within the class definition.</p> - - - - - - - - - - + + +<p>Strings <code>TRUE</code>, <code>YES</code>, <code>ON</code> and <code>1</code> are converted to Boolean <code>True</code>, the empty string as well as strings <code>FALSE</code>, <code>NO</code>, <code>OFF</code> and <code>0</code> are converted to Boolean <code>False</code>, and the string <code>NONE</code> is converted to the Python <code>None</code> object. Other strings and other accepted values are passed as-is, allowing keywords to handle them specially if needed. All string comparisons are case-insensitive.</p> +<p>Examples: <code>TRUE</code> (converted to <code>True</code>), <code>off</code> (converted to <code>False</code>), <code>example</code> (used as-is)</p> + +string +integer +float +None + + +Funny Unions + + + +<p>Converter method doc is used when defined.</p> + +string +integer + + +Custom + + + +<p>Class doc is used when converter method has no doc.</p> + + + +Custom + + + +<p>Strings must be Python <a href="https://docs.python.org/library/stdtypes.html#dict">dictionary</a> literals. They are converted to actual dictionaries using the <a href="https://docs.python.org/library/ast.html#ast.literal_eval">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including dictionaries and other containers.</p> +<p>If the type has nested types like <code>dict[str, int]</code>, items are converted to those types automatically. This in new in Robot Framework 6.0.</p> +<p>Examples: <code>{'a': 1, 'b': 2}</code>, <code>{'key': 1, 'nested': {'key': 2}}</code></p> + +string +Mapping + + +Typing Types + + + +<p>Conversion is done using Python's <a href="https://docs.python.org/library/functions.html#float">float</a> built-in function.</p> +<p>Starting from RF 4.1, spaces and underscores can be used as visual separators for digit grouping purposes.</p> +<p>Examples: <code>3.14</code>, <code>2.9979e8</code>, <code>10 000.000 01</code></p> + +string +Real + + +Funny Unions + + + <p>Defines the geolocation.</p> <ul> <li><code>latitude</code> Latitude between -90 and 90.</li> <li><code>longitude</code> Longitude between -180 and 180.</li> -<li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0. Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></li> -</ul> +<li><code>accuracy</code> <b>Optional</b> Non-negative accuracy value. Defaults to 0.</li> +</ul> +<p>Example usage: <code>{'latitude': 59.95, 'longitude': 30.31667}</code></p> + +string +Mapping + + +Funny Unions +Set Location + - - - + + +<p>Conversion is done using Python's <a href="https://docs.python.org/library/functions.html#int">int</a> built-in function. Floating point numbers are accepted only if they can be represented as integers exactly. For example, <code>1.0</code> is accepted and <code>1.1</code> is not.</p> +<p>Starting from RF 4.1, it is possible to use hexadecimal, octal and binary numbers by prefixing values with <code>0x</code>, <code>0o</code> and <code>0b</code>, respectively.</p> +<p>Starting from RF 4.1, spaces and underscores can be used as visual separators for digit grouping purposes.</p> +<p>Examples: <code>42</code>, <code>-1</code>, <code>0b1010</code>, <code>10 000 000</code>, <code>0xBAD_C0FFEE</code></p> + +string +float + + +Funny Unions +Typing Types + + + +<p>Strings must be Python <a href="https://docs.python.org/library/stdtypes.html#list">list</a> literals. They are converted to actual lists using the <a href="https://docs.python.org/library/ast.html#ast.literal_eval">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including lists and other containers.</p> +<p>If the type has nested types like <code>list[int]</code>, items are converted to those types automatically. This in new in Robot Framework 6.0.</p> +<p>Examples: <code>['one', 'two']</code>, <code>[('one', 1), ('two', 2)]</code></p> + +string +Sequence + + +Typing Types + + + +<p>String <code>NONE</code> (case-insensitive) is converted to Python <code>None</code> object. Other values cause an error.</p> + +string + + +Assert Something +Funny Unions + + + +<p>This is the Documentation.</p> +<p>This was defined within the class definition.</p> + +string +integer + + +__init__ +Funny Unions + + + + + + + + + +<p>All arguments are converted to Unicode strings.</p> + +Any + + +Assert Something +Funny Unions +Typing Types + + + From 0b9b2616a496fae826750ee335da7c4410deacd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 29 Sep 2023 23:54:00 +0300 Subject: [PATCH 0717/1592] Refactor type conversion. - Handle mapping strings to actual types in `TypeInfo`, not in converters. - Related to the above, pass name and type separately to `TypeInfo` when both are known. - Add new type aliases `sequence` and `mapping`. - Make sure `TypeInfo.type` is a proper type, not any random object. - Preserve original name with generics from typing (e.g. `List`, not `list`). This was the old behavior before recent changes. Part of #4711. --- .../type_conversion/stringly_types.robot | 3 + .../keywords/type_conversion/unions.robot | 2 + atest/robot/libdoc/type_annotations.robot | 4 +- .../keywords/type_conversion/StringlyTypes.py | 5 + .../annotations_with_typing.robot | 34 ++--- .../type_conversion/stringly_types.robot | 3 + .../keywords/type_conversion/unions.robot | 6 +- .../CreatingTestLibraries.rst | 10 +- .../running/arguments/argumentconverter.py | 2 +- src/robot/running/arguments/typeconverters.py | 25 +--- src/robot/running/arguments/typeinfo.py | 137 ++++++++++++------ src/robot/utils/robottypes.py | 11 +- utest/libdoc/test_datatypes.py | 4 +- utest/libdoc/test_libdoc.py | 2 +- utest/running/test_typeinfo.py | 87 ++++++++++- utest/utils/test_robottypes.py | 4 - 16 files changed, 234 insertions(+), 105 deletions(-) diff --git a/atest/robot/keywords/type_conversion/stringly_types.robot b/atest/robot/keywords/type_conversion/stringly_types.robot index 1c1c144edb7..6640f2adb27 100644 --- a/atest/robot/keywords/type_conversion/stringly_types.robot +++ b/atest/robot/keywords/type_conversion/stringly_types.robot @@ -24,6 +24,9 @@ Union Nested Check Test Case ${TESTNAME} +Aliases + Check Test Case ${TESTNAME} + Invalid Check Test Case ${TESTNAME} Check Log Message ${ERRORS[0]} diff --git a/atest/robot/keywords/type_conversion/unions.robot b/atest/robot/keywords/type_conversion/unions.robot index a83b7ef248c..19a389253ff 100644 --- a/atest/robot/keywords/type_conversion/unions.robot +++ b/atest/robot/keywords/type_conversion/unions.robot @@ -74,6 +74,8 @@ Tuple with invalid types Union without types Check Test Case ${TESTNAME} + Check Log Message ${ERRORS}[1] Error in library 'unions': Adding keyword 'union_without_types' failed: Union used as a type hint cannot be empty. ERROR Empty tuple Check Test Case ${TESTNAME} + Check Log Message ${ERRORS}[0] Error in library 'unions': Adding keyword 'empty_tuple' failed: Union used as a type hint cannot be empty. ERROR diff --git a/atest/robot/libdoc/type_annotations.robot b/atest/robot/libdoc/type_annotations.robot index 160a0932ce2..8013d6b7136 100644 --- a/atest/robot/libdoc/type_annotations.robot +++ b/atest/robot/libdoc/type_annotations.robot @@ -29,14 +29,14 @@ Non-type annotations ... *varargs: But surely feels odd... Drop `typing.` prefix - Keyword Arguments Should Be 7 a: Any b: list c: Any | list + Keyword Arguments Should Be 7 a: Any b: List c: Any | List Union from typing Keyword Arguments Should Be 8 a: int | str | list | tuple Keyword Arguments Should Be 9 a: int | str | list | tuple | None = None Nested - Keyword Arguments Should Be 10 a: list[int] b: list[int | float] c: tuple[tuple[UnknownType], dict[str, tuple[float]]] + Keyword Arguments Should Be 10 a: List[int] b: List[int | float] c: Tuple[Tuple[UnknownType], Dict[str, Tuple[float]]] Union syntax [Tags] require-py3.10 diff --git a/atest/testdata/keywords/type_conversion/StringlyTypes.py b/atest/testdata/keywords/type_conversion/StringlyTypes.py index 45f954fd2b5..b4a808ae458 100644 --- a/atest/testdata/keywords/type_conversion/StringlyTypes.py +++ b/atest/testdata/keywords/type_conversion/StringlyTypes.py @@ -26,5 +26,10 @@ def nested(argument: 'dict[int|float, tuple[int, ...] | tuple[int, float]]', exp assert argument == eval(expected), repr(argument) +def aliases(a: 'sequence[integer]', b: 'MAPPING[STRING, DOUBLE|None]'): + assert a == [1, 2, 3] + assert b == {'1': 1.1, '2': 2.2, '': None} + + def invalid(argument: 'bad[info'): assert False diff --git a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot index b7f0fa6f3bf..34e23ffc5d9 100644 --- a/atest/testdata/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/testdata/keywords/type_conversion/annotations_with_typing.robot @@ -20,16 +20,16 @@ List with types List with incompatible types [Template] Conversion Should Fail - List with types ['foo', 'bar'] type=list[int] error=Item '0' got value 'foo' that cannot be converted to integer. - List with types [0, 1, 2, 3, 4, 5, 6.1] type=list[int] error=Item '6' got value '6.1' (float) that cannot be converted to integer: Conversion would lose precision. - List with types ${{[0.0, 1.1]}} type=list[int] error=Item '1' got value '1.1' (float) that cannot be converted to integer: Conversion would lose precision. + List with types ['foo', 'bar'] type=List[int] error=Item '0' got value 'foo' that cannot be converted to integer. + List with types [0, 1, 2, 3, 4, 5, 6.1] type=List[int] error=Item '6' got value '6.1' (float) that cannot be converted to integer: Conversion would lose precision. + List with types ${{[0.0, 1.1]}} type=List[int] error=Item '1' got value '1.1' (float) that cannot be converted to integer: Conversion would lose precision. ... arg_type=list Invalid list [Template] Conversion Should Fail List [1, oops] error=Invalid expression. List () error=Value is tuple, not list. - List with types ooops type=list[int] error=Invalid expression. + List with types ooops type=List[int] error=Invalid expression. Tuple Tuple () () @@ -55,21 +55,21 @@ Tuple with homogenous types Tuple with incompatible types [Template] Conversion Should Fail - Tuple with types ('bad', 'values') type=tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. - Homogenous tuple ('bad', 'values') type=tuple[int, ...] error=Item '0' got value 'bad' that cannot be converted to integer. - Tuple with types ${{('bad', 'values')}} type=tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. + Tuple with types ('bad', 'values') type=Tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. + Homogenous tuple ('bad', 'values') type=Tuple[int, ...] error=Item '0' got value 'bad' that cannot be converted to integer. + Tuple with types ${{('bad', 'values')}} type=Tuple[bool, int] error=Item '1' got value 'values' that cannot be converted to integer. ... arg_type=tuple Tuple with wrong number of values [Template] Conversion Should Fail - Tuple with types ('false',) type=tuple[bool, int] error=Expected 2 items, got 1. - Tuple with types ('too', 'many', '!') type=tuple[bool, int] error=Expected 2 items, got 3. + Tuple with types ('false',) type=Tuple[bool, int] error=Expected 2 items, got 1. + Tuple with types ('too', 'many', '!') type=Tuple[bool, int] error=Expected 2 items, got 3. Invalid tuple [Template] Conversion Should Fail Tuple (1, oops) error=Invalid expression. - Tuple with types [] type=tuple[bool, int] error=Value is list, not tuple. - Homogenous tuple ooops type=tuple[int, ...] error=Invalid expression. + Tuple with types [] type=Tuple[bool, int] error=Value is list, not tuple. + Homogenous tuple ooops type=Tuple[int, ...] error=Invalid expression. Sequence Sequence [] [] @@ -109,16 +109,16 @@ Dict with types Dict with incompatible types [Template] Conversion Should Fail - Dict with types {1: 2, 'bad': 3} type=dict[int, float] error=Key 'bad' cannot be converted to integer. - Dict with types {None: 0} type=dict[int, float] error=Key 'None' (None) cannot be converted to integer. - Dict with types {666: 'bad'} type=dict[int, float] error=Item '666' got value 'bad' that cannot be converted to float. - Dict with types {0: None} type=dict[int, float] error=Item '0' got value 'None' (None) that cannot be converted to float. + Dict with types {1: 2, 'bad': 3} type=Dict[int, float] error=Key 'bad' cannot be converted to integer. + Dict with types {None: 0} type=Dict[int, float] error=Key 'None' (None) cannot be converted to integer. + Dict with types {666: 'bad'} type=Dict[int, float] error=Item '666' got value 'bad' that cannot be converted to float. + Dict with types {0: None} type=Dict[int, float] error=Item '0' got value 'None' (None) that cannot be converted to float. Invalid dictionary [Template] Conversion Should Fail Dict {1: ooops} type=dictionary error=Invalid expression. Dict [] type=dictionary error=Value is list, not dict. - Dict with types ooops type=dict[int, float] error=Invalid expression. + Dict with types ooops type=Dict[int, float] error=Invalid expression. Mapping Mapping {} {} @@ -192,7 +192,7 @@ Set with types Set with incompatible types [Template] Conversion Should Fail - Set with types {1, 2.0, 'three'} type=set[int] error=Item 'three' cannot be converted to integer. + Set with types {1, 2.0, 'three'} type=Set[int] error=Item 'three' cannot be converted to integer. Mutable set with types {1, 2.0, 'three'} type=MutableSet[float] error=Item 'three' cannot be converted to float. Invalid Set diff --git a/atest/testdata/keywords/type_conversion/stringly_types.robot b/atest/testdata/keywords/type_conversion/stringly_types.robot index 3ead3575f45..d757f28757d 100644 --- a/atest/testdata/keywords/type_conversion/stringly_types.robot +++ b/atest/testdata/keywords/type_conversion/stringly_types.robot @@ -58,6 +58,9 @@ Nested ... type=dict[int | float, tuple[int, ...] | tuple[int, float]] ... error=Item '2' got value '(1.1, 2.2, 3.3)' (tuple) that cannot be converted to tuple[int, ...] or tuple[int, float]. +Aliases + Aliases [1, 2, '3'] {'1': 1.1, 2: '2.2', '': 'NONE'} + Invalid [Documentation] FAIL No keyword with name 'Invalid' found. Invalid whatever diff --git a/atest/testdata/keywords/type_conversion/unions.robot b/atest/testdata/keywords/type_conversion/unions.robot index 97f309f86bf..a365b2e3d66 100644 --- a/atest/testdata/keywords/type_conversion/unions.robot +++ b/atest/testdata/keywords/type_conversion/unions.robot @@ -64,7 +64,7 @@ Argument not matching union Union of int and float ${CUSTOM} type=integer or float arg_type=Custom Union with int and None invalid type=integer or None Union with int and None ${1.1} type=integer or None arg_type=float - Union with subscripted generics invalid type=list[int] or integer + Union with subscripted generics invalid type=List[int] or integer Union with unrecognized type ${myobject}= Create my object @@ -164,9 +164,9 @@ Tuple with invalid types ${42} ${42} Union without types - [Documentation] FAIL TypeError: Union used as a type hint cannot be empty. + [Documentation] FAIL No keyword with name 'Union without types' found. Union without types whatever Empty tuple - [Documentation] FAIL TypeError: Union used as a type hint cannot be empty. + [Documentation] FAIL No keyword with name 'Empty tuple' found. Empty tuple ${666} diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index d32fbd4fa21..c3570b841de 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1270,7 +1270,7 @@ Other types cause conversion failures. | | | | | | | `1` (PowerState.ON) | | | | | | Support for IntEnum_ and IntFlag_ is new in RF 4.1. | | +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | None_ | | NoneType | str_ | String `NONE` (case-insensitive) is converted to the Python | | `None` | + | None_ | | | str_ | String `NONE` (case-insensitive) is converted to the Python | | `None` | | | | | | `None` object. Other values cause an error. | | +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | Any_ | | | Any | Any value is accepted. No conversion is done. | | @@ -1279,7 +1279,7 @@ Other types cause conversion failures. | | | | | but conversion may have been done based on `default values | | | | | | | `__. | | +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ - | list_ | Sequence_ | | str_, | Strings must be Python list literals. They are converted | | `['one', 'two']` | + | list_ | Sequence_ | sequence | str_, | Strings must be Python list literals. They are converted | | `['one', 'two']` | | | | | Sequence_ | to actual lists using the `ast.literal_eval`_ function. | | `[('one', 1), ('two', 2)]` | | | | | | They can contain any values `ast.literal_eval` supports, | | | | | | | including lists and other containers. | | @@ -1287,6 +1287,8 @@ Other types cause conversion failures. | | | | | If the used type hint is list_ (e.g. `arg: list`), sequences | | | | | | | that are not lists are converted to lists. If the type hint is | | | | | | | generic Sequence_, sequences are used without conversion. | | + | | | | | | | + | | | | | Alias `sequence` is new in RF 7.0. | | +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | tuple_ | | | str_, | Same as `list`, but string arguments must tuple literals. | | `('one', 'two')` | | | | | Sequence_ | | | @@ -1298,7 +1300,9 @@ Other types cause conversion failures. | | | | Container_ | | | `frozenset()` | +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | dict_ | Mapping_ | dictionary,| str_, | Same as `list`, but string arguments must be dictionary | | `{'a': 1, 'b': 2}` | - | | | map | Mapping_ | literals. | | `{'key': 1, 'nested': {'key': 2}}` | + | | | mapping, | Mapping_ | literals. | | `{'key': 1, 'nested': {'key': 2}}` | + | | | map | | | | + | | | | | Alias `mapping` is new in RF 7.0. | | +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ | TypedDict_ | | | str_, | Same as `dict`, but dictionary items are also converted | .. sourcecode:: python | | | | | Mapping_ | to the specified types and items not included in the type | | diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 4f01af54e00..32e93eefc7c 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -78,7 +78,7 @@ def _convert(self, name, value): except ValueError as err: conversion_error = err if name in spec.defaults: - type_info = TypeInfo.from_type_hint(type(spec.defaults[name])) + type_info = TypeInfo.from_type(type(spec.defaults[name])) converter = TypeConverter.converter_for(type_info, languages=self.languages) if converter: try: diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 68a6d5dd7a8..7d99e9860e9 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -42,11 +42,9 @@ class TypeConverter: type = None type_name = None abc = None - aliases = () value_types = (str,) doc = None _converters = OrderedDict() - _type_aliases = {} def __init__(self, type_info: TypeInfo, custom_converters: 'CustomArgumentConverters|None' = None, @@ -58,27 +56,14 @@ def __init__(self, type_info: TypeInfo, @classmethod def register(cls, converter): cls._converters[converter.type] = converter - for name in (converter.type_name,) + converter.aliases: - if name is not None and not isinstance(name, property): - cls._type_aliases[name.lower()] = converter.type return converter @classmethod def converter_for(cls, type_info: TypeInfo, custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): - # TODO: Move some/most of type inspection logic to TypeInfo - if not type_info: + if type_info.type is None: return None - try: - hash(type_info.type) - except TypeError: - return None - if isinstance(type_info.type, str) and not type_info.is_union: - try: - type_info.type = cls._type_aliases[type_info.type.lower()] - except KeyError: - return None if custom_converters: info = custom_converters.get_converter_info(type_info.type) if info: @@ -224,7 +209,6 @@ def _find_by_int_value(self, enum, value): class AnyConverter(TypeConverter): type = Any type_name = 'Any' - aliases = ('any',) value_types = (Any,) @classmethod @@ -245,7 +229,6 @@ def _handles_value(self, value): class StringConverter(TypeConverter): type = str type_name = 'string' - aliases = ('string', 'str', 'unicode') value_types = (Any,) def _handles_value(self, value): @@ -264,7 +247,6 @@ def _convert(self, value, explicit_type=True): class BooleanConverter(TypeConverter): type = bool type_name = 'boolean' - aliases = ('bool',) value_types = (str, int, float, NoneType) def _non_string_convert(self, value, explicit_type=True): @@ -286,7 +268,6 @@ class IntegerConverter(TypeConverter): type = int abc = Integral type_name = 'integer' - aliases = ('int', 'long') value_types = (str, float) def _non_string_convert(self, value, explicit_type=True): @@ -322,7 +303,6 @@ class FloatConverter(TypeConverter): type = float abc = Real type_name = 'float' - aliases = ('double',) value_types = (str, Real) def _convert(self, value, explicit_type=True): @@ -592,7 +572,6 @@ class DictionaryConverter(TypeConverter): type = dict abc = Mapping type_name = 'dictionary' - aliases = ('dict', 'map') value_types = (str, Mapping) def __init__(self, type_info: TypeInfo, @@ -694,7 +673,7 @@ def _convert(self, value, explicit_type=True): @TypeConverter.register -class CombinedConverter(TypeConverter): +class UnionConverter(TypeConverter): type = Union def __init__(self, type_info: TypeInfo, diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index ac9fa2cbbee..07e4ef9aa18 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -15,68 +15,108 @@ from enum import auto, Enum from collections.abc import Sequence +from datetime import date, datetime, timedelta +from decimal import Decimal from dataclasses import dataclass -from typing import Union +from pathlib import Path +from typing import Any, Union from robot.errors import DataError -from robot.utils import has_args, is_union, NOT_SET, type_repr - - -Type = Union[type, str, tuple, type(NOT_SET)] +from robot.utils import has_args, is_union, NOT_SET, type_repr, typeddict_types + + +TYPE_NAMES = { + '...': Ellipsis, + 'any': Any, + 'str': str, + 'string': str, + 'unicode': str, + 'bool': bool, + 'boolean': bool, + 'int': int, + 'integer': int, + 'long': int, + 'float': float, + 'double': float, + 'decimal': Decimal, + 'bytes': bytes, + 'bytearray': bytearray, + 'datetime': datetime, + 'date': date, + 'timedelta': timedelta, + 'path': Path, + 'none': type(None), + 'list': list, + 'sequence': list, + 'tuple': tuple, + 'dictionary': dict, + 'dict': dict, + 'mapping': dict, + 'map': dict, + 'set': set, + 'frozenset': frozenset, + 'union': Union +} class TypeInfo: - """Represents argument type. Only used by Libdoc. + """Represents argument type. With unions and parametrized types, :attr:`nested` contains nested types. """ - __slots__ = ('type', 'nested') - - def __init__(self, type: Type = NOT_SET, nested: 'Sequence[TypeInfo]' = ()): - # TODO: Fix type hint of `type`. - # TODO: Handle type aliases here. - self.type = type if type != '...' else Ellipsis + __slots__ = ('name', 'type', 'nested') + + def __init__(self, name: 'str|None' = None, + type: 'type|None' = None, + nested: 'Sequence[TypeInfo]' = ()): + if name and not type: + type = TYPE_NAMES.get(name.lower()) + self.name = name + self.type = type self.nested = tuple(nested) + if self.is_union and not nested: + raise DataError('Union used as a type hint cannot be empty.') @property - def name(self) -> str: - if isinstance(self.type, str): - return self.type - return type_repr(self.type, nested=False) - - # TODO: Add `union=False` to `__init__` and remove this property. - @property - def is_union(self) -> bool: - if isinstance(self.type, str): - return self.type == 'Union' - return is_union(self.type, allow_tuple=True) + def is_union(self): + return self.name == 'Union' @classmethod - def from_type_hint(cls, hint: Type) -> 'TypeInfo': + def from_type_hint(cls, hint: Any) -> 'TypeInfo': if isinstance(hint, TypeInfo): return hint if hint is NOT_SET: return cls() + if isinstance(hint, type): + return cls(type_repr(hint), hint) + if hint is None: + return cls('None', type(None)) if isinstance(hint, str): return cls.from_sting(hint) if isinstance(hint, dict): return cls.from_dict(hint) + if is_union(hint): + nested = [cls.from_type_hint(typ) for typ in hint.__args__] + return cls('Union', nested=nested) if isinstance(hint, (tuple, list)): - if len(hint) == 1: - return cls(hint[0]) - nested = tuple(cls.from_type_hint(t) for t in hint) - return cls('Union', nested) - return cls.from_type(hint) + return cls._from_sequence(hint) + if hasattr(hint, '__origin__'): + if has_args(hint): + nested = [cls.from_type_hint(t) for t in hint.__args__] + else: + nested = [] + return cls(type_repr(hint, nested=False), hint.__origin__, nested) + if hint is Union: + return cls('Union') + if hint is Any: + return cls('Any', hint) + if hint is Ellipsis: + return cls('...', hint) + return cls(str(hint)) @classmethod - def from_type(cls, hint: type): - if has_args(hint): - nested = tuple(cls.from_type_hint(t) for t in hint.__args__) - else: - nested = () - if hasattr(hint, '__origin__') and not is_union(hint): - hint = hint.__origin__ - return cls(hint, nested) + def from_type(cls, hint: type) -> 'TypeInfo': + return cls(type_repr(hint), hint) @classmethod def from_sting(cls, hint: str) -> 'TypeInfo': @@ -89,8 +129,21 @@ def from_sting(cls, hint: str) -> 'TypeInfo': def from_dict(cls, data: dict) -> 'TypeInfo': if not data: return cls() - nested = [cls.from_dict(n) for n in data['nested']] - return cls(data['name'], nested) + nested = [cls.from_type_hint(n) for n in data['nested']] + return cls(data['name'], nested=nested) + + @classmethod + def _from_sequence(cls, sequence: 'tuple|list') -> 'TypeInfo': + infos = [] + for typ in sequence: + info = cls.from_type_hint(typ) + if info.is_union: + infos.extend(info.nested) + else: + infos.append(info) + if len(infos) == 1: + return infos[0] + return cls('Union', nested=infos) def __str__(self): if self.is_union: @@ -98,10 +151,10 @@ def __str__(self): if self.nested: nested = ', '.join(str(n) for n in self.nested) return f'{self.name}[{nested}]' - return self.name + return self.name or '' def __bool__(self): - return self.type is not NOT_SET + return self.name is not None class TypeInfoTokenType(Enum): @@ -198,7 +251,7 @@ def type(self) -> 'TypeInfo': info.nested = self.params() if self.match(TypeInfoTokenType.PIPE): nested = [info] + self.union() - info = TypeInfo('Union', nested) + info = TypeInfo('Union', nested=nested) return info def params(self) -> 'list[TypeInfo]': diff --git a/src/robot/utils/robottypes.py b/src/robot/utils/robottypes.py index f4fef8e8d61..8da8d9d357d 100644 --- a/src/robot/utils/robottypes.py +++ b/src/robot/utils/robottypes.py @@ -66,10 +66,9 @@ def is_dict_like(item): return isinstance(item, Mapping) -def is_union(item, allow_tuple=False): +def is_union(item): return (isinstance(item, UnionType) - or getattr(item, '__origin__', None) is Union - or (allow_tuple and isinstance(item, tuple))) + or getattr(item, '__origin__', None) is Union) def type_name(item, capitalize=False): @@ -80,8 +79,9 @@ def type_name(item, capitalize=False): if getattr(item, '__origin__', None): item = item.__origin__ if hasattr(item, '_name') and item._name: - # Union, Any, etc. from typing have real name in _name and __name__ is just - # generic `SpecialForm`. Also, pandas.Series has _name but it's None. + # Prior to Python 3.10 Union, Any, etc. from typing didn't have `__name__`. + # but instead had `_name`. Python 3.10 has both and newer only `__name__`. + # Also, pandas.Series has `_name` but it's None. name = item._name elif is_union(item): name = 'Union' @@ -115,6 +115,7 @@ def type_repr(typ, nested=True): def _get_type_name(typ): + # See comment in `type_name` for explanation about `_name`. for attr in '__name__', '_name': name = getattr(typ, attr, None) if name: diff --git a/utest/libdoc/test_datatypes.py b/utest/libdoc/test_datatypes.py index e6020b697cb..ce64e3c7e49 100644 --- a/utest/libdoc/test_datatypes.py +++ b/utest/libdoc/test_datatypes.py @@ -2,12 +2,12 @@ from robot.libdocpkg.standardtypes import STANDARD_TYPE_DOCS from robot.running.arguments.typeconverters import ( - EnumConverter, CombinedConverter, CustomConverter, TypeConverter, TypedDictConverter + EnumConverter, CustomConverter, TypeConverter, TypedDictConverter, UnionConverter ) class TestStandardTypeDocs(unittest.TestCase): - no_std_docs = (EnumConverter, CombinedConverter, CustomConverter, TypedDictConverter) + no_std_docs = (EnumConverter, CustomConverter, TypedDictConverter, UnionConverter) def test_all_standard_types_have_docs(self): for cls in TypeConverter.__subclasses__(): diff --git a/utest/libdoc/test_libdoc.py b/utest/libdoc/test_libdoc.py index 00e58d1ee0c..6aa54265e77 100644 --- a/utest/libdoc/test_libdoc.py +++ b/utest/libdoc/test_libdoc.py @@ -221,7 +221,7 @@ def test_DataTypesLibrary_xml(self): def test_DataTypesLibrary_py(self): run_libdoc_and_validate_json('DataTypesLibrary.py') - def test_DataTypesLibrary_libspex(self): + def test_DataTypesLibrary_libspec(self): run_libdoc_and_validate_json('DataTypesLibrary.libspec') diff --git a/utest/running/test_typeinfo.py b/utest/running/test_typeinfo.py index 666c1448129..c6ea53b4886 100644 --- a/utest/running/test_typeinfo.py +++ b/utest/running/test_typeinfo.py @@ -1,13 +1,96 @@ import unittest +from datetime import date, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from typing import Any, Union -from robot.running.arguments.typeinfo import TypeInfoParser +from robot.errors import DataError +from robot.running.arguments.typeinfo import TypeInfo, TypeInfoParser from robot.utils.asserts import assert_equal, assert_raises_with_msg +class TestTypeInfo(unittest.TestCase): + + def test_ellipsis_conversion(self): + assert_equal(TypeInfo('...').type, Ellipsis) + assert_equal(TypeInfo('...').name, '...') + + def test_type_from_name(self): + for name, expected in [('...', Ellipsis), + ('any', Any), + ('str', str), + ('string', str), + ('unicode', str), + ('boolean', bool), + ('bool', bool), + ('int', int), + ('integer', int), + ('long', int), + ('float', float), + ('double', float), + ('decimal', Decimal), + ('bytes', bytes), + ('bytearray', bytearray), + ('datetime', datetime), + ('date', date), + ('timedelta', timedelta), + ('path', Path), + ('none', type(None)), + ('list', list), + ('sequence', list), + ('tuple', tuple), + ('dictionary', dict), + ('dict', dict), + ('map', dict), + ('mapping', dict), + ('set', set), + ('frozenset', frozenset), + ('union', Union)]: + for name in name, name.upper(): + assert_equal(TypeInfo(name).type, expected) + assert_equal(TypeInfo(name).name, name) + + def test_union(self): + for union in [Union[int, str, float], + (int, str, float), + [int, str, float], + Union[int, Union[str, float]], + (int, [str, float])]: + info = TypeInfo.from_type_hint(union) + assert_equal(info.name, 'Union') + assert_equal(info.is_union, True) + assert_equal(info.nested[0].type, int) + assert_equal(info.nested[0].name, 'int') + assert_equal(info.nested[1].type, str) + assert_equal(info.nested[1].name, 'str') + assert_equal(info.nested[2].type, float) + assert_equal(info.nested[2].name, 'float') + assert_equal(len(info.nested), 3) + + def test_union_with_one_type_is_reduced_to_the_type(self): + for union in Union[int], (int,): + info = TypeInfo.from_type_hint(union) + assert_equal(info.type, int) + assert_equal(info.name, 'int') + assert_equal(info.is_union, False) + assert_equal(len(info.nested), 0) + + def test_empty_union_not_allowed(self): + for union in Union, (): + assert_raises_with_msg(DataError, 'Union used as a type hint cannot be empty.', + TypeInfo.from_type_hint, union) + + def test_non_type(self): + for item in 42, object(), set(), b'hello': + info = TypeInfo.from_type_hint(item) + assert_equal(info.name, str(item)) + assert_equal(info.type, None) + + class TestTypeInfoParser(unittest.TestCase): def test_simple(self): - for name in 'str', 'Integer', 'whatever', 'two parts': + for name in 'str', 'Integer', 'whatever', 'two parts', 'non-alpha!?': info = TypeInfoParser(name).parse() assert_equal(info.name, name) diff --git a/utest/utils/test_robottypes.py b/utest/utils/test_robottypes.py index 372bf1c3ba3..03a748104b3 100644 --- a/utest/utils/test_robottypes.py +++ b/utest/utils/test_robottypes.py @@ -40,15 +40,11 @@ def test_bytes(self): def test_is_union(self): assert is_union(Union[int, str]) - assert is_union(Union[int, str], allow_tuple=True) assert not is_union((int, str)) - assert is_union((int, str), allow_tuple=True) if PY_VERSION >= (3, 10): assert is_union(eval('int | str')) - assert is_union(eval('int | str'), allow_tuple=True) for not_union in 'string', 3, [int, str], list, List[int]: assert not is_union(not_union) - assert not is_union(not_union, allow_tuple=True) class TestListLike(unittest.TestCase): From f58a47782eb7666265108d1752d963d27a1f52a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 2 Oct 2023 01:43:24 +0300 Subject: [PATCH 0718/1592] Remove dead code. Related to #4667. --- src/robot/libdocpkg/datatypes.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/robot/libdocpkg/datatypes.py b/src/robot/libdocpkg/datatypes.py index 6af8dde66ca..1737fce6505 100644 --- a/src/robot/libdocpkg/datatypes.py +++ b/src/robot/libdocpkg/datatypes.py @@ -83,15 +83,14 @@ def for_typed_dict(cls, typed_dict): return cls(cls.TYPED_DICT, typed_dict.__name__, getdoc(typed_dict), accepts=(str, 'Mapping'), items=items) - def to_dictionary(self, legacy=False): + def to_dictionary(self): data = { 'type': self.type, 'name': self.name, 'doc': self.doc, + 'usages': self.usages, + 'accepts': self.accepts } - if not legacy: - data['usages'] = self.usages - data['accepts'] = self.accepts if self.members is not None: data['members'] = [m.to_dictionary() for m in self.members] if self.items is not None: From e7a0db2dc602d90df0a1af0f359ab34dec3d4dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 2 Oct 2023 01:44:41 +0300 Subject: [PATCH 0719/1592] Libdoc: Store types parsed from specs as TypeInfo. Now `ArgumentSpec.types` always contains `TypeInfo` objects. Also typo fix: `from_sting` -> `from_string`. Related to #4711. --- atest/robot/libdoc/LibDocLib.py | 8 +++++--- atest/robot/libdoc/libdoc_resource.robot | 2 +- src/robot/libdocpkg/jsonbuilder.py | 18 ++++++++++-------- src/robot/libdocpkg/xmlbuilder.py | 10 +++++----- src/robot/running/arguments/argumentspec.py | 18 +++++++++++------- src/robot/running/arguments/typeinfo.py | 10 ++++------ 6 files changed, 36 insertions(+), 30 deletions(-) diff --git a/atest/robot/libdoc/LibDocLib.py b/atest/robot/libdoc/LibDocLib.py index 33cfab07bc6..dba0f75a812 100644 --- a/atest/robot/libdoc/LibDocLib.py +++ b/atest/robot/libdoc/LibDocLib.py @@ -10,7 +10,7 @@ from robot.api import logger from robot.utils import NOT_SET, SYSTEM_ENCODING -from robot.running.arguments import ArgInfo +from robot.running.arguments import ArgInfo, TypeInfo ROOT = Path(__file__).absolute().parent.parent.parent.parent @@ -64,13 +64,15 @@ def validate_json_spec(self, path): self.json_schema.validate(json.load(f)) def get_repr_from_arg_model(self, model): + type_info = TypeInfo.from_type_hint(model['type']) if model['type'] else None return str(ArgInfo(kind=model['kind'], name=model['name'], - type=model['type'] or NOT_SET, + type=type_info, default=model['default'] or NOT_SET)) def get_repr_from_json_arg_model(self, model): + type_info = TypeInfo.from_type_hint(model['type']) if model['type'] else None return str(ArgInfo(kind=model['kind'], name=model['name'], - type=model['type'] or NOT_SET, + type=type_info, default=model['defaultValue'] or NOT_SET)) diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index eada29552ed..e6afe6c2107 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -148,7 +148,7 @@ Verify Arguments Structure ${name}= Get Element Optional Text ${arg_elem} name ${types}= Get Elements ${arg_elem} type IF not $types - ${type}= Set Variable ${EMPTY} + ${type}= Set Variable ${None} ELSE IF len($types) == 1 ${type}= Get Type ${types}[0] ELSE diff --git a/src/robot/libdocpkg/jsonbuilder.py b/src/robot/libdocpkg/jsonbuilder.py index 673b306ceed..ce2bc227f94 100644 --- a/src/robot/libdocpkg/jsonbuilder.py +++ b/src/robot/libdocpkg/jsonbuilder.py @@ -16,7 +16,7 @@ import json import os.path -from robot.running import ArgInfo +from robot.running import ArgInfo, TypeInfo from robot.errors import DataError from .datatypes import EnumMember, TypedDictItem, TypeDoc @@ -85,24 +85,26 @@ def _create_arguments(self, arguments, kw: KeywordDoc): spec.defaults[name] = default if 'type' in arg: # RF >= 6.1 type_docs = {} - type_info = self._parse_modern_type_info(arg['type'], type_docs) + type_info = self._parse_type_info(arg['type'], type_docs) else: # RF < 6.1 type_docs = arg.get('typedocs', {}) - type_info = tuple(arg['types']) + type_info = self._parse_legacy_type_info(arg['types']) if type_info: if not spec.types: spec.types = {} spec.types[name] = type_info kw.type_docs[name] = type_docs - def _parse_modern_type_info(self, data, type_docs): + def _parse_type_info(self, data, type_docs): if not data: - return {} + return None if data.get('typedoc'): type_docs[data['name']] = data['typedoc'] - return {'name': data['name'], - 'nested': [self._parse_modern_type_info(nested, type_docs) - for nested in data.get('nested', ())]} + nested = [self._parse_type_info(typ, type_docs) for typ in data.get('nested', ())] + return TypeInfo(data['name'], nested=nested) + + def _parse_legacy_type_info(self, types): + return TypeInfo.from_sequence(types) if types else None def _parse_type_docs(self, type_docs): for data in type_docs: diff --git a/src/robot/libdocpkg/xmlbuilder.py b/src/robot/libdocpkg/xmlbuilder.py index 26086810bfc..1c40bdcd658 100644 --- a/src/robot/libdocpkg/xmlbuilder.py +++ b/src/robot/libdocpkg/xmlbuilder.py @@ -16,7 +16,7 @@ import os.path from robot.errors import DataError -from robot.running import ArgInfo +from robot.running import ArgInfo, TypeInfo from robot.utils import ET, ETSource from .datatypes import EnumMember, TypedDictItem, TypeDoc @@ -108,9 +108,9 @@ def _parse_modern_type_info(self, type_elem, type_docs): name = type_elem.get('name') if type_elem.get('typedoc'): type_docs[name] = type_elem.get('typedoc') - nested = tuple(self._parse_modern_type_info(child, type_docs) - for child in type_elem.findall('type')) - return {'name': name, 'nested': nested} + nested = [self._parse_modern_type_info(child, type_docs) + for child in type_elem.findall('type')] + return TypeInfo(name, nested=nested) def _parse_legacy_type_info(self, type_elems, type_docs): types = [] @@ -119,7 +119,7 @@ def _parse_legacy_type_info(self, type_elems, type_docs): types.append(name) if elem.get('typedoc'): type_docs[name] = elem.get('typedoc') - return types + return TypeInfo.from_sequence(types) if types else None def _parse_type_docs(self, spec): for elem in spec.findall('typedocs/type'): diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index 323e635694a..971b68b601a 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -15,6 +15,7 @@ import sys from enum import Enum +from typing import Any from robot.utils import NOT_SET, safe_str, setter @@ -88,23 +89,23 @@ def __iter__(self): get_default = self.defaults.get for arg in self.positional_only: yield ArgInfo(ArgInfo.POSITIONAL_ONLY, arg, - get_type(arg, NOT_SET), get_default(arg, NOT_SET)) + get_type(arg), get_default(arg, NOT_SET)) if self.positional_only: yield ArgInfo(ArgInfo.POSITIONAL_ONLY_MARKER) for arg in self.positional_or_named: yield ArgInfo(ArgInfo.POSITIONAL_OR_NAMED, arg, - get_type(arg, NOT_SET), get_default(arg, NOT_SET)) + get_type(arg), get_default(arg, NOT_SET)) if self.var_positional: yield ArgInfo(ArgInfo.VAR_POSITIONAL, self.var_positional, - get_type(self.var_positional, NOT_SET)) + get_type(self.var_positional)) elif self.named_only: yield ArgInfo(ArgInfo.NAMED_ONLY_MARKER) for arg in self.named_only: yield ArgInfo(ArgInfo.NAMED_ONLY, arg, - get_type(arg, NOT_SET), get_default(arg, NOT_SET)) + get_type(arg), get_default(arg, NOT_SET)) if self.var_named: yield ArgInfo(ArgInfo.VAR_NAMED, self.var_named, - get_type(self.var_named, NOT_SET)) + get_type(self.var_named)) def __bool__(self): return any([self.positional_only, self.positional_or_named, self.var_positional, @@ -124,10 +125,13 @@ class ArgInfo: NAMED_ONLY = 'NAMED_ONLY' VAR_NAMED = 'VAR_NAMED' - def __init__(self, kind, name='', type=NOT_SET, default=NOT_SET): + def __init__(self, kind: str, + name: str = '', + type: 'TypeInfo|None' = None, + default: Any = NOT_SET): self.kind = kind self.name = name - self.type = TypeInfo.from_type_hint(type) + self.type = type or TypeInfo() self.default = default @property diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index 07e4ef9aa18..2f66fb42546 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -83,8 +83,6 @@ def is_union(self): @classmethod def from_type_hint(cls, hint: Any) -> 'TypeInfo': - if isinstance(hint, TypeInfo): - return hint if hint is NOT_SET: return cls() if isinstance(hint, type): @@ -92,14 +90,14 @@ def from_type_hint(cls, hint: Any) -> 'TypeInfo': if hint is None: return cls('None', type(None)) if isinstance(hint, str): - return cls.from_sting(hint) + return cls.from_string(hint) if isinstance(hint, dict): return cls.from_dict(hint) if is_union(hint): nested = [cls.from_type_hint(typ) for typ in hint.__args__] return cls('Union', nested=nested) if isinstance(hint, (tuple, list)): - return cls._from_sequence(hint) + return cls.from_sequence(hint) if hasattr(hint, '__origin__'): if has_args(hint): nested = [cls.from_type_hint(t) for t in hint.__args__] @@ -119,7 +117,7 @@ def from_type(cls, hint: type) -> 'TypeInfo': return cls(type_repr(hint), hint) @classmethod - def from_sting(cls, hint: str) -> 'TypeInfo': + def from_string(cls, hint: str) -> 'TypeInfo': try: return TypeInfoParser(hint).parse() except ValueError as err: @@ -133,7 +131,7 @@ def from_dict(cls, data: dict) -> 'TypeInfo': return cls(data['name'], nested=nested) @classmethod - def _from_sequence(cls, sequence: 'tuple|list') -> 'TypeInfo': + def from_sequence(cls, sequence: 'tuple|list') -> 'TypeInfo': infos = [] for typ in sequence: info = cls.from_type_hint(typ) From 66c1870ed6cabc1a7c30be638bcf4d2732ee4e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 2 Oct 2023 03:32:39 +0300 Subject: [PATCH 0720/1592] Refactor type conversion. Remove support for non-strict conversion from typy converters. Callers can handle conversion errors themselves. --- src/robot/running/arguments/argumentconverter.py | 7 +++---- src/robot/running/arguments/typeconverters.py | 12 +++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 32e93eefc7c..53b1fe7eb37 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -82,10 +82,9 @@ def _convert(self, name, value): converter = TypeConverter.converter_for(type_info, languages=self.languages) if converter: try: - return converter.convert(name, value, explicit_type=False, - strict=bool(conversion_error)) - except ValueError as err: - conversion_error = conversion_error or err + return converter.convert(name, value, explicit_type=False) + except ValueError: + pass if conversion_error: raise conversion_error return value diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index 7d99e9860e9..b5613f543df 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -80,17 +80,17 @@ def handles(cls, type_info: TypeInfo): handled = (cls.type, cls.abc) if cls.abc else cls.type return isinstance(type_info.type, type) and issubclass(type_info.type, handled) - def convert(self, name, value, explicit_type=True, strict=True, kind='Argument'): + def convert(self, name, value, explicit_type=True, kind='Argument'): if self.no_conversion_needed(value): return value if not self._handles_value(value): - return self._handle_error(name, value, kind, strict=strict) + return self._handle_error(name, value, kind) try: if not isinstance(value, str): return self._non_string_convert(value, explicit_type) return self._convert(value, explicit_type) except ValueError as error: - return self._handle_error(name, value, kind, error, strict) + return self._handle_error(name, value, kind, error) def no_conversion_needed(self, value): try: @@ -110,9 +110,7 @@ def _non_string_convert(self, value, explicit_type=True): def _convert(self, value, explicit_type=True): raise NotImplementedError - def _handle_error(self, name, value, kind, error=None, strict=True): - if not strict: - return value + def _handle_error(self, name, value, kind, error=None): value_type = '' if isinstance(value, str) else f' ({type_name(value)})' value = safe_str(value) ending = f': {error}' if (error and error.args) else '.' @@ -760,7 +758,7 @@ def _convert(self, value, explicit_type=True): class NullConverter: - def convert(self, name, value, explicit_type=True, strict=True, kind='Argument'): + def convert(self, name, value, explicit_type=True, kind='Argument'): return value def no_conversion_needed(self, value): From 127ae017d0d7876ece9416b77a3aac0839583503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 2 Oct 2023 10:38:41 +0300 Subject: [PATCH 0721/1592] Refactor type conversion. Remove support for non-explicit conversion from typy converters. Callers can handle that themselves. --- .../running/arguments/argumentconverter.py | 10 +- src/robot/running/arguments/typeconverters.py | 133 +++++++++--------- 2 files changed, 71 insertions(+), 72 deletions(-) diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 53b1fe7eb37..a8b4e62fd96 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -78,11 +78,17 @@ def _convert(self, name, value): except ValueError as err: conversion_error = err if name in spec.defaults: - type_info = TypeInfo.from_type(type(spec.defaults[name])) + typ = type(spec.defaults[name]) + if typ == str: # No conversion. + type_info = TypeInfo() + elif typ == int: # Try also conversion to float. + type_info = TypeInfo.from_sequence([int, float]) + else: + type_info = TypeInfo.from_type(typ) converter = TypeConverter.converter_for(type_info, languages=self.languages) if converter: try: - return converter.convert(name, value, explicit_type=False) + return converter.convert(name, value) except ValueError: pass if conversion_error: diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index b5613f543df..b5ac6c1cf5f 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -80,15 +80,15 @@ def handles(cls, type_info: TypeInfo): handled = (cls.type, cls.abc) if cls.abc else cls.type return isinstance(type_info.type, type) and issubclass(type_info.type, handled) - def convert(self, name, value, explicit_type=True, kind='Argument'): + def convert(self, name, value, kind='Argument'): if self.no_conversion_needed(value): return value if not self._handles_value(value): return self._handle_error(name, value, kind) try: if not isinstance(value, str): - return self._non_string_convert(value, explicit_type) - return self._convert(value, explicit_type) + return self._non_string_convert(value) + return self._convert(value) except ValueError as error: return self._handle_error(name, value, kind, error) @@ -104,10 +104,10 @@ def no_conversion_needed(self, value): def _handles_value(self, value): return isinstance(value, self.value_types) - def _non_string_convert(self, value, explicit_type=True): - return self._convert(value, explicit_type) + def _non_string_convert(self, value): + return self._convert(value) - def _convert(self, value, explicit_type=True): + def _convert(self, value): raise NotImplementedError def _handle_error(self, name, value, kind, error=None): @@ -168,7 +168,7 @@ def type_name(self): def value_types(self): return (str, int) if issubclass(self.type_info.type, int) else (str,) - def _convert(self, value, explicit_type=True): + def _convert(self, value): enum = self.type_info.type if isinstance(value, int): return self._find_by_int_value(enum, value) @@ -216,7 +216,7 @@ def handles(cls, type_info: TypeInfo): def no_conversion_needed(self, value): return True - def _convert(self, value, explicit_type=True): + def _convert(self, value): return value def _handles_value(self, value): @@ -232,9 +232,7 @@ class StringConverter(TypeConverter): def _handles_value(self, value): return True - def _convert(self, value, explicit_type=True): - if not explicit_type: - return value + def _convert(self, value): try: return str(value) except Exception: @@ -247,10 +245,10 @@ class BooleanConverter(TypeConverter): type_name = 'boolean' value_types = (str, int, float, NoneType) - def _non_string_convert(self, value, explicit_type=True): + def _non_string_convert(self, value): return value - def _convert(self, value, explicit_type=True): + def _convert(self, value): normalized = value.title() if normalized == 'None': return None @@ -268,23 +266,18 @@ class IntegerConverter(TypeConverter): type_name = 'integer' value_types = (str, float) - def _non_string_convert(self, value, explicit_type=True): + def _non_string_convert(self, value): if value.is_integer(): return int(value) raise ValueError('Conversion would lose precision.') - def _convert(self, value, explicit_type=True): + def _convert(self, value): value = self._remove_number_separators(value) value, base = self._get_base(value) try: return int(value, base) except ValueError: - if base == 10 and not explicit_type: - try: - return float(value) - except ValueError: - pass - raise ValueError + raise ValueError def _get_base(self, value): value = value.lower() @@ -303,7 +296,7 @@ class FloatConverter(TypeConverter): type_name = 'float' value_types = (str, Real) - def _convert(self, value, explicit_type=True): + def _convert(self, value): try: return float(self._remove_number_separators(value)) except ValueError: @@ -316,7 +309,7 @@ class DecimalConverter(TypeConverter): type_name = 'decimal' value_types = (str, int, float) - def _convert(self, value, explicit_type=True): + def _convert(self, value): try: return Decimal(self._remove_number_separators(value)) except InvalidOperation: @@ -333,10 +326,10 @@ class BytesConverter(TypeConverter): type_name = 'bytes' value_types = (str, bytearray) - def _non_string_convert(self, value, explicit_type=True): + def _non_string_convert(self, value): return bytes(value) - def _convert(self, value, explicit_type=True): + def _convert(self, value): try: return value.encode('latin-1') except UnicodeEncodeError as err: @@ -350,10 +343,10 @@ class ByteArrayConverter(TypeConverter): type_name = 'bytearray' value_types = (str, bytes) - def _non_string_convert(self, value, explicit_type=True): + def _non_string_convert(self, value): return bytearray(value) - def _convert(self, value, explicit_type=True): + def _convert(self, value): try: return bytearray(value, 'latin-1') except UnicodeEncodeError as err: @@ -367,7 +360,7 @@ class DateTimeConverter(TypeConverter): type_name = 'datetime' value_types = (str, int, float) - def _convert(self, value, explicit_type=True): + def _convert(self, value): return convert_date(value, result_format='datetime') @@ -376,7 +369,7 @@ class DateConverter(TypeConverter): type = date type_name = 'date' - def _convert(self, value, explicit_type=True): + def _convert(self, value): dt = convert_date(value, result_format='datetime') if dt.hour or dt.minute or dt.second or dt.microsecond: raise ValueError("Value is datetime, not date.") @@ -389,7 +382,7 @@ class TimeDeltaConverter(TypeConverter): type_name = 'timedelta' value_types = (str, int, float) - def _convert(self, value, explicit_type=True): + def _convert(self, value): return convert_time(value, result_format='timedelta') @@ -400,7 +393,7 @@ class PathConverter(TypeConverter): type_name = 'Path' value_types = (str, PurePath) - def _convert(self, value, explicit_type=True): + def _convert(self, value): return Path(value) @@ -413,7 +406,7 @@ class NoneConverter(TypeConverter): def handles(cls, type_info: TypeInfo) -> bool: return type_info.type in (NoneType, None) - def _convert(self, value, explicit_type=True): + def _convert(self, value): if value.upper() == 'NONE': return None raise ValueError @@ -444,16 +437,16 @@ def no_conversion_needed(self, value): return True return all(self.converter.no_conversion_needed(v) for v in value) - def _non_string_convert(self, value, explicit_type=True): - return self._convert_items(list(value), explicit_type) + def _non_string_convert(self, value): + return self._convert_items(list(value)) - def _convert(self, value, explicit_type=True): - return self._convert_items(self._literal_eval(value, list), explicit_type) + def _convert(self, value): + return self._convert_items(self._literal_eval(value, list)) - def _convert_items(self, value, explicit_type): + def _convert_items(self, value): if not self.converter: return value - return [self.converter.convert(i, v, explicit_type, kind='Item') + return [self.converter.convert(i, v, kind='Item') for i, v in enumerate(value)] @@ -493,23 +486,23 @@ def no_conversion_needed(self, value): return False return all(c.no_conversion_needed(v) for c, v in zip(self.converters, value)) - def _non_string_convert(self, value, explicit_type=True): - return self._convert_items(tuple(value), explicit_type) + def _non_string_convert(self, value): + return self._convert_items(tuple(value)) - def _convert(self, value, explicit_type=True): - return self._convert_items(self._literal_eval(value, tuple), explicit_type) + def _convert(self, value): + return self._convert_items(self._literal_eval(value, tuple)) - def _convert_items(self, value, explicit_type): + def _convert_items(self, value): if not self.converters: return value if self.homogenous: conv = self.converters[0] - return tuple(conv.convert(str(i), v, explicit_type, kind='Item') + return tuple(conv.convert(str(i), v, kind='Item') for i, v in enumerate(value)) if len(self.converters) != len(value): raise ValueError(f'Expected {len(self.converters)} ' f'item{s(self.converters)}, got {len(value)}.') - return tuple(conv.convert(i, v, explicit_type, kind='Item') + return tuple(conv.convert(i, v, kind='Item') for i, (conv, v) in enumerate(zip(self.converters, value))) @@ -536,10 +529,10 @@ def handles(cls, type_info: TypeInfo) -> bool: def no_conversion_needed(self, value): return False - def _non_string_convert(self, value, explicit_type=True): + def _non_string_convert(self, value): return self._convert_items(value) - def _convert(self, value, explicit_type=True): + def _convert(self, value): return self._convert_items(self._literal_eval(value, dict)) def _convert_items(self, value): @@ -594,26 +587,26 @@ def no_conversion_needed(self, value): return all(no_key_conversion_needed(k) and no_value_conversion_needed(v) for k, v in value.items()) - def _non_string_convert(self, value, explicit_type=True): + def _non_string_convert(self, value): if self._used_type_is_dict() and not isinstance(value, dict): value = dict(value) - return self._convert_items(value, explicit_type) + return self._convert_items(value) def _used_type_is_dict(self): return issubclass(self.type_info.type, dict) - def _convert(self, value, explicit_type=True): - return self._convert_items(self._literal_eval(value, dict), explicit_type) + def _convert(self, value): + return self._convert_items(self._literal_eval(value, dict)) - def _convert_items(self, value, explicit_type): + def _convert_items(self, value): if not self.converters: return value - convert_key = self._get_converter(self.converters[0], explicit_type, 'Key') - convert_value = self._get_converter(self.converters[1], explicit_type, 'Item') + convert_key = self._get_converter(self.converters[0], 'Key') + convert_value = self._get_converter(self.converters[1], 'Item') return {convert_key(None, k): convert_value(k, v) for k, v in value.items()} - def _get_converter(self, converter, explicit_type, kind): - return lambda name, value: converter.convert(name, value, explicit_type, + def _get_converter(self, converter, kind): + return lambda name, value: converter.convert(name, value, kind=kind) @@ -642,16 +635,16 @@ def no_conversion_needed(self, value): return True return all(self.converter.no_conversion_needed(v) for v in value) - def _non_string_convert(self, value, explicit_type=True): - return self._convert_items(set(value), explicit_type) + def _non_string_convert(self, value): + return self._convert_items(set(value)) - def _convert(self, value, explicit_type=True): - return self._convert_items(self._literal_eval(value, set), explicit_type) + def _convert(self, value): + return self._convert_items(self._literal_eval(value, set)) - def _convert_items(self, value, explicit_type): + def _convert_items(self, value): if not self.converter: return value - return {self.converter.convert(None, v, explicit_type, kind='Item') + return {self.converter.convert(None, v, kind='Item') for v in value} @@ -660,14 +653,14 @@ class FrozenSetConverter(SetConverter): type = frozenset type_name = 'frozenset' - def _non_string_convert(self, value, explicit_type=True): - return frozenset(super()._non_string_convert(value, explicit_type)) + def _non_string_convert(self, value): + return frozenset(super()._non_string_convert(value)) - def _convert(self, value, explicit_type=True): + def _convert(self, value): # There are issues w/ literal_eval. See self._literal_eval for details. if value == 'frozenset()': return frozenset() - return frozenset(super()._convert(value, explicit_type)) + return frozenset(super()._convert(value)) @TypeConverter.register @@ -709,12 +702,12 @@ def no_conversion_needed(self, value): pass return False - def _convert(self, value, explicit_type=True): + def _convert(self, value): unrecognized_types = False for converter in self.converters: if converter: try: - return converter.convert('', value, explicit_type) + return converter.convert('', value) except ValueError: pass else: @@ -747,7 +740,7 @@ def value_types(self): def _handles_value(self, value): return not self.value_types or isinstance(value, self.value_types) - def _convert(self, value, explicit_type=True): + def _convert(self, value): try: return self.converter_info.convert(value) except ValueError: @@ -758,7 +751,7 @@ def _convert(self, value, explicit_type=True): class NullConverter: - def convert(self, name, value, explicit_type=True, kind='Argument'): + def convert(self, name, value, kind='Argument'): return value def no_conversion_needed(self, value): From 9dadb9d2050810f740d799349f1659595723570b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 2 Oct 2023 13:59:23 +0300 Subject: [PATCH 0722/1592] Document that conversion in cases like `arg: type = default` will change. We currently convert arguments firts based on type hints and then based on possible default value types. It would be better to do default value based conversion only if there's no type hint, but that would make the conversion logic Python version dependent. Better to wait until we have Python 3.11 as the minimum version, but the planned change is now documented. See #4881 for more information (and reopen it if/when the change is done). --- .../CreatingTestLibraries.rst | 9 +++++++++ src/robot/running/arguments/argumentconverter.py | 12 ++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index c3570b841de..ee1b6e3302a 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1160,6 +1160,15 @@ __ `Specifying argument types using function annotations`_ __ `Specifying argument types using @keyword decorator`_ __ `Implicit argument types based on default values`_ +.. note:: If an argument has both a type hint and a default value, conversion is + first attempted based on the type hint and then, if that fails, based on + the default value type. This behavior is likely to change in the future + so that conversion based on the default value is done *only* if the argument + does not have a type hint. That will change conversion behavior in cases + like `arg: list = None` where `None` conversion will not be attempted + anymore. Library creators are strongly recommended to specify the default + value type explicitly like `arg: Union[list, None] = None` already now. + The type to use can be specified either using concrete types (e.g. list_), by using Abstract Base Classes (ABC) (e.g. Sequence_), or by using sub classes of these types (e.g. MutableSequence_). Also types in in the typing_ diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index a8b4e62fd96..8686271497b 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -65,9 +65,11 @@ def _convert(self, name, value): # Don't convert None if argument has None as a default value. # Python < 3.11 adds None to type hints automatically when using None as # a default value which preserves None automatically. This code keeps - # the same behavior also with newer Python versions. + # the same behavior also with newer Python versions. We can consider + # changing this once Python 3.11 is our minimum supported version. if value is None and name in spec.defaults and spec.defaults[name] is None: return value + # Primarily convert arguments based on type hints. if name in spec.types: converter = TypeConverter.converter_for(spec.types[name], self.custom_converters, @@ -77,9 +79,15 @@ def _convert(self, name, value): return converter.convert(name, value) except ValueError as err: conversion_error = err + # Try conversion also based on the default value type. We probably should + # do this only if there is no explicit type hint, but Python < 3.11 + # handling `arg: type = None` differently than newer versions would mean + # that conversion behavior depends on the Python version. Once Python 3.11 + # is our minimum supported version, we can consider reopening + # https://github.com/robotframework/robotframework/issues/4881 if name in spec.defaults: typ = type(spec.defaults[name]) - if typ == str: # No conversion. + if typ == str: # Don't convert arguments to strings. type_info = TypeInfo() elif typ == int: # Try also conversion to float. type_info = TypeInfo.from_sequence([int, float]) From 77e8ec213e9a63e1c3d89062da7d76ada0b0ba7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 2 Oct 2023 16:34:05 +0300 Subject: [PATCH 0723/1592] Add `TypeInfo.convert`. This is a more convenient API, possible also for external tools, than first getting a converter based on `TypeInfo` and then using it. Some FIXMEs added to type convertors to make the code better. --- .../running/arguments/argumentconverter.py | 31 ++++---- .../running/arguments/customconverters.py | 9 ++- src/robot/running/arguments/typeconverters.py | 73 ++++++++++--------- src/robot/running/arguments/typeinfo.py | 19 ++++- utest/running/test_typeinfo.py | 61 ++++++++++++++++ 5 files changed, 137 insertions(+), 56 deletions(-) diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 8686271497b..0136f908bd3 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -71,14 +71,13 @@ def _convert(self, name, value): return value # Primarily convert arguments based on type hints. if name in spec.types: - converter = TypeConverter.converter_for(spec.types[name], - self.custom_converters, - self.languages) - if converter: - try: - return converter.convert(name, value) - except ValueError as err: - conversion_error = err + info: TypeInfo = spec.types[name] + try: + return info.convert(value, name, self.custom_converters, self.languages) + except ValueError as err: + conversion_error = err + except RuntimeError: + pass # Try conversion also based on the default value type. We probably should # do this only if there is no explicit type hint, but Python < 3.11 # handling `arg: type = None` differently than newer versions would mean @@ -88,17 +87,15 @@ def _convert(self, name, value): if name in spec.defaults: typ = type(spec.defaults[name]) if typ == str: # Don't convert arguments to strings. - type_info = TypeInfo() + info = TypeInfo() elif typ == int: # Try also conversion to float. - type_info = TypeInfo.from_sequence([int, float]) + info = TypeInfo.from_sequence([int, float]) else: - type_info = TypeInfo.from_type(typ) - converter = TypeConverter.converter_for(type_info, languages=self.languages) - if converter: - try: - return converter.convert(name, value) - except ValueError: - pass + info = TypeInfo.from_type(typ) + try: + return info.convert(value, name, languages=self.languages) + except (ValueError, RuntimeError): + pass if conversion_error: raise conversion_error return value diff --git a/src/robot/running/arguments/customconverters.py b/src/robot/running/arguments/customconverters.py index f93b5f8e030..beb2c591c06 100644 --- a/src/robot/running/arguments/customconverters.py +++ b/src/robot/running/arguments/customconverters.py @@ -15,8 +15,6 @@ from robot.utils import getdoc, seq2str, type_name -from .argumentparser import PythonArgumentParser - class CustomArgumentConverters: @@ -24,12 +22,14 @@ def __init__(self, converters): self.converters = converters @classmethod - def from_dict(cls, converters, library): + def from_dict(cls, converters, library=None): valid = [] for type_, conv in converters.items(): try: info = ConverterInfo.for_converter(type_, conv, library) except TypeError as err: + if library is None: + raise library.report_error(str(err)) else: valid.append(info) @@ -92,6 +92,9 @@ def converter(arg): @classmethod def _get_arg_spec(cls, converter): + # Avoid cyclic import. Yuck. + from .argumentparser import PythonArgumentParser + spec = PythonArgumentParser(type='Converter').parse(converter) if spec.minargs > 2: required = seq2str([a for a in spec.positional if a not in spec.defaults]) diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index b5ac6c1cf5f..ae3771e2dfa 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -29,10 +29,10 @@ from robot.utils import (eq, get_error_message, is_string, plural_or_not as s, safe_str, seq2str, type_name, typeddict_types) -from .typeinfo import TypeInfo if TYPE_CHECKING: from .customconverters import ConverterInfo, CustomArgumentConverters + from .typeinfo import TypeInfo NoneType = type(None) @@ -46,7 +46,7 @@ class TypeConverter: doc = None _converters = OrderedDict() - def __init__(self, type_info: TypeInfo, + def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): self.type_info = type_info @@ -54,14 +54,14 @@ def __init__(self, type_info: TypeInfo, self.languages = languages or Languages() @classmethod - def register(cls, converter): + def register(cls, converter: 'type[TypeConverter]') -> 'type[TypeConverter]': cls._converters[converter.type] = converter return converter @classmethod - def converter_for(cls, type_info: TypeInfo, + def converter_for(cls, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, - languages: LanguagesLike = None): + languages: LanguagesLike = None) -> 'TypeConverter|None': if type_info.type is None: return None if custom_converters: @@ -76,23 +76,25 @@ def converter_for(cls, type_info: TypeInfo, return None @classmethod - def handles(cls, type_info: TypeInfo): + def handles(cls, type_info: 'TypeInfo') -> bool: handled = (cls.type, cls.abc) if cls.abc else cls.type return isinstance(type_info.type, type) and issubclass(type_info.type, handled) - def convert(self, name, value, kind='Argument'): + def convert(self, value: Any, + name: 'str|None' = None, + kind: str = 'Argument') -> Any: if self.no_conversion_needed(value): return value if not self._handles_value(value): - return self._handle_error(name, value, kind) + return self._handle_error(value, name, kind) try: if not isinstance(value, str): return self._non_string_convert(value) return self._convert(value) except ValueError as error: - return self._handle_error(name, value, kind, error) + return self._handle_error(value, name, kind, error) - def no_conversion_needed(self, value): + def no_conversion_needed(self, value: Any) -> bool: try: return isinstance(value, self.type_info.type) except TypeError: @@ -110,17 +112,17 @@ def _non_string_convert(self, value): def _convert(self, value): raise NotImplementedError - def _handle_error(self, name, value, kind, error=None): + def _handle_error(self, value, name, kind, error=None): value_type = '' if isinstance(value, str) else f' ({type_name(value)})' value = safe_str(value) ending = f': {error}' if (error and error.args) else '.' if name is None: raise ValueError( - f"{kind} '{value}'{value_type} " + f"{kind.capitalize()} '{value}'{value_type} " f"cannot be converted to {self.type_name}{ending}" ) raise ValueError( - f"{kind} '{name}' got value '{value}'{value_type} that " + f"{kind.capitalize()} '{name}' got value '{value}'{value_type} that " f"cannot be converted to {self.type_name}{ending}" ) @@ -139,9 +141,10 @@ def _literal_eval(self, value, expected): raise ValueError(f'Value is {type_name(value)}, not {expected.__name__}.') return value - def _get_nested_types(self, type_info: TypeInfo, + def _get_nested_types(self, type_info: 'TypeInfo', expected_count: 'int|None' = None): nested = type_info.nested + # FIXME: Handle nested type validation in TypeInfo. if nested and expected_count and len(nested) != expected_count: raise TypeError(f'{type_info.name}[] construct used as a type hint ' f'requires exactly {expected_count} nested ' @@ -210,7 +213,7 @@ class AnyConverter(TypeConverter): value_types = (Any,) @classmethod - def handles(cls, type_info: TypeInfo): + def handles(cls, type_info: 'TypeInfo'): return type_info.type is Any def no_conversion_needed(self, value): @@ -403,7 +406,7 @@ class NoneConverter(TypeConverter): type_name = 'None' @classmethod - def handles(cls, type_info: TypeInfo) -> bool: + def handles(cls, type_info: 'TypeInfo') -> bool: return type_info.type in (NoneType, None) def _convert(self, value): @@ -419,7 +422,7 @@ class ListConverter(TypeConverter): abc = Sequence value_types = (str, Sequence) - def __init__(self, type_info: TypeInfo, + def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) @@ -446,7 +449,7 @@ def _convert(self, value): def _convert_items(self, value): if not self.converter: return value - return [self.converter.convert(i, v, kind='Item') + return [self.converter.convert(v, name=i, kind='Item') for i, v in enumerate(value)] @@ -456,7 +459,7 @@ class TupleConverter(TypeConverter): type_name = 'tuple' value_types = (str, Sequence) - def __init__(self, type_info: TypeInfo, + def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) @@ -497,12 +500,12 @@ def _convert_items(self, value): return value if self.homogenous: conv = self.converters[0] - return tuple(conv.convert(str(i), v, kind='Item') + return tuple(conv.convert(v, name=str(i), kind='Item') for i, v in enumerate(value)) if len(self.converters) != len(value): raise ValueError(f'Expected {len(self.converters)} ' f'item{s(self.converters)}, got {len(value)}.') - return tuple(conv.convert(i, v, kind='Item') + return tuple(conv.convert(v, name=str(i), kind='Item') for i, (conv, v) in enumerate(zip(self.converters, value))) @@ -511,10 +514,12 @@ class TypedDictConverter(TypeConverter): type = 'TypedDict' value_types = (str, Mapping) - def __init__(self, type_info: TypeInfo, + def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) + # FIXME: Handle TypedDict in TypeInfo + from .typeinfo import TypeInfo self.converters = {n: self.converter_for(TypeInfo.from_type_hint(t), custom_converters, languages) for n, t in type_info.type.__annotations__.items()} @@ -523,7 +528,7 @@ def __init__(self, type_info: TypeInfo, self.required_keys = getattr(type_info.type, '__required_keys__', frozenset()) @classmethod - def handles(cls, type_info: TypeInfo) -> bool: + def handles(cls, type_info: 'TypeInfo') -> bool: return isinstance(type_info.type, typeddict_types) def no_conversion_needed(self, value): @@ -544,7 +549,7 @@ def _convert_items(self, value): not_allowed.append(key) else: if converter: - value[key] = converter.convert(key, value[key], kind='Item') + value[key] = converter.convert(value[key], name=key, kind='Item') if not_allowed: error = f'Item{s(not_allowed)} {seq2str(sorted(not_allowed))} not allowed.' available = [key for key in self.converters if key not in value] @@ -565,7 +570,7 @@ class DictionaryConverter(TypeConverter): type_name = 'dictionary' value_types = (str, Mapping) - def __init__(self, type_info: TypeInfo, + def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) @@ -606,8 +611,7 @@ def _convert_items(self, value): return {convert_key(None, k): convert_value(k, v) for k, v in value.items()} def _get_converter(self, converter, kind): - return lambda name, value: converter.convert(name, value, - kind=kind) + return lambda name, value: converter.convert(value, name, kind=kind) @TypeConverter.register @@ -617,7 +621,7 @@ class SetConverter(TypeConverter): type_name = 'set' value_types = (str, Container) - def __init__(self, type_info: TypeInfo, + def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) @@ -644,8 +648,7 @@ def _convert(self, value): def _convert_items(self, value): if not self.converter: return value - return {self.converter.convert(None, v, kind='Item') - for v in value} + return {self.converter.convert(v, kind='Item') for v in value} @TypeConverter.register @@ -667,7 +670,7 @@ def _convert(self, value): class UnionConverter(TypeConverter): type = Union - def __init__(self, type_info: TypeInfo, + def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) @@ -683,7 +686,7 @@ def type_name(self): return ' or '.join(c.type_name for c in self.converters) @classmethod - def handles(cls, type_info: TypeInfo) -> bool: + def handles(cls, type_info: 'TypeInfo') -> bool: return type_info.is_union def _handles_value(self, value): @@ -707,7 +710,7 @@ def _convert(self, value): for converter in self.converters: if converter: try: - return converter.convert('', value) + return converter.convert(value) except ValueError: pass else: @@ -719,7 +722,7 @@ def _convert(self, value): class CustomConverter(TypeConverter): - def __init__(self, type_info: TypeInfo, + def __init__(self, type_info: 'TypeInfo', converter_info: 'ConverterInfo', languages: LanguagesLike = None): super().__init__(type_info, languages=languages) @@ -751,7 +754,7 @@ def _convert(self, value): class NullConverter: - def convert(self, name, value, kind='Argument'): + def convert(self, value, name, kind='Argument'): return value def no_conversion_needed(self, value): diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index 2f66fb42546..166f32966fd 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -21,8 +21,12 @@ from pathlib import Path from typing import Any, Union +from robot.conf import LanguagesLike from robot.errors import DataError -from robot.utils import has_args, is_union, NOT_SET, type_repr, typeddict_types +from robot.utils import has_args, is_union, NOT_SET, type_repr + +from .customconverters import CustomArgumentConverters +from .typeconverters import TypeConverter TYPE_NAMES = { @@ -143,6 +147,19 @@ def from_sequence(cls, sequence: 'tuple|list') -> 'TypeInfo': return infos[0] return cls('Union', nested=infos) + def convert(self, value: Any, + name: 'str|None' = None, + custom_converters: 'CustomArgumentConverters|dict|None' = None, + languages: 'LanguagesLike' = None, + kind: str = 'Argument'): + if isinstance(custom_converters, dict): + custom_converters = CustomArgumentConverters.from_dict(custom_converters) + converter = TypeConverter.converter_for(self, custom_converters, languages) + if not converter: + # FIXME: Change to TypeError + raise RuntimeError(f"No converter found for '{self}'.") + return converter.convert(value, name, kind) + def __str__(self): if self.is_union: return ' | '.join(str(n) for n in self.nested) diff --git a/utest/running/test_typeinfo.py b/utest/running/test_typeinfo.py index c6ea53b4886..fd460c20d77 100644 --- a/utest/running/test_typeinfo.py +++ b/utest/running/test_typeinfo.py @@ -86,6 +86,67 @@ def test_non_type(self): assert_equal(info.name, str(item)) assert_equal(info.type, None) + def test_conversion(self): + assert_equal(TypeInfo.from_type_hint(int).convert('42'), 42) + assert_equal(TypeInfo.from_type_hint('list[int]').convert('[42]'), [42]) + + def test_failing_conversion(self): + assert_raises_with_msg( + ValueError, + "Argument 'bad' cannot be converted to integer.", + TypeInfo.from_type_hint(int).convert, + 'bad' + ) + assert_raises_with_msg( + ValueError, + "Thingy 't' got value 'bad' that cannot be converted to list[int]: Invalid expression.", + TypeInfo.from_type_hint('list[int]').convert, + 'bad', 't', kind='Thingy' + ) + + def test_custom_converter(self): + class Custom: + def __init__(self, arg: int): + self.arg = arg + + @classmethod + def from_string(cls, value: str): + if not value.isdigit(): + raise ValueError(f'{value} is not good') + return cls(int(value)) + + info = TypeInfo.from_type_hint(Custom) + converters = {Custom: Custom.from_string} + result = info.convert('42', custom_converters=converters) + assert_equal(type(result), Custom) + assert_equal(result.arg, 42) + assert_raises_with_msg( + ValueError, + "Argument 'bad' cannot be converted to Custom: bad is not good", + info.convert, + 'bad', custom_converters=converters + ) + assert_raises_with_msg( + TypeError, + "Custom converters must be callable, converter for Custom is string.", + info.convert, + '42', custom_converters={Custom: 'bad'} + ) + + def test_no_converter(self): + assert_raises_with_msg( + RuntimeError, + "No converter found for 'Unknown'.", + TypeInfo.from_type_hint(type('Unknown', (), {})).convert, + 'whatever' + ) + assert_raises_with_msg( + RuntimeError, + "No converter found for 'unknown[int]'.", + TypeInfo.from_type_hint('unknown[int]').convert, + 'whatever' + ) + class TestTypeInfoParser(unittest.TestCase): From 157917ba431bcee4abc83da4816a71c203bd1a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 3 Oct 2023 01:25:30 +0300 Subject: [PATCH 0724/1592] Validate nested types in TypeInfo, not in converter. This allows using TypeError when there's no suitable converter. --- .../type_conversion/standard_generics.robot | 12 +++ .../type_conversion/stringly_types.robot | 8 +- .../keywords/type_conversion/StringlyTypes.py | 4 + .../type_conversion/standard_generics.robot | 16 +-- .../type_conversion/stringly_types.robot | 4 + .../running/arguments/argumentconverter.py | 4 +- src/robot/running/arguments/typeconverters.py | 18 +--- src/robot/running/arguments/typeinfo.py | 53 ++++++++-- src/robot/utils/robottypes.py | 2 +- utest/running/test_typeinfo.py | 99 +++++++++++++++---- 10 files changed, 167 insertions(+), 53 deletions(-) diff --git a/atest/robot/keywords/type_conversion/standard_generics.robot b/atest/robot/keywords/type_conversion/standard_generics.robot index f71318c83e7..ec228b3f705 100644 --- a/atest/robot/keywords/type_conversion/standard_generics.robot +++ b/atest/robot/keywords/type_conversion/standard_generics.robot @@ -69,12 +69,24 @@ Incompatible nested generics Invalid list Check Test Case ${TESTNAME} + Check Log Message ${ERRORS[1]} + ... Error in library 'StandardGenerics': Adding keyword 'invalid_list' failed: 'list[]' requires exactly 1 argument, 'list[int, float]' has 2. + ... ERROR Invalid tuple Check Test Case ${TESTNAME} + Check Log Message ${ERRORS[3]} + ... Error in library 'StandardGenerics': Adding keyword 'invalid_tuple' failed: Homogenous tuple requires exactly 1 argument, 'tuple[int, float, ...]' has 2. + ... ERROR Invalid dict Check Test Case ${TESTNAME} + Check Log Message ${ERRORS[0]} + ... Error in library 'StandardGenerics': Adding keyword 'invalid_dict' failed: 'dict[]' requires exactly 2 arguments, 'dict[int]' has 1. + ... ERROR Invalid set Check Test Case ${TESTNAME} + Check Log Message ${ERRORS[2]} + ... Error in library 'StandardGenerics': Adding keyword 'invalid_set' failed: 'set[]' requires exactly 1 argument, 'set[int, float]' has 2. + ... ERROR diff --git a/atest/robot/keywords/type_conversion/stringly_types.robot b/atest/robot/keywords/type_conversion/stringly_types.robot index 6640f2adb27..d654d8e0874 100644 --- a/atest/robot/keywords/type_conversion/stringly_types.robot +++ b/atest/robot/keywords/type_conversion/stringly_types.robot @@ -29,6 +29,12 @@ Aliases Invalid Check Test Case ${TESTNAME} - Check Log Message ${ERRORS[0]} + Check Log Message ${ERRORS[1]} ... Error in library 'StringlyTypes': Adding keyword 'invalid' failed: Parsing type 'bad[info' failed: Error at end: Closing ']' missing. ... ERROR + +Bad parameters + Check Test Case ${TESTNAME} + Check Log Message ${ERRORS[0]} + ... Error in library 'StringlyTypes': Adding keyword 'bad_params' failed: 'list[]' requires exactly 1 argument, 'list[int, str]' has 2. + ... ERROR diff --git a/atest/testdata/keywords/type_conversion/StringlyTypes.py b/atest/testdata/keywords/type_conversion/StringlyTypes.py index b4a808ae458..c1ec90422d9 100644 --- a/atest/testdata/keywords/type_conversion/StringlyTypes.py +++ b/atest/testdata/keywords/type_conversion/StringlyTypes.py @@ -33,3 +33,7 @@ def aliases(a: 'sequence[integer]', b: 'MAPPING[STRING, DOUBLE|None]'): def invalid(argument: 'bad[info'): assert False + + +def bad_params(argument: 'list[int, str]'): + assert False diff --git a/atest/testdata/keywords/type_conversion/standard_generics.robot b/atest/testdata/keywords/type_conversion/standard_generics.robot index c9ca5f0279a..d5359d4d4ca 100644 --- a/atest/testdata/keywords/type_conversion/standard_generics.robot +++ b/atest/testdata/keywords/type_conversion/standard_generics.robot @@ -150,17 +150,17 @@ Incompatible nested generics ... error=Item '0' got value '(1, 'x')' (tuple) that cannot be converted to tuple[int, int]: Item '1' got value 'x' that cannot be converted to integer. Invalid list - [Documentation] FAIL TypeError: list[] construct used as a type hint requires exactly 1 nested type, got 2. - Invalid List whatever + [Documentation] FAIL No keyword with name 'Invalid List' found. + Invalid List whatever Invalid tuple - [Documentation] FAIL TypeError: Homogenous tuple used as a type hint requires exactly one nested type, got 2. - Invalid Tuple whatever + [Documentation] FAIL No keyword with name 'Invalid Tuple' found. + Invalid Tuple whatever Invalid dict - [Documentation] FAIL TypeError: dict[] construct used as a type hint requires exactly 2 nested types, got 1. - Invalid Dict whatever + [Documentation] FAIL No keyword with name 'Invalid Dict' found. + Invalid Dict whatever Invalid set - [Documentation] FAIL TypeError: set[] construct used as a type hint requires exactly 1 nested type, got 2. - Invalid set whatever + [Documentation] FAIL No keyword with name 'Invalid Set' found. + Invalid Set whatever diff --git a/atest/testdata/keywords/type_conversion/stringly_types.robot b/atest/testdata/keywords/type_conversion/stringly_types.robot index d757f28757d..9f67335242d 100644 --- a/atest/testdata/keywords/type_conversion/stringly_types.robot +++ b/atest/testdata/keywords/type_conversion/stringly_types.robot @@ -64,3 +64,7 @@ Aliases Invalid [Documentation] FAIL No keyword with name 'Invalid' found. Invalid whatever + +Bad parameters + [Documentation] FAIL No keyword with name 'Bad Params' found. + Bad Params whatever diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 0136f908bd3..bfb0d21b33b 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -76,7 +76,7 @@ def _convert(self, name, value): return info.convert(value, name, self.custom_converters, self.languages) except ValueError as err: conversion_error = err - except RuntimeError: + except TypeError: pass # Try conversion also based on the default value type. We probably should # do this only if there is no explicit type hint, but Python < 3.11 @@ -94,7 +94,7 @@ def _convert(self, name, value): info = TypeInfo.from_type(typ) try: return info.convert(value, name, languages=self.languages) - except (ValueError, RuntimeError): + except (ValueError, TypeError): pass if conversion_error: raise conversion_error diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index ae3771e2dfa..c9119169a1b 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -141,16 +141,6 @@ def _literal_eval(self, value, expected): raise ValueError(f'Value is {type_name(value)}, not {expected.__name__}.') return value - def _get_nested_types(self, type_info: 'TypeInfo', - expected_count: 'int|None' = None): - nested = type_info.nested - # FIXME: Handle nested type validation in TypeInfo. - if nested and expected_count and len(nested) != expected_count: - raise TypeError(f'{type_info.name}[] construct used as a type hint ' - f'requires exactly {expected_count} nested ' - f'type{s(expected_count)}, got {len(nested)}.') - return nested - def _remove_number_separators(self, value): if is_string(value): for sep in ' ', '_': @@ -426,7 +416,7 @@ def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) - nested = self._get_nested_types(type_info, expected_count=1) + nested = type_info.nested if not nested: self.converter = None else: @@ -465,7 +455,7 @@ def __init__(self, type_info: 'TypeInfo', super().__init__(type_info, custom_converters, languages) self.converters = () self.homogenous = False - nested = self._get_nested_types(type_info) + nested = type_info.nested if not nested: return if nested[-1].type is Ellipsis: @@ -574,7 +564,7 @@ def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) - nested = self._get_nested_types(type_info, expected_count=2) + nested = type_info.nested if not nested: self.converters = () else: @@ -625,7 +615,7 @@ def __init__(self, type_info: 'TypeInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) - nested = self._get_nested_types(type_info, expected_count=1) + nested = type_info.nested if not nested: self.converter = None else: diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index 166f32966fd..d0f699d1983 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -14,7 +14,7 @@ # limitations under the License. from enum import auto, Enum -from collections.abc import Sequence +from collections.abc import Mapping, Sequence, Set from datetime import date, datetime, timedelta from decimal import Decimal from dataclasses import dataclass @@ -23,7 +23,8 @@ from robot.conf import LanguagesLike from robot.errors import DataError -from robot.utils import has_args, is_union, NOT_SET, type_repr +from robot.utils import (has_args, is_union, NOT_SET, plural_or_not as s, + setter, SetterAwareType, type_repr) from .customconverters import CustomArgumentConverters from .typeconverters import TypeConverter @@ -31,6 +32,7 @@ TYPE_NAMES = { '...': Ellipsis, + 'ellipsis': Ellipsis, 'any': Any, 'str': str, 'string': str, @@ -63,12 +65,12 @@ } -class TypeInfo: +class TypeInfo(metaclass=SetterAwareType): """Represents argument type. With unions and parametrized types, :attr:`nested` contains nested types. """ - __slots__ = ('name', 'type', 'nested') + __slots__ = ('name', 'type') def __init__(self, name: 'str|None' = None, type: 'type|None' = None, @@ -77,9 +79,43 @@ def __init__(self, name: 'str|None' = None, type = TYPE_NAMES.get(name.lower()) self.name = name self.type = type - self.nested = tuple(nested) - if self.is_union and not nested: - raise DataError('Union used as a type hint cannot be empty.') + self.nested = nested + + @setter + def nested(self, nested: 'Sequence[TypeInfo]') -> 'tuple[TypeInfo, ...]': + if self.is_union: + if not nested: + raise DataError('Union used as a type hint cannot be empty.') + return tuple(nested) + typ = self.type + if typ is None or not nested: + return tuple(nested) + if not isinstance(typ, type): + self._report_nested_error(nested, 0) + elif issubclass(typ, tuple): + if nested[-1].type is Ellipsis and len(nested) != 2: + self._report_nested_error(nested, 1, 'Homogenous tuple', -1) + elif issubclass(typ, Sequence) and not issubclass(typ, (str, bytes, bytearray)): + if len(nested) != 1: + self._report_nested_error(nested, 1) + elif issubclass(typ, Set): + if len(nested) != 1: + self._report_nested_error(nested, 1) + elif issubclass(typ, Mapping): + if len(nested) != 2: + self._report_nested_error(nested, 2) + elif typ in TYPE_NAMES.values(): + self._report_nested_error(nested, 0) + return tuple(nested) + + def _report_nested_error(self, nested, expected, kind=None, offset=0): + args = ', '.join(str(n) for n in nested) + kind = kind or f"'{self.name}{'[]' if expected > 0 else ''}'" + if expected == 0: + raise DataError(f"{kind} does not accept arguments, " + f"'{self.name}[{args}]' has {len(nested) + offset}.") + raise DataError(f"{kind} requires exactly {expected} argument{s(expected)}, " + f"'{self.name}[{args}]' has {len(nested) + offset}.") @property def is_union(self): @@ -156,8 +192,7 @@ def convert(self, value: Any, custom_converters = CustomArgumentConverters.from_dict(custom_converters) converter = TypeConverter.converter_for(self, custom_converters, languages) if not converter: - # FIXME: Change to TypeError - raise RuntimeError(f"No converter found for '{self}'.") + raise TypeError(f"No converter found for '{self}'.") return converter.convert(value, name, kind) def __str__(self): diff --git a/src/robot/utils/robottypes.py b/src/robot/utils/robottypes.py index 8da8d9d357d..b96e09a249a 100644 --- a/src/robot/utils/robottypes.py +++ b/src/robot/utils/robottypes.py @@ -134,7 +134,7 @@ def has_args(type): when we support only Python 3.9 and newer. """ args = getattr(type, '__args__', None) - return args and not all(isinstance(a, TypeVar) for a in args) + return bool(args and not all(isinstance(a, TypeVar) for a in args)) def is_truthy(item): diff --git a/utest/running/test_typeinfo.py b/utest/running/test_typeinfo.py index fd460c20d77..dcb334717c7 100644 --- a/utest/running/test_typeinfo.py +++ b/utest/running/test_typeinfo.py @@ -2,10 +2,10 @@ from datetime import date, datetime, timedelta from decimal import Decimal from pathlib import Path -from typing import Any, Union +from typing import Any, Dict, Generic, List, Mapping, Sequence, Set, Tuple, TypeVar, Union from robot.errors import DataError -from robot.running.arguments.typeinfo import TypeInfo, TypeInfoParser +from robot.running.arguments.typeinfo import TypeInfo, TypeInfoParser, TYPE_NAMES from robot.utils.asserts import assert_equal, assert_raises_with_msg @@ -77,8 +77,77 @@ def test_union_with_one_type_is_reduced_to_the_type(self): def test_empty_union_not_allowed(self): for union in Union, (): - assert_raises_with_msg(DataError, 'Union used as a type hint cannot be empty.', - TypeInfo.from_type_hint, union) + assert_raises_with_msg( + DataError, 'Union used as a type hint cannot be empty.', + TypeInfo.from_type_hint, union + ) + + def test_valid_params(self): + for typ in (List[int], Sequence[int], Set[int], Tuple[int], 'list[int]', + 'SEQUENCE[INT]', 'Set[integer]', 'frozenset[int]', 'tuple[int]'): + info = TypeInfo.from_type_hint(typ) + assert_equal(len(info.nested), 1) + assert_equal(info.nested[0].type, int) + for typ in Dict[int, str], Mapping[int, str], 'dict[int, str]', 'MAP[INT,STR]': + info = TypeInfo.from_type_hint(typ) + assert_equal(len(info.nested), 2) + assert_equal(info.nested[0].type, int) + assert_equal(info.nested[1].type, str) + + def test_invalid_sequence_params(self): + for typ in 'list[int, str]', 'SEQUENCE[x, y]', 'Set[x, y]', 'frozenset[x, y]': + name = typ.split('[')[0] + assert_raises_with_msg( + DataError, + f"'{name}[]' requires exactly 1 argument, '{typ}' has 2.", + TypeInfo.from_type_hint, typ + ) + + def test_invalid_mapping_params(self): + assert_raises_with_msg( + DataError, + "'dict[]' requires exactly 2 arguments, 'dict[int]' has 1.", + TypeInfo.from_type_hint, 'dict[int]' + ) + assert_raises_with_msg( + DataError, + "'Mapping[]' requires exactly 2 arguments, 'Mapping[x, y, z]' has 3.", + TypeInfo.from_type_hint, 'Mapping[x,y,z]' + ) + + def test_invalid_tuple_params(self): + assert_raises_with_msg( + DataError, + "Homogenous tuple requires exactly 1 argument, 'tuple[int, str, ...]' has 2.", + TypeInfo.from_type_hint, 'tuple[int, str, ...]' + ) + assert_raises_with_msg( + DataError, + "Homogenous tuple requires exactly 1 argument, 'tuple[...]' has 0.", + TypeInfo.from_type_hint, 'tuple[...]' + ) + + def test_params_with_invalid_type(self): + for name in TYPE_NAMES: + if TYPE_NAMES[name] not in (list, tuple, dict, set, frozenset): + assert_raises_with_msg( + DataError, + f"'{name}' does not accept arguments, '{name}[int]' has 1.", + TypeInfo.from_type_hint, f'{name}[int]' + ) + + def test_parameters_with_unknown_type(self): + info = TypeInfo.from_type_hint('x[int, float]') + assert_equal([n.type for n in info.nested], [int, float]) + + def test_parameters_with_custom_generic(self): + T = TypeVar('T') + + class Gen(Generic[T]): + pass + + assert_equal(TypeInfo.from_type_hint(Gen[int]).nested[0].type, int) + assert_equal(TypeInfo.from_type_hint(Gen[str]).nested[0].type, str) def test_non_type(self): for item in 42, object(), set(), b'hello': @@ -94,14 +163,12 @@ def test_failing_conversion(self): assert_raises_with_msg( ValueError, "Argument 'bad' cannot be converted to integer.", - TypeInfo.from_type_hint(int).convert, - 'bad' + TypeInfo.from_type_hint(int).convert, 'bad' ) assert_raises_with_msg( ValueError, "Thingy 't' got value 'bad' that cannot be converted to list[int]: Invalid expression.", - TypeInfo.from_type_hint('list[int]').convert, - 'bad', 't', kind='Thingy' + TypeInfo.from_type_hint('list[int]').convert, 'bad', 't', kind='Thingy' ) def test_custom_converter(self): @@ -123,28 +190,24 @@ def from_string(cls, value: str): assert_raises_with_msg( ValueError, "Argument 'bad' cannot be converted to Custom: bad is not good", - info.convert, - 'bad', custom_converters=converters + info.convert, 'bad', custom_converters=converters ) assert_raises_with_msg( TypeError, "Custom converters must be callable, converter for Custom is string.", - info.convert, - '42', custom_converters={Custom: 'bad'} + info.convert, '42', custom_converters={Custom: 'bad'} ) def test_no_converter(self): assert_raises_with_msg( - RuntimeError, + TypeError, "No converter found for 'Unknown'.", - TypeInfo.from_type_hint(type('Unknown', (), {})).convert, - 'whatever' + TypeInfo.from_type_hint(type('Unknown', (), {})).convert, 'whatever' ) assert_raises_with_msg( - RuntimeError, + TypeError, "No converter found for 'unknown[int]'.", - TypeInfo.from_type_hint('unknown[int]').convert, - 'whatever' + TypeInfo.from_type_hint('unknown[int]').convert, 'whatever' ) From 73987d9e91b81d74cfe160ac33fb2242b5839a76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 3 Oct 2023 01:50:39 +0300 Subject: [PATCH 0725/1592] Handle TypedDict items in TypeInfo, not in converter. --- src/robot/running/arguments/typeconverters.py | 20 ++++++++----------- src/robot/running/arguments/typeinfo.py | 19 ++++++++++++++++-- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index c9119169a1b..c483b5e3f16 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -27,12 +27,12 @@ from robot.conf import Languages, LanguagesLike from robot.libraries.DateTime import convert_date, convert_time from robot.utils import (eq, get_error_message, is_string, plural_or_not as s, - safe_str, seq2str, type_name, typeddict_types) + safe_str, seq2str, type_name) if TYPE_CHECKING: from .customconverters import ConverterInfo, CustomArgumentConverters - from .typeinfo import TypeInfo + from .typeinfo import TypeInfo, TypedDictInfo NoneType = type(None) @@ -503,23 +503,19 @@ def _convert_items(self, value): class TypedDictConverter(TypeConverter): type = 'TypedDict' value_types = (str, Mapping) + type_info: 'TypedDictInfo' - def __init__(self, type_info: 'TypeInfo', + def __init__(self, type_info: 'TypedDictInfo', custom_converters: 'CustomArgumentConverters|None' = None, languages: LanguagesLike = None): super().__init__(type_info, custom_converters, languages) - # FIXME: Handle TypedDict in TypeInfo - from .typeinfo import TypeInfo - self.converters = {n: self.converter_for(TypeInfo.from_type_hint(t), - custom_converters, languages) - for n, t in type_info.type.__annotations__.items()} + self.converters = {n: self.converter_for(t, custom_converters, languages) + for n, t in type_info.annotations.items()} self.type_name = type_info.name - # __required_keys__ is new in Python 3.9. - self.required_keys = getattr(type_info.type, '__required_keys__', frozenset()) @classmethod def handles(cls, type_info: 'TypeInfo') -> bool: - return isinstance(type_info.type, typeddict_types) + return type_info.is_typed_dict def no_conversion_needed(self, value): return False @@ -546,7 +542,7 @@ def _convert_items(self, value): if available: error += f' Available item{s(available)}: {seq2str(sorted(available))}' raise ValueError(error) - missing = [key for key in self.required_keys if key not in value] + missing = [key for key in self.type_info.required if key not in value] if missing: raise ValueError(f"Required item{s(missing)} " f"{seq2str(sorted(missing))} missing.") diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index d0f699d1983..af1a2bd50b5 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -23,8 +23,8 @@ from robot.conf import LanguagesLike from robot.errors import DataError -from robot.utils import (has_args, is_union, NOT_SET, plural_or_not as s, - setter, SetterAwareType, type_repr) +from robot.utils import (has_args, is_union, NOT_SET, plural_or_not as s, setter, + SetterAwareType, type_repr, typeddict_types) from .customconverters import CustomArgumentConverters from .typeconverters import TypeConverter @@ -70,6 +70,7 @@ class TypeInfo(metaclass=SetterAwareType): With unions and parametrized types, :attr:`nested` contains nested types. """ + is_typed_dict = False __slots__ = ('name', 'type') def __init__(self, name: 'str|None' = None, @@ -125,6 +126,8 @@ def is_union(self): def from_type_hint(cls, hint: Any) -> 'TypeInfo': if hint is NOT_SET: return cls() + if isinstance(hint, typeddict_types): + return TypedDictInfo(hint.__name__, hint) if isinstance(hint, type): return cls(type_repr(hint), hint) if hint is None: @@ -207,6 +210,18 @@ def __bool__(self): return self.name is not None +class TypedDictInfo(TypeInfo): + is_typed_dict = True + __slots__ = ('annotations', 'required') + + def __init__(self, name: str, type: type): + super().__init__(name, type) + self.annotations = {n: TypeInfo.from_type_hint(t) + for n, t in type.__annotations__.items()} + # __required_keys__ is new in Python 3.9. + self.required = getattr(type, '__required_keys__', frozenset()) + + class TypeInfoTokenType(Enum): NAME = auto() LEFT_SQUARE = auto() From 6668c345d0141d3d485ae80a119ed2f64a35faa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 3 Oct 2023 03:31:43 +0300 Subject: [PATCH 0726/1592] Fix conversion with generics w/ Python 3.9 and 3.10. Interestingly `isinstance(list[int], type)` is true with Python 3.9 and 3.10 but false with newer. --- src/robot/running/arguments/typeinfo.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index af1a2bd50b5..14e23280b01 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -128,6 +128,12 @@ def from_type_hint(cls, hint: Any) -> 'TypeInfo': return cls() if isinstance(hint, typeddict_types): return TypedDictInfo(hint.__name__, hint) + if hasattr(hint, '__origin__'): + if has_args(hint): + nested = [cls.from_type_hint(t) for t in hint.__args__] + else: + nested = [] + return cls(type_repr(hint, nested=False), hint.__origin__, nested) if isinstance(hint, type): return cls(type_repr(hint), hint) if hint is None: @@ -141,12 +147,6 @@ def from_type_hint(cls, hint: Any) -> 'TypeInfo': return cls('Union', nested=nested) if isinstance(hint, (tuple, list)): return cls.from_sequence(hint) - if hasattr(hint, '__origin__'): - if has_args(hint): - nested = [cls.from_type_hint(t) for t in hint.__args__] - else: - nested = [] - return cls(type_repr(hint, nested=False), hint.__origin__, nested) if hint is Union: return cls('Union') if hint is Any: From 0cb8bce6ef2a84b60c4cb9610d283b56f43695ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 15:14:12 +0300 Subject: [PATCH 0727/1592] Bump actions/setup-python from 4.6.1 to 4.7.1 (#4882) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.6.1 to 4.7.1. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.6.1...v4.7.1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index af2acbf9dfa..7227b614a1f 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.1 with: python-version: '3.10' architecture: 'x64' @@ -49,7 +49,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 3144764e5ba..cbf6e97f036 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python for starting the tests - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.1 with: python-version: '3.11' 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@v4.6.1 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index c6fccb3076f..a15f14703c0 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.1 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 8d864ec3370..3b32f879f3f 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From ac8549a5be942db160cdfa0ff1448ee2f79b8904 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 15:14:26 +0300 Subject: [PATCH 0728/1592] Bump actions/checkout from 3 to 4 (#4855) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/acceptance_tests_cpython_pr.yml | 2 +- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 7227b614a1f..5265e5f1e1a 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -32,7 +32,7 @@ jobs: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup python for starting the tests uses: actions/setup-python@v4.7.1 diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index cbf6e97f036..c0706e79c1e 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -26,7 +26,7 @@ jobs: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup python for starting the tests uses: actions/setup-python@v4.7.1 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index a15f14703c0..273c1937e06 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -28,7 +28,7 @@ jobs: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup python ${{ matrix.python-version }} uses: actions/setup-python@v4.7.1 diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml index 3b32f879f3f..7cd7dc9ef15 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -21,7 +21,7 @@ jobs: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup python ${{ matrix.python-version }} uses: actions/setup-python@v4.7.1 From 93073af595f1c685d9b7416a01e0e378e420f4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= Date: Sat, 9 Sep 2023 09:04:57 +0300 Subject: [PATCH 0729/1592] model: remove deprecated attributes .keywords and .children have been deprecated since RF 4.0 .critical has also been deprecated a long time Part of #4846 --- src/robot/model/__init__.py | 2 +- src/robot/model/control.py | 10 ---- src/robot/model/keyword.py | 93 -------------------------------- src/robot/model/testcase.py | 15 +----- src/robot/model/testsuite.py | 15 +----- src/robot/result/model.py | 31 +---------- src/robot/running/model.py | 18 +------ utest/model/test_keyword.py | 10 +--- utest/model/test_testcase.py | 9 ---- utest/model/test_testsuite.py | 9 ---- utest/result/test_resultmodel.py | 11 ---- utest/running/test_run_model.py | 13 ----- 12 files changed, 6 insertions(+), 230 deletions(-) diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index 168bd8ecfa7..dd69a8dde7d 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -30,7 +30,7 @@ from .control import Break, Continue, Error, For, If, IfBranch, Return, Try, TryBranch, While from .fixture import create_fixture from .itemlist import ItemList -from .keyword import Keyword, Keywords +from .keyword import Keyword from .message import Message, MessageLevel, Messages from .modelobject import DataDict, ModelObject from .modifier import ModelModifier diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 3069bbdb144..dd897a45c89 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -19,7 +19,6 @@ from robot.utils import setter from .body import Body, BodyItem, BodyItemParent, BaseBranches -from .keyword import Keywords from .modelobject import DataDict from .visitor import SuiteVisitor @@ -79,15 +78,6 @@ def variables(self, assign: 'tuple[str, ...]'): def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) - @property - def keywords(self): - """Deprecated since Robot Framework 4.0. Use :attr:`body` instead.""" - return Keywords(self, self.body) - - @keywords.setter - def keywords(self, keywords): - Keywords.raise_deprecation_error() - def visit(self, visitor: SuiteVisitor): visitor.visit_for(self) diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index e96beff0035..c141ac00339 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -78,96 +78,3 @@ def to_dict(self) -> DataDict: if self.assign: data['assign'] = self.assign return data - - -# FIXME: Remote in RF 7. -class Keywords(ItemList[BodyItem]): - """A list-like object representing keywords in a suite, a test or a keyword. - - Read-only and deprecated since Robot Framework 4.0. - """ - __slots__ = [] - deprecation_message = ( - "'keywords' attribute is read-only and deprecated since Robot Framework 4.0. " - "Use 'body', 'setup' or 'teardown' instead." - ) - - def __init__(self, parent: BodyItemParent = None, - keywords: Sequence[BodyItem] = ()): - warnings.warn(self.deprecation_message, UserWarning) - ItemList.__init__(self, object, {'parent': parent}) - if keywords: - ItemList.extend(self, keywords) - - @property - def setup(self) -> 'Keyword|None': - if self and self[0].type == 'SETUP': - return cast(Keyword, self[0]) - return None - - @setup.setter - def setup(self, kw): - self.raise_deprecation_error() - - def create_setup(self, *args, **kwargs): - self.raise_deprecation_error() - - @property - def teardown(self) -> 'Keyword|None': - if self and self[-1].type == 'TEARDOWN': - return cast(Keyword, self[-1]) - return None - - @teardown.setter - def teardown(self, kw: Keyword): - self.raise_deprecation_error() - - def create_teardown(self, *args, **kwargs): - self.raise_deprecation_error() - - @property - def all(self) -> 'Keywords': - """Iterates over all keywords, including setup and teardown.""" - return self - - @property - def normal(self) -> 'list[BodyItem]': - """Iterates over normal keywords, omitting setup and teardown.""" - return [kw for kw in self if kw.type not in ('SETUP', 'TEARDOWN')] - - def __setitem__(self, index: int, item: Keyword): - self.raise_deprecation_error() - - def create(self, *args, **kwargs): - self.raise_deprecation_error() - - def append(self, item: Keyword): - self.raise_deprecation_error() - - def extend(self, items: Sequence[Keyword]): - self.raise_deprecation_error() - - def insert(self, index: int, item: Keyword): - self.raise_deprecation_error() - - def pop(self, *index: int): - self.raise_deprecation_error() - - def remove(self, item: Keyword): - self.raise_deprecation_error() - - def clear(self): - self.raise_deprecation_error() - - def __delitem__(self, index: int): - self.raise_deprecation_error() - - def sort(self): - self.raise_deprecation_error() - - def reverse(self): - self.raise_deprecation_error() - - @classmethod - def raise_deprecation_error(cls: 'Type[Keywords]'): - raise AttributeError(cls.deprecation_message) diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index aacd2ffee02..8b91dead6d8 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -21,7 +21,7 @@ from .body import Body, BodyItem from .fixture import create_fixture from .itemlist import ItemList -from .keyword import Keyword, Keywords +from .keyword import Keyword from .modelobject import DataDict, ModelObject from .tags import Tags @@ -143,19 +143,6 @@ def has_teardown(self) -> bool: """ return bool(self._teardown) - @property - def keywords(self) -> Keywords: - """Deprecated since Robot Framework 4.0 - - Use :attr:`body`, :attr:`setup` or :attr:`teardown` instead. - """ - keywords = [self.setup] + list(self.body) + [self.teardown] - return Keywords(self, [kw for kw in keywords if kw]) - - @keywords.setter - def keywords(self, keywords): - Keywords.raise_deprecation_error() - @property def id(self) -> str: """Test case id in format like ``s1-t3``. diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index 2f9603b0f4a..bee93933632 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -24,7 +24,7 @@ from .filter import Filter, EmptySuiteRemover from .fixture import create_fixture from .itemlist import ItemList -from .keyword import Keyword, Keywords +from .keyword import Keyword from .metadata import Metadata from .modelobject import DataDict, ModelObject from .tagsetter import TagSetter @@ -308,19 +308,6 @@ def has_teardown(self) -> bool: """ return bool(self._teardown) - @property - def keywords(self) -> Keywords: - """Deprecated since Robot Framework 4.0. - - Use :attr:`setup` or :attr:`teardown` instead. - """ - keywords = [self.setup, self.teardown] - return Keywords(self, [kw for kw in keywords if kw]) - - @keywords.setter - def keywords(self, keywords): - Keywords.raise_deprecation_error() - @property def id(self) -> str: """An automatically generated unique id. diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 26db0951d18..74e4433ce3f 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -43,7 +43,7 @@ from typing import Generic, Literal, Mapping, Sequence, Type, Union, TypeVar from robot import model -from robot.model import (BodyItem, create_fixture, DataDict, Keywords, Tags, +from robot.model import (BodyItem, create_fixture, DataDict, Tags, SuiteVisitor, TotalStatistics, TotalStatisticsBuilder, TestSuites) from robot.utils import copy_signature, KnownAtRuntime, setter @@ -761,21 +761,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - def keywords(self) -> Keywords: # FIXME: Remove in RF 7. - """Deprecated since Robot Framework 4.0. - - Use :attr:`body` or :attr:`teardown` instead. - """ - keywords = self.body.filter(messages=False) - if self.teardown: - keywords.append(self.teardown) - return Keywords(self, keywords) - - @keywords.setter - def keywords(self, keywords): - Keywords.raise_deprecation_error() - @property def messages(self) -> 'list[Message]': """Keyword's messages. @@ -785,15 +770,6 @@ def messages(self) -> 'list[Message]': """ return self.body.filter(messages=True) # type: ignore - @property - def children(self) -> 'list[BodyItem]': # FIXME: Remove in RF 7. - """List of child keywords and messages in creation order. - - Deprecated since Robot Framework 4.0. Use :attr:`body` instead. - """ - warnings.warn("'Keyword.children' is deprecated. Use 'Keyword.body' instead.") - return list(self.body) - @property def name(self) -> 'str|None': """Keyword name in format ``libname.kwname``. @@ -921,11 +897,6 @@ def _elapsed_time_from_children(self) -> timedelta: def not_run(self) -> bool: return False - @property - def critical(self) -> bool: # FIXME: Remove in RF 7. - warnings.warn("'TestCase.critical' is deprecated and always returns 'True'.") - return True - @setter def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Test body as a :class:`~robot.result.Body` object.""" diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 9241234cfd7..c956c0d9756 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -41,8 +41,7 @@ from robot import model from robot.conf import RobotSettings from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword -from robot.model import (BodyItem, create_fixture, DataDict, Keywords, ModelObject, - TestSuites) +from robot.model import (BodyItem, create_fixture, DataDict, ModelObject, TestSuites) from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, Error as ErrorResult, Return as ReturnResult) @@ -787,21 +786,6 @@ def __init__(self, name: str = '', def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return Body(self, body) - @property - def keywords(self) -> Keywords: - """Deprecated since Robot Framework 4.0. - - Use :attr:`body` or :attr:`teardown` instead. - """ - kws = list(self.body) - if self.teardown: - kws.append(self.teardown) - return Keywords(self, kws) - - @keywords.setter - def keywords(self, keywords): - Keywords.raise_deprecation_error() - @property def teardown(self) -> Keyword: if self._teardown is None: diff --git a/utest/model/test_keyword.py b/utest/model/test_keyword.py index cc727934e8c..e34ffd52d47 100644 --- a/utest/model/test_keyword.py +++ b/utest/model/test_keyword.py @@ -1,7 +1,7 @@ import unittest import warnings -from robot.model import TestSuite, TestCase, Keyword, Keywords +from robot.model import TestSuite, TestCase, Keyword from robot.utils.asserts import (assert_equal, assert_not_equal, assert_true, assert_raises) @@ -122,13 +122,5 @@ def test_copy_and_deepcopy_with_non_existing_attributes(self): assert_raises(AttributeError, Keyword().deepcopy, bad='attr') -class TestKeywords(unittest.TestCase): - - def test_deprecation(self): - with warnings.catch_warnings(record=True) as w: - Keywords() - assert_true('deprecated' in str(w[0].message)) - - if __name__ == '__main__': unittest.main() diff --git a/utest/model/test_testcase.py b/utest/model/test_testcase.py index 72815175a25..1008304e661 100644 --- a/utest/model/test_testcase.py +++ b/utest/model/test_testcase.py @@ -100,15 +100,6 @@ def test_deepcopy_with_attributes(self): assert_equal(copy.name, 'New') assert_equal(copy.doc, 'New') - def test_keywords_deprecation(self): - self.test.body = [Keyword(), Keyword(), Keyword()] - with warnings.catch_warnings(record=True) as w: - kws = self.test.keywords - assert_equal(len(kws), 3) - assert_true('deprecated' in str(w[0].message)) - assert_raises(AttributeError, kws.append, Keyword()) - assert_raises(AttributeError, setattr, self.test, 'keywords', []) - class TestStringRepresentation(unittest.TestCase): diff --git a/utest/model/test_testsuite.py b/utest/model/test_testsuite.py index ab1bba6b7f6..9c65e3dff49 100644 --- a/utest/model/test_testsuite.py +++ b/utest/model/test_testsuite.py @@ -189,15 +189,6 @@ def test_configure_only_works_with_root_suite(self): def test_slots(self): assert_raises(AttributeError, setattr, self.suite, 'attr', 'value') - def test_keywords_deprecation(self): - self.suite.setup.config(name='S') - with warnings.catch_warnings(record=True) as w: - kws = self.suite.keywords - assert_equal(len(kws), 1) - assert_true('deprecated' in str(w[0].message)) - assert_raises(AttributeError, kws.extend, ()) - assert_raises(AttributeError, setattr, self.suite, 'keywords', []) - class TestSuiteId(unittest.TestCase): diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 8d21460e81e..0de943b1d1c 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -407,17 +407,6 @@ def test_keyword_teardown(self): assert_equal(kw.teardown.name, None) assert_equal(kw.teardown.type, 'TEARDOWN') - def test_keywords_deprecation(self): - kw = Keyword() - kw.body = [Keyword(), Message(), Keyword(), Keyword(), Message()] - kw.teardown.config(kwname='T') - with warnings.catch_warnings(record=True) as w: - kws = kw.keywords - assert_equal(list(kws), [kw.body[0], kw.body[2], kw.body[3], kw.teardown]) - assert_true('deprecated' in str(w[0].message)) - assert_raises(AttributeError, kws.append, Keyword()) - assert_raises(AttributeError, setattr, kw, 'keywords', []) - def test_for_parents(self): test = TestCase() for_ = test.body.create_for() diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 21de2e43529..a050c960f10 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -43,19 +43,6 @@ def test_test_case_keyword(self): assert_not_equal(type(kw), model.Keyword) -class TestUserKeyword(unittest.TestCase): - - def test_keywords_deprecation(self): - uk = UserKeyword('Name') - uk.body.create_keyword() - uk.teardown.config(name='T') - with warnings.catch_warnings(record=True) as w: - kws = uk.keywords - assert_equal(len(kws), 2) - assert_true('deprecated' in str(w[0].message)) - assert_raises(AttributeError, kws.append, Keyword()) - assert_raises(AttributeError, setattr, uk, 'keywords', []) - class TestSuiteFromSources(unittest.TestCase): path = Path(os.getenv('TEMPDIR') or tempfile.gettempdir(), From 7487fa1bba0a8b0ea3cac234904770606fcd643c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= Date: Thu, 14 Sep 2023 21:46:36 +0300 Subject: [PATCH 0730/1592] jsmodelbuilder: do not use deprecated attrs part of #4846 --- .../remove_keywords/for_loop_keywords.robot | 3 +- src/robot/reporting/jsmodelbuilders.py | 85 +++++++++++++++---- src/robot/result/model.py | 50 +++-------- utest/reporting/test_jsmodelbuilders.py | 8 +- utest/result/test_resultmodel.py | 31 +++---- 5 files changed, 98 insertions(+), 79 deletions(-) diff --git a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot index aa8b1c29403..d316bc0687a 100644 --- a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot @@ -21,7 +21,8 @@ Failed Steps Are Not Removed ${tc}= Check Test Case Failure inside FOR 2 Length Should Be ${tc.kws[0].kws} 1 Should Be Equal ${tc.kws[0].doc} ${3 REMOVED} - Should Be Equal ${tc.kws[0].kws[0].name} \${num} = 4 + Should Be Equal ${tc.kws[0].kws[0].__str__()} \${num} = 4 + Should Be Equal ${tc.kws[0].kws[0].type} ITERATION Should Be Equal ${tc.kws[0].kws[0].status} FAIL Length Should Be ${tc.kws[0].kws[0].kws} 3 Should Be Equal ${tc.kws[0].kws[0].kws[-1].status} NOT RUN diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index 29600f5068b..eb535a6558c 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -12,12 +12,15 @@ # 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 typing import Sequence from robot.output import LEVELS +from robot.result import (Break, Continue, Error, For, ForIteration, IfBranch, + Keyword, Return, TryBranch, While, WhileIteration) from .jsbuildingcontext import JsBuildingContext from .jsexecutionresult import JsExecutionResult - +from ..model import BodyItem STATUSES = {'FAIL': 0, 'PASS': 1, 'SKIP': 2, 'NOT RUN': 3} KEYWORD_TYPES = {'KEYWORD': 0, 'SETUP': 1, 'TEARDOWN': 2, @@ -151,23 +154,73 @@ def build(self, item, split=False): def build_keyword(self, kw, split=False): self._context.check_expansion(kw) + with self._context.prune_input(kw.body): + if isinstance (kw, Keyword): + return self.build_kw(kw, split) + elif isinstance(kw, For): + return self.build_for(kw, split) + elif isinstance(kw, ForIteration): + return self.build_for_iteration(kw, split) + elif isinstance(kw, IfBranch): + return self.build_if_branch(kw, split) + elif isinstance(kw, Return): + return self.build_return(kw, split) + elif isinstance(kw, TryBranch): + return self.build_try_branch(kw, split) + elif isinstance(kw, While): + return self.build_while(kw, split) + elif isinstance(kw, WhileIteration): + return self._build(kw, split=split) + elif isinstance(kw, Continue): + return self._build(kw, split=split) + elif isinstance(kw, Break): + return self._build(kw, split=split) + elif isinstance(kw, Error): + return self.build_error(kw, split=split) + + def build_kw(self, kw: Keyword, split: bool): items = kw.body.flatten() - if getattr(kw, 'has_teardown', False): + if kw.has_teardown: items.append(kw.teardown) - with self._context.prune_input(kw.body): - # Hack to avoid new `For.assign` or `Try.assign` to be used here. - # Can be removed when building doesn't expect everything to be keywords. - assign = kw.assign if kw.type in ('KEYWORD', 'SETUP', 'TEARDOWN') else () - return (KEYWORD_TYPES[kw.type], - self._string(kw.kwname, attr=True), - self._string(kw.libname, attr=True), - self._string(kw.timeout), - self._html(kw.doc), - self._string(', '.join(kw.args)), - self._string(', '.join(assign)), - self._string(', '.join(kw.tags)), - self._get_status(kw), - self._build_keywords(items, split)) + return self._build(kw, kw.kwname, kw.libname, kw.timeout, kw.doc, kw.args, kw.assign, kw.tags, split=split) + + def build_for(self, for_: For, split: bool): + return self._build(for_, str(for_), split=split) + + def build_for_iteration(self, iter: ForIteration, split: bool): + return self._build(iter, str(iter), split=split) + + def build_if_branch(self, if_: IfBranch, split: bool): + return self._build(if_, if_.condition or '', split=split) + + def build_return(self, return_: Return, split: bool): + return self._build(return_, args=return_.values, split=split) + + def build_try_branch(self, branch: TryBranch, split: bool): + return self._build(branch, str(branch), split=split) + + def build_while(self, while_: While, split: bool): + return self._build(while_, str(while_), split=split) + + def build_while_iteration(self, iter: WhileIteration, split: bool): + return self._build(iter, split=split) + + def build_error(self, error: Error, split: bool): + return self._build(error, error.values[0], args=error.values[1:], split=split) + + def _build(self, kw, kwname: str = '', libname: str = '', timeout: str = '', doc: str = '', + args: Sequence[str] = (), assign: Sequence[str] = (), tags: Sequence[str] = (), + items: 'Sequence[BodyItem]|None' = None, split: bool = False): + return (KEYWORD_TYPES[kw.type], + self._string(kwname, attr=True), + self._string(libname, attr=True), + self._string(timeout), + self._html(kw.doc), + self._string(', '.join(args)), + self._string(', '.join(assign)), + self._string(', '.join(tags)), + self._get_status(kw), + self._build_keywords(items if items is not None else kw.body.flatten(), split)) class MessageBuilder(_Builder): diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 74e4433ce3f..b5803182763 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -324,7 +324,10 @@ def visit(self, visitor: SuiteVisitor): @property @deprecated - def name(self) -> str: + def name(self): + return str(self) + + def __str__(self): return ', '.join('%s = %s' % item for item in self.assign.items()) @@ -359,7 +362,10 @@ def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> iterations_clas @property @deprecated - def name(self) -> str: + def name(self): + return str(self) + + def __str__(self): assign = ' | '.join(self.assign) values = ' | '.join(self.values) for name, value in [('start', self.start), @@ -397,11 +403,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def visit(self, visitor: SuiteVisitor): visitor.visit_while_iteration(self) - @property - @deprecated - def name(self) -> str: - return '' - @Body.register class While(model.While, StatusMixin, DeprecatedAttributesMixin): @@ -430,9 +431,7 @@ def __init__(self, condition: 'str|None' = None, def body(self, iterations: 'Sequence[WhileIteration|DataDict]') -> iterations_class: return self.iterations_class(self.iteration_class, self, iterations) - @property - @deprecated - def name(self) -> str: + def __str__(self): parts = [] if self.condition: parts.append(self.condition) @@ -513,7 +512,10 @@ def __init__(self, type: str = BodyItem.TRY, @property @deprecated - def name(self) -> str: + def name(self): + return str(self) + + def __str__(self): patterns = list(self.patterns) if self.pattern_type: patterns.append(f'type={self.pattern_type}') @@ -524,7 +526,6 @@ def name(self) -> str: parts.append(f'AS {self.assign}') return ' '.join(parts) - @Body.register class Try(model.Try, StatusMixin, DeprecatedAttributesMixin): branch_class = TryBranch @@ -573,11 +574,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - @deprecated - def args(self) -> 'tuple[str, ...]': - return self.values - @property @deprecated def doc(self) -> str: @@ -611,11 +607,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - @deprecated - def args(self) -> 'tuple[str, ...]': - return () - @property @deprecated def doc(self) -> str: @@ -649,11 +640,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - @deprecated - def args(self) -> 'tuple[str, ...]': - return () - @property @deprecated def doc(self) -> str: @@ -686,16 +672,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - @deprecated - def kwname(self) -> str: - return self.values[0] - - @property - @deprecated - def args(self) -> 'tuple[str, ...]': - return self.values[1:] - @property @deprecated def doc(self) -> 'str': diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index e64b560e337..ca8cc0fdd8f 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -4,7 +4,7 @@ from pathlib import Path from robot.utils.asserts import assert_equal, assert_true -from robot.result import Keyword, Message, TestCase, TestSuite +from robot.result import Keyword, Message, TestCase, TestSuite, For from robot.result.executionerrors import ExecutionErrors from robot.model import Statistics, BodyItem from robot.reporting.jsmodelbuilders import ( @@ -135,11 +135,11 @@ def test_nested_structure(self): suite.tests = [TestCase(), TestCase(status='PASS')] S1 = self._verify_suite(suite.suites[0], status=0, tests=(t,), stats=(1, 0, 1, 0)) - suite.tests[0].body = [Keyword(type=Keyword.FOR), Keyword()] + suite.tests[0].body = [For(assign=['${x}'], flavor='IN', values=['1', '2']), Keyword()] suite.tests[0].body[0].body = [Keyword(type=Keyword.ITERATION), Message()] k = self._verify_keyword(suite.tests[0].body[0].body[0], type=4) - m = self._verify_message(suite.tests[0].body[0].messages[0]) - k1 = self._verify_keyword(suite.tests[0].body[0], type=3, body=(k, m)) + m = self._verify_message(suite.tests[0].body[0].body[1]) + k1 = self._verify_keyword(suite.tests[0].body[0], type=3, body=(k, m), kwname='${x} IN [ 1 | 2 ]') suite.tests[0].body[1].body = [Message(), Message('msg', level='TRACE')] m1 = self._verify_message(suite.tests[0].body[1].messages[0]) m2 = self._verify_message(suite.tests[0].body[1].messages[1], 'msg', level=0) diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 0de943b1d1c..a9ead921047 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -437,16 +437,16 @@ def test_if_parents(self): kw = branch.body.create_keyword() assert_equal(kw.parent, branch) - def test_while_name(self): - assert_equal(While().name, '') - assert_equal(While('$x > 0').name, '$x > 0') - assert_equal(While('True', '1 minute').name, 'True | limit=1 minute') - assert_equal(While(limit='1 minute').name, 'limit=1 minute') - assert_equal(While('True', '1 s', on_limit_message='Error message').name, + def test_while_str(self): + assert_equal(str(While()), '') + assert_equal(str(While('$x > 0')), '$x > 0') + assert_equal(str(While('True', '1 minute')), 'True | limit=1 minute') + assert_equal(str(While(limit='1 minute')), 'limit=1 minute') + assert_equal(str(While('True', '1 s', on_limit_message='Error message')), 'True | limit=1 s | on_limit_message=Error message') - assert_equal(While(on_limit='pass').name, + assert_equal(str(While(on_limit='pass')), 'on_limit=pass') - assert_equal(While(on_limit_message='Error message').name, + assert_equal(str(While(on_limit_message='Error message')), 'on_limit_message=Error message') @@ -549,29 +549,18 @@ class TestDeprecatedKeywordSpecificAttributes(unittest.TestCase): def test_deprecated_keyword_specific_properties(self): for_ = For(['${x}', '${y}'], 'IN', ['a', 'b', 'c', 'd']) - for name, expected in [('name', '${x} | ${y} IN [ a | b | c | d ]'), - ('args', ()), + for name, expected in [('args', ()), ('tags', Tags()), ('timeout', None)]: assert_equal(getattr(for_, name), expected) def test_if(self): - for name, expected in [('name', ''), - ('args', ()), + for name, expected in [('args', ()), ('assign', ()), ('tags', Tags()), ('timeout', None)]: assert_equal(getattr(If(), name), expected) - def test_if_branch(self): - branch = IfBranch(IfBranch.IF, '$x > 0') - for name, expected in [('name', '$x > 0'), - ('args', ()), - ('assign', ()), - ('tags', Tags()), - ('timeout', None)]: - assert_equal(getattr(branch, name), expected) - if __name__ == '__main__': unittest.main() From 75a36863760e3f8cc4b8edac46095d5db951f8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= Date: Mon, 18 Sep 2023 22:23:54 +0300 Subject: [PATCH 0731/1592] stringcache: add TODO --- src/robot/reporting/stringcache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/reporting/stringcache.py b/src/robot/reporting/stringcache.py index 20ea35102f2..0a0d6bcbcc5 100644 --- a/src/robot/reporting/stringcache.py +++ b/src/robot/reporting/stringcache.py @@ -15,7 +15,7 @@ from robot.utils import compress_text, html_format - +# TODO: can this be removed? class StringIndex(int): pass From ccc188ff573e445a3cc7fa413a24637fdd5aad82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= Date: Sun, 24 Sep 2023 20:37:25 +0300 Subject: [PATCH 0732/1592] resultmodel: add depreaction warnings Add warnings to previously deprecated result model attributes `name`, `kwname`, `libname`, `args`, `assign`, `tags`, and `timeout` --- .../parsing/same_setting_multiple_times.robot | 3 ++- .../robot/running/if/invalid_inline_if.robot | 3 ++- atest/robot/variables/return_values.robot | 4 ++-- .../listeners/VerifyAttributes.py | 6 +++-- src/robot/model/body.py | 4 ++++ src/robot/output/listenerarguments.py | 16 +++++++++---- src/robot/output/output.py | 4 ++-- src/robot/output/xmllogger.py | 12 ++++++---- src/robot/result/model.py | 24 ++++--------------- src/robot/result/modeldeprecation.py | 4 +++- src/robot/running/modelcombiner.py | 18 ++++++++------ src/robot/running/statusreporter.py | 3 ++- utest/result/test_resultmodel.py | 10 ++++++-- 13 files changed, 64 insertions(+), 47 deletions(-) diff --git a/atest/robot/parsing/same_setting_multiple_times.robot b/atest/robot/parsing/same_setting_multiple_times.robot index 7daa2b9718d..50beeddefd3 100644 --- a/atest/robot/parsing/same_setting_multiple_times.robot +++ b/atest/robot/parsing/same_setting_multiple_times.robot @@ -36,7 +36,8 @@ Test Timeout Test [Documentation] ${tc} = Check Test Case Test Settings - Check Keyword Data ${tc.kws[0]} ${EMPTY} type=ERROR status=FAIL + Should Be Equal ${tc.kws[0].type} ERROR + Should Be Equal ${tc.kws[0].status} FAIL Should Be Equal ${tc.kws[0].values[0]} [Documentation] Test [Tags] diff --git a/atest/robot/running/if/invalid_inline_if.robot b/atest/robot/running/if/invalid_inline_if.robot index d725e09663e..01ea3768dd0 100644 --- a/atest/robot/running/if/invalid_inline_if.robot +++ b/atest/robot/running/if/invalid_inline_if.robot @@ -68,7 +68,8 @@ Invalid END after inline header Check IF/ELSE Status PASS root=${tc.body[0]} Check Log Message ${tc.body[0].body[0].body[0].body[0]} Executed inside inline IF Check Log Message ${tc.body[1].body[0]} Executed outside IF - Check Keyword Data ${tc.body[2]} ${EMPTY} type=ERROR status=FAIL + Should Be Equal ${tc.body[2].type} ERROR + Should Be Equal ${tc.body[2].status} FAIL Assign in IF branch FAIL diff --git a/atest/robot/variables/return_values.robot b/atest/robot/variables/return_values.robot index b18c774ac61..f1aea36ac88 100644 --- a/atest/robot/variables/return_values.robot +++ b/atest/robot/variables/return_values.robot @@ -83,9 +83,9 @@ Unrepresentable objects to list variables ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} \@{unrepr} = ? ${UNREPR} | ${UNREPR} ? pattern=yes Check Log Message ${tc.kws[0].msgs[0]} \@{unrepr} = ? ${UNREPR} | ${UNREPR} ? pattern=yes - Should Match ${tc.kws[2].kws[0].name} \${obj} = ${UNREPR} + Should Match ${tc.kws[2].kws[0].__str__()} \${obj} = ${UNREPR} Check Log Message ${tc.kws[2].kws[0].kws[1].msgs[0]} $\{var} = ${UNREPR} pattern=yes - Should Match ${tc.kws[2].kws[1].name} \${obj} = ${UNREPR} + Should Match ${tc.kws[2].kws[1].__str__()} \${obj} = ${UNREPR} Check Log Message ${tc.kws[2].kws[1].kws[1].msgs[0]} $\{var} = ${UNREPR} pattern=yes None To List Variable diff --git a/atest/testresources/listeners/VerifyAttributes.py b/atest/testresources/listeners/VerifyAttributes.py index d3470d9566e..2409af77096 100644 --- a/atest/testresources/listeners/VerifyAttributes.py +++ b/atest/testresources/listeners/VerifyAttributes.py @@ -120,7 +120,8 @@ def start_keyword(self, name, attrs): if type_ == 'FOR': extra += FOR_FLAVOR_EXTRA.get(attrs['flavor'], '') verify_attrs('START ' + type_, attrs, START + KW + extra) - verify_name(name, **attrs) + if type_ in ('KEYWORD', 'SETUP', 'TEARDOWN'): + verify_name(name, **attrs) self._keyword_stack.append(type_) def end_keyword(self, name, attrs): @@ -132,7 +133,8 @@ def end_keyword(self, name, attrs): if type_ == 'FOR': extra += FOR_FLAVOR_EXTRA.get(attrs['flavor'], '') verify_attrs('END ' + type_, attrs, END + KW + extra) - verify_name(name, **attrs) + if type_ in ('KEYWORD', 'SETUP', 'TEARDOWN'): + verify_name(name, **attrs) def close(self): OUTFILE.close() diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 151010f1e63..21709dff3ff 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -107,6 +107,10 @@ def _get_id(self, parent: 'BodyItemParent|ResourceFile') -> str: parent_id = getattr(parent, 'id', None) return f'{parent_id}-k{index + 1}' if parent_id else f'k{index + 1}' + @property + def keyword_types(self): + return self.KEYWORD, self.SETUP, self.TEARDOWN + def to_dict(self) -> DataDict: raise NotImplementedError diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 4eec92f0cb0..54966bfa618 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -149,10 +149,18 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): def _get_extra_attributes(self, kw): # FOR and TRY model objects use `assign` starting from RF 7.0, but for # backwards compatibility reasons we pass them as `variable(s)`. - assign = kw.assign if kw.type in ('KEYWORD', 'SETUP', 'TEARDOWN') else () - attrs = {'kwname': kw.kwname or '', - 'libname': kw.libname or '', - 'args': [a if is_string(a) else safe_str(a) for a in kw.args], + assign = kw.assign if kw.type in kw.keyword_types else () + if kw.type in ('FOR', 'ITERATION', 'TRY', 'EXCEPT', 'FINALLY'): + kwname = str(kw.result) + libname = '' + args = [] + else: + kwname = kw.kwname or '' + libname = kw.libname or '' + args = [a if is_string(a) else safe_str(a) for a in kw.args] + attrs = {'kwname': kwname, + 'libname': libname, + 'args': args, 'assign': list(assign), 'source': str(kw.source or '')} if kw.type in self._type_attributes: diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 45e7f26a74a..4bca528d0e4 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -69,13 +69,13 @@ def end_test(self, test): def start_keyword(self, kw): LOGGER.start_keyword(kw) - if kw.tags.robot('flatten'): + if kw.type in kw.keyword_types and kw.tags.robot('flatten'): self._flatten_level += 1 if self._flatten_level == 1: LOGGER._xml_logger = LoggerProxy(self.flat_xml_logger) def end_keyword(self, kw): - if kw.tags.robot('flatten'): + if kw.type in kw.keyword_types and kw.tags.robot('flatten'): self._flatten_level -= 1 if not self._flatten_level: LOGGER._xml_logger = LoggerProxy(self._xmllogger) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 13d66938654..d47a76b4aad 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -12,7 +12,7 @@ # 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. - +import warnings from datetime import datetime from robot.utils import NullMarkupWriter, safe_str, XmlWriter @@ -266,10 +266,12 @@ def _write_list(self, tag, items): self._writer.element(tag, item) def _write_status(self, item): - attrs = {'status': item.status, - 'start': item.start_time.isoformat() if item.start_time else None, - 'elapsed': str(item.elapsed_time.total_seconds())} - self._writer.element('status', item.message, attrs) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + attrs = {'status': item.status, + 'start': item.start_time.isoformat() if item.start_time else None, + 'elapsed': str(item.elapsed_time.total_seconds())} + self._writer.element('status', item.message, attrs) class FlatXmlLogger(XmlLogger): diff --git a/src/robot/result/model.py b/src/robot/result/model.py index b5803182763..9dfa7bac750 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -322,11 +322,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def visit(self, visitor: SuiteVisitor): visitor.visit_for_iteration(self) - @property - @deprecated - def name(self): - return str(self) - def __str__(self): return ', '.join('%s = %s' % item for item in self.assign.items()) @@ -360,11 +355,6 @@ def __init__(self, assign: Sequence[str] = (), def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> iterations_class: return self.iterations_class(self.iteration_class, self, iterations) - @property - @deprecated - def name(self): - return str(self) - def __str__(self): assign = ' | '.join(self.assign) values = ' | '.join(self.values) @@ -510,11 +500,6 @@ def __init__(self, type: str = BodyItem.TRY, self.elapsed_time = elapsed_time self.doc = doc - @property - @deprecated - def name(self): - return str(self) - def __str__(self): patterns = list(self.patterns) if self.pattern_type: @@ -526,6 +511,7 @@ def __str__(self): parts.append(f'AS {self.assign}') return ' '.join(parts) + @Body.register class Try(model.Try, StatusMixin, DeprecatedAttributesMixin): branch_class = TryBranch @@ -575,7 +561,7 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property - @deprecated + # FIXME @deprecated def doc(self) -> str: return '' @@ -608,7 +594,7 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property - @deprecated + # FIXME @deprecated def doc(self) -> str: return '' @@ -641,7 +627,7 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property - @deprecated + # FIXME @deprecated def doc(self) -> str: return '' @@ -673,7 +659,7 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return self.body_class(self, body) @property - @deprecated + # FIXME @deprecated def doc(self) -> 'str': return '' diff --git a/src/robot/result/modeldeprecation.py b/src/robot/result/modeldeprecation.py index e67aec224f3..deb71d33391 100644 --- a/src/robot/result/modeldeprecation.py +++ b/src/robot/result/modeldeprecation.py @@ -13,12 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import warnings from robot.model import Tags def deprecated(method): def wrapper(self, *args, **kws): """Deprecated.""" + warnings.warn(f"{type(self).__name__}, {method.__name__}", stacklevel=1) return method(self, *args, **kws) return wrapper @@ -62,6 +64,6 @@ def timeout(self): return None @property - @deprecated + # FIXME @deprecated def message(self): return '' diff --git a/src/robot/running/modelcombiner.py b/src/robot/running/modelcombiner.py index 0e781f4dab5..ab9cb8ad993 100644 --- a/src/robot/running/modelcombiner.py +++ b/src/robot/running/modelcombiner.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import warnings + class ModelCombiner: __slots__ = ['data', 'result', 'priority'] @@ -23,10 +25,12 @@ def __init__(self, data, result, **priority): self.priority = priority def __getattr__(self, name): - if name in self.priority: - return self.priority[name] - if hasattr(self.result, name): - return getattr(self.result, name) - if hasattr(self.data, name): - return getattr(self.data, name) - raise AttributeError(name) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if name in self.priority: + return self.priority[name] + if hasattr(self.result, name): + return getattr(self.result, name) + if hasattr(self.data, name): + return getattr(self.data, name) + raise AttributeError(name) diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index e443594c75c..a779b0608fa 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -43,7 +43,8 @@ def __enter__(self): if not result.start_time: result.start_time = datetime.now() context.start_keyword(ModelCombiner(self.data, result)) - self._warn_if_deprecated(result.doc, result.name) + if result.type in result.keyword_types: + self._warn_if_deprecated(result.doc, result.name) return self def _warn_if_deprecated(self, doc, name): diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index a9ead921047..1100cf7a5a5 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -552,14 +552,20 @@ def test_deprecated_keyword_specific_properties(self): for name, expected in [('args', ()), ('tags', Tags()), ('timeout', None)]: - assert_equal(getattr(for_, name), expected) + with warnings.catch_warnings(record=True) as w: + assert_equal(getattr(for_, name), expected) + assert_true(issubclass(w[-1].category, UserWarning)) + assert_true(f'For, {name}' in str(w[-1].message)) def test_if(self): for name, expected in [('args', ()), ('assign', ()), ('tags', Tags()), ('timeout', None)]: - assert_equal(getattr(If(), name), expected) + with warnings.catch_warnings(record=True) as w: + assert_equal(getattr(If(), name), expected) + assert_true(issubclass(w[-1].category, UserWarning)) + assert_true(f'If, {name}' in str(w[-1].message)) if __name__ == '__main__': From 041a4b9355c30e9af1e93112ffd784335bdcfc91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20H=C3=A4rk=C3=B6nen?= Date: Tue, 26 Sep 2023 07:08:15 +0300 Subject: [PATCH 0733/1592] model refactoring: make result models consistent --- .../lineno_and_source.robot | 40 +++++++++---------- atest/testresources/listeners/listeners.py | 7 ++-- src/robot/output/listenerarguments.py | 10 ++--- src/robot/result/model.py | 9 +++++ utest/output/test_listeners.py | 5 ++- 5 files changed, 41 insertions(+), 30 deletions(-) diff --git a/atest/robot/output/listener_interface/lineno_and_source.robot b/atest/robot/output/listener_interface/lineno_and_source.robot index 68a7c75edc2..0800d57ab7a 100644 --- a/atest/robot/output/listener_interface/lineno_and_source.robot +++ b/atest/robot/output/listener_interface/lineno_and_source.robot @@ -59,14 +59,14 @@ FOR in keyword \END KEYWORD FOR In Keyword 26 PASS FOR in IF - START IF True 29 NOT SET + START IF IF \ \ \ True 29 NOT SET START FOR \${x} | \${y} IN [ x | y ] 30 NOT SET START ITERATION \${x} = x, \${y} = y 30 NOT SET START KEYWORD No Operation 31 NOT SET \END KEYWORD No Operation 31 PASS \END ITERATION \${x} = x, \${y} = y 30 PASS \END FOR \${x} | \${y} IN [ x | y ] 30 PASS - \END IF True 29 PASS + \END IF IF \ \ \ True 29 PASS FOR in resource START KEYWORD FOR In Resource 36 NOT SET @@ -79,59 +79,59 @@ FOR in resource \END KEYWORD FOR In Resource 36 PASS IF - START IF 1 > 2 39 NOT RUN + START IF IF \ \ \ 1 > 2 39 NOT RUN START KEYWORD Fail 40 NOT RUN \END KEYWORD Fail 40 NOT RUN - \END IF 1 > 2 39 NOT RUN - START ELSE IF 1 < 2 41 NOT SET + \END IF IF \ \ \ 1 > 2 39 NOT RUN + START ELSE IF ELSE IF \ \ \ 1 < 2 41 NOT SET START KEYWORD No Operation 42 NOT SET \END KEYWORD No Operation 42 PASS - \END ELSE IF 1 < 2 41 PASS - START ELSE ${EMPTY} 43 NOT RUN + \END ELSE IF ELSE IF \ \ \ 1 < 2 41 PASS + START ELSE ELSE 43 NOT RUN START KEYWORD Fail 44 NOT RUN \END KEYWORD Fail 44 NOT RUN - \END ELSE ${EMPTY} 43 NOT RUN + \END ELSE ELSE 43 NOT RUN IF in keyword START KEYWORD IF In Keyword 48 NOT SET - START IF True 110 NOT SET + START IF IF \ \ \ True 110 NOT SET START KEYWORD No Operation 111 NOT SET \END KEYWORD No Operation 111 PASS START RETURN ${EMPTY} 112 NOT SET \END RETURN ${EMPTY} 112 PASS - \END IF True 110 PASS + \END IF IF \ \ \ True 110 PASS \END KEYWORD IF In Keyword 48 PASS IF in FOR START FOR \${x} IN [ 1 | 2 ] 52 NOT SET START ITERATION \${x} = 1 52 NOT SET - START IF \${x} == 1 53 NOT SET + START IF IF \ \ \ \${x} == 1 53 NOT SET START KEYWORD Log 54 NOT SET \END KEYWORD Log 54 PASS - \END IF \${x} == 1 53 PASS - START ELSE ${EMPTY} 55 NOT RUN + \END IF IF \ \ \ \${x} == 1 53 PASS + START ELSE ELSE 55 NOT RUN START KEYWORD Fail 56 NOT RUN \END KEYWORD Fail 56 NOT RUN - \END ELSE ${EMPTY} 55 NOT RUN + \END ELSE ELSE 55 NOT RUN \END ITERATION \${x} = 1 52 PASS START ITERATION \${x} = 2 52 NOT SET - START IF \${x} == 1 53 NOT RUN + START IF IF \ \ \ \${x} == 1 53 NOT RUN START KEYWORD Log 54 NOT RUN \END KEYWORD Log 54 NOT RUN - \END IF \${x} == 1 53 NOT RUN - START ELSE ${EMPTY} 55 NOT SET + \END IF IF \ \ \ \${x} == 1 53 NOT RUN + START ELSE ELSE 55 NOT SET START KEYWORD Fail 56 NOT SET \END KEYWORD Fail 56 FAIL - \END ELSE ${EMPTY} 55 FAIL + \END ELSE ELSE 55 FAIL \END ITERATION \${x} = 2 52 FAIL \END FOR \${x} IN [ 1 | 2 ] 52 FAIL IF in resource START KEYWORD IF In Resource 61 NOT SET - START IF True 11 NOT SET source=${RESOURCE FILE} + START IF IF \ \ \ True 11 NOT SET source=${RESOURCE FILE} START KEYWORD No Operation 12 NOT SET source=${RESOURCE FILE} \END KEYWORD No Operation 12 PASS source=${RESOURCE FILE} - \END IF True 11 PASS source=${RESOURCE FILE} + \END IF IF \ \ \ True 11 PASS source=${RESOURCE FILE} \END KEYWORD IF In Resource 61 PASS TRY diff --git a/atest/testresources/listeners/listeners.py b/atest/testresources/listeners/listeners.py index edec08ac4b0..23573f65212 100644 --- a/atest/testresources/listeners/listeners.py +++ b/atest/testresources/listeners/listeners.py @@ -78,15 +78,16 @@ def _get_expected_type(self, kwname, libname, args, source, lineno, **ignore): if ' = ' in kwname: return 'ITERATION' if not args: - if kwname in ("'IF' == 'WRONG'", '${i} == 9'): + if "'IF' == 'WRONG'" in kwname or '${i} == 9' in kwname: return 'IF' - if kwname == "'ELSE IF' == 'ELSE IF'": + if "'ELSE IF' == 'ELSE IF'" in kwname: return 'ELSE IF' + if kwname == 'ELSE': + return 'ELSE' if kwname == '': source = os.path.basename(source) if source == 'for_loops.robot': return 'BREAK' if lineno == 14 else 'CONTINUE' - return 'ELSE' expected = args[0] if libname == 'BuiltIn' else kwname return {'Suite Setup': 'SETUP', 'Suite Teardown': 'TEARDOWN', 'Test Setup': 'SETUP', 'Test Teardown': 'TEARDOWN', diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 54966bfa618..370c9a2b309 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -150,14 +150,14 @@ def _get_extra_attributes(self, kw): # FOR and TRY model objects use `assign` starting from RF 7.0, but for # backwards compatibility reasons we pass them as `variable(s)`. assign = kw.assign if kw.type in kw.keyword_types else () - if kw.type in ('FOR', 'ITERATION', 'TRY', 'EXCEPT', 'FINALLY'): - kwname = str(kw.result) - libname = '' - args = [] - else: + if kw.type in kw.keyword_types: kwname = kw.kwname or '' libname = kw.libname or '' args = [a if is_string(a) else safe_str(a) for a in kw.args] + else: + kwname = str(kw.result) + libname = '' + args = [] attrs = {'kwname': kwname, 'libname': libname, 'args': args, diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 9dfa7bac750..c093a889714 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -565,6 +565,9 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def doc(self) -> str: return '' + def __str__(self): + return '' + @Body.register class Continue(model.Continue, StatusMixin, DeprecatedAttributesMixin): @@ -598,6 +601,9 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def doc(self) -> str: return '' + def __str__(self): + return '' + @Body.register class Break(model.Break, StatusMixin, DeprecatedAttributesMixin): @@ -631,6 +637,9 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def doc(self) -> str: return '' + def __str__(self): + return '' + @Body.register class Error(model.Error, StatusMixin, DeprecatedAttributesMixin): diff --git a/utest/output/test_listeners.py b/utest/output/test_listeners.py index 37267842f87..a580294a3da 100644 --- a/utest/output/test_listeners.py +++ b/utest/output/test_listeners.py @@ -1,5 +1,6 @@ import unittest +from robot.model import BodyItem from robot.output.listeners import Listeners, LibraryListeners from robot.output import LOGGER from robot.running.outputcapture import OutputCapturer @@ -42,14 +43,14 @@ def __init__(self): self.data = DotDict({'name':self.name}) -class KwMock(Mock): +class KwMock(Mock, BodyItem): non_existing = ('branch_status',) def __init__(self): self.name = 'kwmock' self.args = ['a1', 'a2'] self.status = 'PASS' - self.type = 'kw' + self.type = BodyItem.KEYWORD class ListenOutputs: From b56d5ea910ee625d4ad1d20f4d03b1cb95925292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Tue, 3 Oct 2023 17:19:43 +0300 Subject: [PATCH 0734/1592] cleanup --- src/robot/output/listenerarguments.py | 5 +++-- src/robot/result/model.py | 5 ----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 370c9a2b309..280761b8b8b 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -149,19 +149,20 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): def _get_extra_attributes(self, kw): # FOR and TRY model objects use `assign` starting from RF 7.0, but for # backwards compatibility reasons we pass them as `variable(s)`. - assign = kw.assign if kw.type in kw.keyword_types else () if kw.type in kw.keyword_types: + assign = list(kw.assign) kwname = kw.kwname or '' libname = kw.libname or '' args = [a if is_string(a) else safe_str(a) for a in kw.args] else: + assign = [] kwname = str(kw.result) libname = '' args = [] attrs = {'kwname': kwname, 'libname': libname, 'args': args, - 'assign': list(assign), + 'assign': assign, 'source': str(kw.source or '')} if kw.type in self._type_attributes: for name in self._type_attributes[kw.type]: diff --git a/src/robot/result/model.py b/src/robot/result/model.py index c093a889714..4fcd86b106e 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -453,11 +453,6 @@ def __init__(self, type: str = BodyItem.IF, self.elapsed_time = elapsed_time self.doc = doc - @property - @deprecated - def name(self) -> str: - return self.condition or '' - @Body.register class If(model.If, StatusMixin, DeprecatedAttributesMixin): From 9ee038ee0f8e806ccf82b319f192414692f7416e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Tue, 3 Oct 2023 18:16:53 +0300 Subject: [PATCH 0735/1592] jsmodelbuilding: refactoring add `_name` attribute to construct the display name used in building logs and passed to listeners. This should not be part of the public API, we want to refactor log/report generation to get rid of the attribute Also misc cleanup based on PR comments --- .../remove_keywords/for_loop_keywords.robot | 2 +- .../lineno_and_source.robot | 40 ++++---- atest/robot/variables/return_values.robot | 4 +- atest/testresources/listeners/listeners.py | 3 +- src/robot/model/body.py | 5 +- src/robot/output/listenerarguments.py | 4 +- src/robot/output/output.py | 4 +- src/robot/output/xmllogger.py | 12 +-- src/robot/reporting/jsmodelbuilders.py | 97 ++++++------------- src/robot/result/model.py | 29 +++--- src/robot/result/modeldeprecation.py | 5 +- src/robot/running/statusreporter.py | 2 +- utest/result/test_resultmodel.py | 17 ++-- 13 files changed, 89 insertions(+), 135 deletions(-) diff --git a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot index d316bc0687a..be0fb6c1cf2 100644 --- a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot @@ -21,7 +21,7 @@ Failed Steps Are Not Removed ${tc}= Check Test Case Failure inside FOR 2 Length Should Be ${tc.kws[0].kws} 1 Should Be Equal ${tc.kws[0].doc} ${3 REMOVED} - Should Be Equal ${tc.kws[0].kws[0].__str__()} \${num} = 4 + Should Be Equal ${tc.kws[0].kws[0]._name} \${num} = 4 Should Be Equal ${tc.kws[0].kws[0].type} ITERATION Should Be Equal ${tc.kws[0].kws[0].status} FAIL Length Should Be ${tc.kws[0].kws[0].kws} 3 diff --git a/atest/robot/output/listener_interface/lineno_and_source.robot b/atest/robot/output/listener_interface/lineno_and_source.robot index 0800d57ab7a..ec4b4fbbb42 100644 --- a/atest/robot/output/listener_interface/lineno_and_source.robot +++ b/atest/robot/output/listener_interface/lineno_and_source.robot @@ -59,14 +59,14 @@ FOR in keyword \END KEYWORD FOR In Keyword 26 PASS FOR in IF - START IF IF \ \ \ True 29 NOT SET + START IF True 29 NOT SET START FOR \${x} | \${y} IN [ x | y ] 30 NOT SET START ITERATION \${x} = x, \${y} = y 30 NOT SET START KEYWORD No Operation 31 NOT SET \END KEYWORD No Operation 31 PASS \END ITERATION \${x} = x, \${y} = y 30 PASS \END FOR \${x} | \${y} IN [ x | y ] 30 PASS - \END IF IF \ \ \ True 29 PASS + \END IF True 29 PASS FOR in resource START KEYWORD FOR In Resource 36 NOT SET @@ -79,59 +79,59 @@ FOR in resource \END KEYWORD FOR In Resource 36 PASS IF - START IF IF \ \ \ 1 > 2 39 NOT RUN + START IF 1 > 2 39 NOT RUN START KEYWORD Fail 40 NOT RUN \END KEYWORD Fail 40 NOT RUN - \END IF IF \ \ \ 1 > 2 39 NOT RUN - START ELSE IF ELSE IF \ \ \ 1 < 2 41 NOT SET + \END IF 1 > 2 39 NOT RUN + START ELSE IF 1 < 2 41 NOT SET START KEYWORD No Operation 42 NOT SET \END KEYWORD No Operation 42 PASS - \END ELSE IF ELSE IF \ \ \ 1 < 2 41 PASS - START ELSE ELSE 43 NOT RUN + \END ELSE IF 1 < 2 41 PASS + START ELSE \ 43 NOT RUN START KEYWORD Fail 44 NOT RUN \END KEYWORD Fail 44 NOT RUN - \END ELSE ELSE 43 NOT RUN + \END ELSE \ 43 NOT RUN IF in keyword START KEYWORD IF In Keyword 48 NOT SET - START IF IF \ \ \ True 110 NOT SET + START IF True 110 NOT SET START KEYWORD No Operation 111 NOT SET \END KEYWORD No Operation 111 PASS START RETURN ${EMPTY} 112 NOT SET \END RETURN ${EMPTY} 112 PASS - \END IF IF \ \ \ True 110 PASS + \END IF True 110 PASS \END KEYWORD IF In Keyword 48 PASS IF in FOR START FOR \${x} IN [ 1 | 2 ] 52 NOT SET START ITERATION \${x} = 1 52 NOT SET - START IF IF \ \ \ \${x} == 1 53 NOT SET + START IF \${x} == 1 53 NOT SET START KEYWORD Log 54 NOT SET \END KEYWORD Log 54 PASS - \END IF IF \ \ \ \${x} == 1 53 PASS - START ELSE ELSE 55 NOT RUN + \END IF \${x} == 1 53 PASS + START ELSE \ 55 NOT RUN START KEYWORD Fail 56 NOT RUN \END KEYWORD Fail 56 NOT RUN - \END ELSE ELSE 55 NOT RUN + \END ELSE \ 55 NOT RUN \END ITERATION \${x} = 1 52 PASS START ITERATION \${x} = 2 52 NOT SET - START IF IF \ \ \ \${x} == 1 53 NOT RUN + START IF \${x} == 1 53 NOT RUN START KEYWORD Log 54 NOT RUN \END KEYWORD Log 54 NOT RUN - \END IF IF \ \ \ \${x} == 1 53 NOT RUN - START ELSE ELSE 55 NOT SET + \END IF \${x} == 1 53 NOT RUN + START ELSE \ 55 NOT SET START KEYWORD Fail 56 NOT SET \END KEYWORD Fail 56 FAIL - \END ELSE ELSE 55 FAIL + \END ELSE \ 55 FAIL \END ITERATION \${x} = 2 52 FAIL \END FOR \${x} IN [ 1 | 2 ] 52 FAIL IF in resource START KEYWORD IF In Resource 61 NOT SET - START IF IF \ \ \ True 11 NOT SET source=${RESOURCE FILE} + START IF True 11 NOT SET source=${RESOURCE FILE} START KEYWORD No Operation 12 NOT SET source=${RESOURCE FILE} \END KEYWORD No Operation 12 PASS source=${RESOURCE FILE} - \END IF IF \ \ \ True 11 PASS source=${RESOURCE FILE} + \END IF True 11 PASS source=${RESOURCE FILE} \END KEYWORD IF In Resource 61 PASS TRY diff --git a/atest/robot/variables/return_values.robot b/atest/robot/variables/return_values.robot index f1aea36ac88..39f67d61583 100644 --- a/atest/robot/variables/return_values.robot +++ b/atest/robot/variables/return_values.robot @@ -83,9 +83,9 @@ Unrepresentable objects to list variables ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} \@{unrepr} = ? ${UNREPR} | ${UNREPR} ? pattern=yes Check Log Message ${tc.kws[0].msgs[0]} \@{unrepr} = ? ${UNREPR} | ${UNREPR} ? pattern=yes - Should Match ${tc.kws[2].kws[0].__str__()} \${obj} = ${UNREPR} + Should Match ${tc.kws[2].kws[0]._name} \${obj} = ${UNREPR} Check Log Message ${tc.kws[2].kws[0].kws[1].msgs[0]} $\{var} = ${UNREPR} pattern=yes - Should Match ${tc.kws[2].kws[1].__str__()} \${obj} = ${UNREPR} + Should Match ${tc.kws[2].kws[1]._name} \${obj} = ${UNREPR} Check Log Message ${tc.kws[2].kws[1].kws[1].msgs[0]} $\{var} = ${UNREPR} pattern=yes None To List Variable diff --git a/atest/testresources/listeners/listeners.py b/atest/testresources/listeners/listeners.py index 23573f65212..fe7f5009e3e 100644 --- a/atest/testresources/listeners/listeners.py +++ b/atest/testresources/listeners/listeners.py @@ -82,12 +82,11 @@ def _get_expected_type(self, kwname, libname, args, source, lineno, **ignore): return 'IF' if "'ELSE IF' == 'ELSE IF'" in kwname: return 'ELSE IF' - if kwname == 'ELSE': - return 'ELSE' if kwname == '': source = os.path.basename(source) if source == 'for_loops.robot': return 'BREAK' if lineno == 14 else 'CONTINUE' + return 'ELSE' expected = args[0] if libname == 'BuiltIn' else kwname return {'Suite Setup': 'SETUP', 'Suite Teardown': 'TEARDOWN', 'Test Setup': 'SETUP', 'Test Teardown': 'TEARDOWN', diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 21709dff3ff..29430d54b74 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -70,6 +70,7 @@ class BodyItem(ModelObject): BREAK = 'BREAK' ERROR = 'ERROR' MESSAGE = 'MESSAGE' + KEYWORD_TYPES = (KEYWORD, SETUP, TEARDOWN) type = None __slots__ = ['parent'] @@ -107,10 +108,6 @@ def _get_id(self, parent: 'BodyItemParent|ResourceFile') -> str: parent_id = getattr(parent, 'id', None) return f'{parent_id}-k{index + 1}' if parent_id else f'k{index + 1}' - @property - def keyword_types(self): - return self.KEYWORD, self.SETUP, self.TEARDOWN - def to_dict(self) -> DataDict: raise NotImplementedError diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 280761b8b8b..84f0babd186 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -149,14 +149,14 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): def _get_extra_attributes(self, kw): # FOR and TRY model objects use `assign` starting from RF 7.0, but for # backwards compatibility reasons we pass them as `variable(s)`. - if kw.type in kw.keyword_types: + if kw.type in kw.KEYWORD_TYPES: assign = list(kw.assign) kwname = kw.kwname or '' libname = kw.libname or '' args = [a if is_string(a) else safe_str(a) for a in kw.args] else: assign = [] - kwname = str(kw.result) + kwname = kw._name libname = '' args = [] attrs = {'kwname': kwname, diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 4bca528d0e4..ff27dc4d259 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -69,13 +69,13 @@ def end_test(self, test): def start_keyword(self, kw): LOGGER.start_keyword(kw) - if kw.type in kw.keyword_types and kw.tags.robot('flatten'): + if kw.type in kw.KEYWORD_TYPES and kw.tags.robot('flatten'): self._flatten_level += 1 if self._flatten_level == 1: LOGGER._xml_logger = LoggerProxy(self.flat_xml_logger) def end_keyword(self, kw): - if kw.type in kw.keyword_types and kw.tags.robot('flatten'): + if kw.type in kw.KEYWORD_TYPES and kw.tags.robot('flatten'): self._flatten_level -= 1 if not self._flatten_level: LOGGER._xml_logger = LoggerProxy(self._xmllogger) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index d47a76b4aad..13d66938654 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -12,7 +12,7 @@ # 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. -import warnings + from datetime import datetime from robot.utils import NullMarkupWriter, safe_str, XmlWriter @@ -266,12 +266,10 @@ def _write_list(self, tag, items): self._writer.element(tag, item) def _write_status(self, item): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - attrs = {'status': item.status, - 'start': item.start_time.isoformat() if item.start_time else None, - 'elapsed': str(item.elapsed_time.total_seconds())} - self._writer.element('status', item.message, attrs) + attrs = {'status': item.status, + 'start': item.start_time.isoformat() if item.start_time else None, + 'elapsed': str(item.elapsed_time.total_seconds())} + self._writer.element('status', item.message, attrs) class FlatXmlLogger(XmlLogger): diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index eb535a6558c..fc04bca8526 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -12,7 +12,6 @@ # 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 typing import Sequence from robot.output import LEVELS from robot.result import (Break, Continue, Error, For, ForIteration, IfBranch, @@ -71,7 +70,7 @@ def _get_status(self, item): msg = self._string(msg) return model + (msg,) - def _build_keywords(self, steps, split=False): + def _build_body(self, steps, split=False): splitting = self._context.start_splitting_if_needed(split) # tuple([]) is faster than tuple() with short lists. model = tuple([self._build_keyword(step) for step in steps]) @@ -121,16 +120,16 @@ def __init__(self, context): self._build_keyword = KeywordBuilder(context).build def build(self, test): - kws = self._get_keywords(test) + items = self._get_body_items(test) with self._context.prune_input(test.body): return (self._string(test.name, attr=True), self._string(test.timeout), self._html(test.doc), tuple(self._string(t) for t in test.tags), self._get_status(test), - self._build_keywords(kws, split=True)) + self._build_body(items, split=True)) - def _get_keywords(self, test): + def _get_body_items(self, test): kws = [] if test.setup: kws.append(test.setup) @@ -150,77 +149,35 @@ def __init__(self, context): def build(self, item, split=False): if item.type == item.MESSAGE: return self._build_message(item) - return self.build_keyword(item, split) - - def build_keyword(self, kw, split=False): - self._context.check_expansion(kw) - with self._context.prune_input(kw.body): - if isinstance (kw, Keyword): - return self.build_kw(kw, split) - elif isinstance(kw, For): - return self.build_for(kw, split) - elif isinstance(kw, ForIteration): - return self.build_for_iteration(kw, split) - elif isinstance(kw, IfBranch): - return self.build_if_branch(kw, split) - elif isinstance(kw, Return): - return self.build_return(kw, split) - elif isinstance(kw, TryBranch): - return self.build_try_branch(kw, split) - elif isinstance(kw, While): - return self.build_while(kw, split) - elif isinstance(kw, WhileIteration): - return self._build(kw, split=split) - elif isinstance(kw, Continue): - return self._build(kw, split=split) - elif isinstance(kw, Break): - return self._build(kw, split=split) - elif isinstance(kw, Error): - return self.build_error(kw, split=split) - - def build_kw(self, kw: Keyword, split: bool): - items = kw.body.flatten() - if kw.has_teardown: - items.append(kw.teardown) - return self._build(kw, kw.kwname, kw.libname, kw.timeout, kw.doc, kw.args, kw.assign, kw.tags, split=split) - - def build_for(self, for_: For, split: bool): - return self._build(for_, str(for_), split=split) - - def build_for_iteration(self, iter: ForIteration, split: bool): - return self._build(iter, str(iter), split=split) - - def build_if_branch(self, if_: IfBranch, split: bool): - return self._build(if_, if_.condition or '', split=split) - - def build_return(self, return_: Return, split: bool): - return self._build(return_, args=return_.values, split=split) - - def build_try_branch(self, branch: TryBranch, split: bool): - return self._build(branch, str(branch), split=split) - - def build_while(self, while_: While, split: bool): - return self._build(while_, str(while_), split=split) - - def build_while_iteration(self, iter: WhileIteration, split: bool): - return self._build(iter, split=split) - - def build_error(self, error: Error, split: bool): - return self._build(error, error.values[0], args=error.values[1:], split=split) - - def _build(self, kw, kwname: str = '', libname: str = '', timeout: str = '', doc: str = '', - args: Sequence[str] = (), assign: Sequence[str] = (), tags: Sequence[str] = (), - items: 'Sequence[BodyItem]|None' = None, split: bool = False): - return (KEYWORD_TYPES[kw.type], + return self.build_body_item(item, split) + + def build_body_item(self, item, split=False): + self._context.check_expansion(item) + with self._context.prune_input(item.body): + if isinstance (item, Keyword): + items = item.body.flatten() + if item.has_teardown: + items.append(item.teardown) + return self._build(item, item.kwname, item.libname, item.timeout, item.doc, item.args, + item.assign, item.tags, split=split) + if isinstance(item, Return): + return self._build(item, args=item.values, split=split) + if isinstance(item, Error): + return self._build(item, item._name, args=item.values[1:], split=split) + return self._build(item, item._name, split=split) + + def _build(self, item, kwname='', libname='', timeout='', doc='', args=(), assign=(), + tags=(), items=None, split =False): + return (KEYWORD_TYPES[item.type], self._string(kwname, attr=True), self._string(libname, attr=True), self._string(timeout), - self._html(kw.doc), + self._html(item.doc), self._string(', '.join(args)), self._string(', '.join(assign)), self._string(', '.join(tags)), - self._get_status(kw), - self._build_keywords(items if items is not None else kw.body.flatten(), split)) + self._get_status(item), + self._build_body(items if items is not None else item.body.flatten(), split)) class MessageBuilder(_Builder): diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 4fcd86b106e..9c50f1ab753 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -322,7 +322,8 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def visit(self, visitor: SuiteVisitor): visitor.visit_for_iteration(self) - def __str__(self): + @property + def _name(self): return ', '.join('%s = %s' % item for item in self.assign.items()) @@ -355,7 +356,8 @@ def __init__(self, assign: Sequence[str] = (), def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> iterations_class: return self.iterations_class(self.iteration_class, self, iterations) - def __str__(self): + @property + def _name(self): assign = ' | '.join(self.assign) values = ' | '.join(self.values) for name, value in [('start', self.start), @@ -421,7 +423,8 @@ def __init__(self, condition: 'str|None' = None, def body(self, iterations: 'Sequence[WhileIteration|DataDict]') -> iterations_class: return self.iterations_class(self.iteration_class, self, iterations) - def __str__(self): + @property + def _name(self): parts = [] if self.condition: parts.append(self.condition) @@ -453,6 +456,10 @@ def __init__(self, type: str = BodyItem.IF, self.elapsed_time = elapsed_time self.doc = doc + @property + def _name(self): + return self.condition or '' + @Body.register class If(model.If, StatusMixin, DeprecatedAttributesMixin): @@ -495,7 +502,8 @@ def __init__(self, type: str = BodyItem.TRY, self.elapsed_time = elapsed_time self.doc = doc - def __str__(self): + @property + def _name(self): patterns = list(self.patterns) if self.pattern_type: patterns.append(f'type={self.pattern_type}') @@ -560,9 +568,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def doc(self) -> str: return '' - def __str__(self): - return '' - @Body.register class Continue(model.Continue, StatusMixin, DeprecatedAttributesMixin): @@ -596,9 +601,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def doc(self) -> str: return '' - def __str__(self): - return '' - @Body.register class Break(model.Break, StatusMixin, DeprecatedAttributesMixin): @@ -632,9 +634,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def doc(self) -> str: return '' - def __str__(self): - return '' - @Body.register class Error(model.Error, StatusMixin, DeprecatedAttributesMixin): @@ -667,6 +666,10 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def doc(self) -> 'str': return '' + @property + def _name(self): + return self.values[0] + @Body.register @Branches.register diff --git a/src/robot/result/modeldeprecation.py b/src/robot/result/modeldeprecation.py index deb71d33391..61bfee39c11 100644 --- a/src/robot/result/modeldeprecation.py +++ b/src/robot/result/modeldeprecation.py @@ -27,16 +27,17 @@ def wrapper(self, *args, **kws): class DeprecatedAttributesMixin: __slots__ = [] + _name = '' @property @deprecated def name(self): - return '' + return self._name @property @deprecated def kwname(self): - return self.name + return self._name @property @deprecated diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index a779b0608fa..44e0438eabc 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -43,7 +43,7 @@ def __enter__(self): if not result.start_time: result.start_time = datetime.now() context.start_keyword(ModelCombiner(self.data, result)) - if result.type in result.keyword_types: + if result.type in result.KEYWORD_TYPES: self._warn_if_deprecated(result.doc, result.name) return self diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 1100cf7a5a5..5ca865d714d 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -437,16 +437,15 @@ def test_if_parents(self): kw = branch.body.create_keyword() assert_equal(kw.parent, branch) - def test_while_str(self): - assert_equal(str(While()), '') - assert_equal(str(While('$x > 0')), '$x > 0') - assert_equal(str(While('True', '1 minute')), 'True | limit=1 minute') - assert_equal(str(While(limit='1 minute')), 'limit=1 minute') - assert_equal(str(While('True', '1 s', on_limit_message='Error message')), + def test_while_name(self): + assert_equal(While()._name, '') + assert_equal(While('$x > 0')._name, '$x > 0') + assert_equal(While('True', '1 minute')._name, 'True | limit=1 minute') + assert_equal(While(limit='1 minute')._name, 'limit=1 minute') + assert_equal(While('True', '1 s', on_limit_message='Error message')._name, 'True | limit=1 s | on_limit_message=Error message') - assert_equal(str(While(on_limit='pass')), - 'on_limit=pass') - assert_equal(str(While(on_limit_message='Error message')), + assert_equal(While(on_limit='pass')._name, 'on_limit=pass') + assert_equal(While(on_limit_message='Error message')._name, 'on_limit_message=Error message') From f1f05e3b619cbbf3fb9067cd6825e5d057172bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 3 Oct 2023 18:06:12 +0300 Subject: [PATCH 0736/1592] Misc cleanup --- src/robot/api/interfaces.py | 2 +- src/robot/model/control.py | 8 ++------ src/robot/running/arguments/typeinfo.py | 6 +++--- src/robot/running/bodyrunner.py | 2 +- src/robot/running/model.py | 4 ++-- 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/robot/api/interfaces.py b/src/robot/api/interfaces.py index ce138c9979d..bf649e3a645 100644 --- a/src/robot/api/interfaces.py +++ b/src/robot/api/interfaces.py @@ -519,7 +519,7 @@ def start_test(self, data: running.TestCase, result: result.TestCase): """Called when a test or task starts.""" def end_test(self, data: running.TestCase, result: result.TestCase): - """Called when a test or ends starts.""" + """Called when a test or tasks ends.""" def log_message(self, message: Message): """Called when a normal log message are emitted. diff --git a/src/robot/model/control.py b/src/robot/model/control.py index dd897a45c89..bcf8204835d 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -35,18 +35,14 @@ class Branches(BaseBranches['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'C @Body.register class For(BodyItem): - """Represents ``FOR`` loops. - - :attr:`flavor` specifies the flavor, and it can be ``IN``, ``IN RANGE``, - ``IN ENUMERATE`` or ``IN ZIP``. - """ + """Represents ``FOR`` loops.""" type = BodyItem.FOR body_class = Body repr_args = ('assign', 'flavor', 'values', 'start', 'mode', 'fill') __slots__ = ['assign', 'flavor', 'values', 'start', 'mode', 'fill'] def __init__(self, assign: Sequence[str] = (), - flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', + flavor: Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP'] = 'IN', values: Sequence[str] = (), start: 'str|None' = None, mode: 'str|None' = None, diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index 14e23280b01..0d6418edc4f 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -128,6 +128,9 @@ def from_type_hint(cls, hint: Any) -> 'TypeInfo': return cls() if isinstance(hint, typeddict_types): return TypedDictInfo(hint.__name__, hint) + if is_union(hint): + nested = [cls.from_type_hint(typ) for typ in hint.__args__] + return cls('Union', nested=nested) if hasattr(hint, '__origin__'): if has_args(hint): nested = [cls.from_type_hint(t) for t in hint.__args__] @@ -142,9 +145,6 @@ def from_type_hint(cls, hint: Any) -> 'TypeInfo': return cls.from_string(hint) if isinstance(hint, dict): return cls.from_dict(hint) - if is_union(hint): - nested = [cls.from_type_hint(typ) for typ in hint.__args__] - return cls('Union', nested=nested) if isinstance(hint, (tuple, list)): return cls.from_sequence(hint) if hint is Union: diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index b886d029e62..2858072da9d 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -617,7 +617,7 @@ def _should_run_except(self, branch, error): matchers = { 'GLOB': lambda m, p: Matcher(p, spaceless=False, caseless=False).match(m), 'LITERAL': lambda m, p: m == p, - 'REGEXP': lambda m, p: re.match(rf'{p}\Z', m) is not None, + 'REGEXP': lambda m, p: re.fullmatch(p, m) is not None, 'START': lambda m, p: m.startswith(p) } if branch.pattern_type: diff --git a/src/robot/running/model.py b/src/robot/running/model.py index c956c0d9756..d93f72c342a 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -114,7 +114,7 @@ class For(model.For, WithSource): body_class = Body def __init__(self, assign: Sequence[str] = (), - flavor: "Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP']" = 'IN', + flavor: Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP'] = 'IN', values: Sequence[str] = (), start: 'str|None' = None, mode: 'str|None' = None, @@ -840,7 +840,7 @@ class Import(ModelObject): RESOURCE = 'RESOURCE' VARIABLES = 'VARIABLES' - def __init__(self, type: "Literal['LIBRARY', 'RESOURCE', 'VARIABLES']", + def __init__(self, type: Literal['LIBRARY', 'RESOURCE', 'VARIABLES'], name: str, args: Sequence[str] = (), alias: 'str|None' = None, From 42b90eeb7dc999bb577013f003b96dd497e5ad58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 00:39:05 +0300 Subject: [PATCH 0737/1592] Result model: Add message to body items, remove doc from controls. Fixes #4883. Doc is now deprecated as part of #4846, but it will be removed in the future. --- .../all_passed_tag_and_name.robot | 10 +- .../remove_keywords/for_loop_keywords.robot | 42 +++--- .../remove_keywords_resource.robot | 13 +- .../wait_until_keyword_succeeds.robot | 24 ++-- .../remove_keywords/while_loop_keywords.robot | 24 ++-- atest/robot/output/flatten_keyword.robot | 136 +++++++++--------- atest/robot/rebot/merge.robot | 9 +- atest/robot/running/for/for.robot | 2 +- .../wait_until_keyword_succeeds.robot | 18 +-- atest/testdata/output/flatten_keywords.robot | 12 +- atest/testdata/rebot/output-5.0.xml | 8 ++ atest/testdata/running/for/for.robot | 4 +- doc/schema/robot.xsd | 8 -- src/robot/htmldata/rebot/log.html | 6 + src/robot/htmldata/rebot/model.js | 3 +- src/robot/htmldata/rebot/testdata.js | 8 +- src/robot/output/xmllogger.py | 6 - src/robot/reporting/jsmodelbuilders.py | 6 +- src/robot/result/keywordremover.py | 14 +- src/robot/result/model.py | 77 +++++----- src/robot/result/modeldeprecation.py | 3 +- src/robot/result/resultbuilder.py | 41 +++--- src/robot/result/xmlelementhandlers.py | 8 +- src/robot/running/statusreporter.py | 3 +- utest/reporting/test_jsmodelbuilders.py | 25 ++-- 25 files changed, 265 insertions(+), 245 deletions(-) diff --git a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot index c55adc45f79..84076d4c224 100644 --- a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot +++ b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot @@ -15,7 +15,7 @@ All Mode Length Should Be ${tc2.body} 2 Keyword Should Be Empty ${tc2.body[0]} My Keyword Fail Keyword Should Be Empty ${tc2.body[1]} BuiltIn.Fail Expected failure - Keyword Should Contain Removal Message ${tc2.body[1]} Fails the test with the given message and optionally alters its tags. + Keyword Should Contain Removal Message ${tc2.body[1]} Expected failure Warnings Are Removed In All Mode [Setup] Verify previous test and set My Suite All Mode 1 @@ -169,9 +169,11 @@ Verify previous test and set My Suite Set Test Variable ${MY SUITE} ${SUITE.suites[${suite index}]} Keyword Should Contain Removal Message - [Arguments] ${keyword} ${doc}=${EMPTY} - ${expected} = Set Variable ${doc}\n\n_Keyword data removed using --RemoveKeywords option._ - Should Be Equal ${keyword.doc} ${expected.strip()} + [Arguments] ${keyword} ${message}= + IF $message + ${message} = Set Variable ${message}
+ END + Should Be Equal ${keyword.message} *HTML* ${message}Data removed using --RemoveKeywords option. Logged Warnings Are Preserved In Execution Errors Check Log Message ${ERRORS[1]} Warning in suite setup WARN diff --git a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot index be0fb6c1cf2..75e611e9bfe 100644 --- a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot @@ -4,23 +4,22 @@ Suite Teardown Remove File ${INPUTFILE} Resource remove_keywords_resource.robot *** Variables *** -${0 REMOVED} ${EMPTY} -${1 REMOVED} _1 passing step removed using --RemoveKeywords option._ -${2 REMOVED} _2 passing steps removed using --RemoveKeywords option._ -${3 REMOVED} _3 passing steps removed using --RemoveKeywords option._ -${4 REMOVED} _4 passing steps removed using --RemoveKeywords option._ +${1 REMOVED} 1 passing step removed using --RemoveKeywords option. +${2 REMOVED} 2 passing steps removed using --RemoveKeywords option. +${3 REMOVED} 3 passing steps removed using --RemoveKeywords option. +${4 REMOVED} 4 passing steps removed using --RemoveKeywords option. *** Test Cases *** Passed Steps Are Removed Except The Last One ${tc}= Check Test Case Simple loop - Length Should Be ${tc.kws[1].kws} 1 - Should Be Equal ${tc.kws[1].doc} ${1 REMOVED} + Length Should Be ${tc.kws[1].kws} 1 + Should Be Equal ${tc.kws[1].message} *HTML* ${1 REMOVED} Should Be Equal ${tc.kws[1].kws[0].status} PASS Failed Steps Are Not Removed ${tc}= Check Test Case Failure inside FOR 2 Length Should Be ${tc.kws[0].kws} 1 - Should Be Equal ${tc.kws[0].doc} ${3 REMOVED} + Should Be Equal ${tc.kws[0].message} *HTML* Failure with <4>
${3 REMOVED} Should Be Equal ${tc.kws[0].kws[0]._name} \${num} = 4 Should Be Equal ${tc.kws[0].kws[0].type} ITERATION Should Be Equal ${tc.kws[0].kws[0].status} FAIL @@ -29,33 +28,34 @@ Failed Steps Are Not Removed Steps With Warning Are Not Removed ${tc}= Check Test Case Variables in values - Length Should Be ${tc.kws[0].kws} 2 - Should Be Equal ${tc.kws[0].doc} ${4 REMOVED} + Length Should Be ${tc.kws[0].kws} 2 + Should Be Equal ${tc.kws[0].message} *HTML* ${4 REMOVED} Check Log Message ${tc.kws[0].kws[0].kws[-1].kws[0].msgs[0]} Presidential Candidate! WARN Check Log Message ${tc.kws[0].kws[1].kws[-1].kws[0].msgs[0]} Presidential Candidate! WARN Steps From Nested Loops Are Removed ${tc}= Check Test Case Nested Loop Syntax - Length Should Be ${tc.kws[0].kws} 1 - Should Be Equal ${tc.kws[0].doc} ${2 REMOVED} - Length Should Be ${tc.kws[0].kws[0].kws[1].kws} 1 - Should Be Equal ${tc.kws[0].kws[0].kws[1].doc} ${2 REMOVED} + Length Should Be ${tc.kws[0].kws} 1 + Should Be Equal ${tc.kws[0].message} *HTML* ${2 REMOVED} + Length Should Be ${tc.kws[0].kws[0].kws[1].kws} 1 + Should Be Equal ${tc.kws[0].kws[0].kws[1].message} *HTML* ${2 REMOVED} Steps From Loops In Keywords From Loops Are Removed ${tc}= Check Test Case Keyword with loop calling other keywords with loops - Length Should Be ${tc.kws[0].kws[0].kws} 1 - Should Be Equal ${tc.kws[0].kws[0].doc} ${0 REMOVED} - Length Should Be ${tc.kws[0].kws[0].kws[0].kws[0].kws[1].kws} 1 - Should Be Equal ${tc.kws[0].kws[0].kws[0].kws[0].kws[1].doc} ${1 REMOVED} - Length Should Be ${tc.kws[0].kws[0].kws[0].kws[1].kws[0].kws} 1 - Should Be Equal ${tc.kws[0].kws[0].kws[0].kws[1].kws[0].doc} ${1 REMOVED} + Length Should Be ${tc.kws[0].kws[0].kws} 1 + Should Be Equal ${tc.kws[0].kws[0].message} This ought to be enough + Length Should Be ${tc.kws[0].kws[0].kws[0].kws[0].kws[1].kws} 1 + Should Be Equal ${tc.kws[0].kws[0].kws[0].kws[0].kws[1].message} *HTML* ${1 REMOVED} + Length Should Be ${tc.kws[0].kws[0].kws[0].kws[1].kws[0].kws} 1 + Should Be Equal ${tc.kws[0].kws[0].kws[0].kws[1].kws[0].message} *HTML* ${1 REMOVED} Empty Loops Are Handled Correctly ${tc}= Check Test Case Empty body + Should Be Equal ${tc.body[0].status} FAIL + Should Be Equal ${tc.body[0].message} FOR loop cannot be empty. Should Be Equal ${tc.body[0].body[0].type} ITERATION Should Be Equal ${tc.body[0].body[0].status} NOT RUN Should Be Empty ${tc.body[0].body[0].body} - Should Be Equal ${tc.body[0].doc} ${0 REMOVED} *** Keywords *** Remove For Loop Keywords With Rebot diff --git a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot index 8e7bdb12364..9bc78241006 100644 --- a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot +++ b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot @@ -3,26 +3,27 @@ Resource rebot_resource.robot *** Variables *** ${INPUTFILE} %{TEMPDIR}${/}rebot-test-rmkw.xml +${DATA REMOVED} Data removed using --RemoveKeywords option. *** Keywords *** Keyword Should Be Empty [Arguments] ${kw} ${name} @{args} - Should End With ${kw.doc} _Keyword data removed using --RemoveKeywords option._ + Should End With ${kw.message} ${DATA REMOVED} Check Keyword Name And Args ${kw} ${name} @{args} Should Be Empty ${kw.body} IF Branch Should Be Empty [Arguments] ${branch} ${type} ${condition}=${None} - Should Be Equal ${branch.doc} _Keyword data removed using --RemoveKeywords option._ - Should Be Equal ${branch.type} ${type} + Should Be Equal ${branch.message} *HTML* ${DATA REMOVED} + Should Be Equal ${branch.type} ${type} Should Be Equal ${branch.condition} ${condition} Should Be Empty ${branch.body} FOR Loop Should Be Empty [Arguments] ${loop} ${flavor} - Should Be Equal ${loop.doc} _Keyword data removed using --RemoveKeywords option._ - Should Be Equal ${loop.type} FOR - Should Be Equal ${loop.flavor} ${flavor} + Should Be Equal ${loop.message} *HTML* ${DATA REMOVED} + Should Be Equal ${loop.type} FOR + Should Be Equal ${loop.flavor} ${flavor} Should Be Empty ${loop.body} Keyword Should Not Be Empty diff --git a/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot b/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot index 7c90afd0766..c81a504604a 100644 --- a/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot +++ b/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot @@ -2,27 +2,27 @@ Suite Setup Remove Wait Until Keyword Succeeds with Rebot Resource remove_keywords_resource.robot -*** Variables *** -${DOC} Runs the specified keyword and retries if it fails. - *** Test Cases *** Last failing Step is not removed ${tc}= Check Number Of Keywords Fail Until The End 1 - Should Match ${tc.kws[0].doc} ${DOC}\n\n_? failing step* removed using --RemoveKeywords option._ + ${expected} = Catenate + ... [*]HTML[*] Keyword 'Fail' failed after retrying for 50 milliseconds. + ... The last error was: Not gonna happen
? failing step* removed using --RemoveKeywords option. + Should Match ${tc.body[0].message} ${expected} Last passing Step is not removed ${tc}= Check Number Of Keywords Passes before timeout 2 - Should Be Equal ${tc.kws[0].doc} ${DOC}\n\n_1 failing step removed using --RemoveKeywords option._ + Should Be Equal ${tc.body[0].message} *HTML* 1 failing step removed using --RemoveKeywords option. Steps containing warnings are not removed ${tc}= Check Number Of Keywords Warnings 3 - Should be Equal ${tc.kws[0].doc} ${DOC} + Should be Equal ${tc.body[0].message} ${EMPTY} Check Number Of Keywords One Warning 2 Nested Wait Until keywords are removed ${tc}= Check Test Case Nested - Length Should Be ${tc.kws[0].kws} 1 - Length Should Be ${tc.kws[0].kws[0].kws} 1 + Length Should Be ${tc.body[0].body.filter(messages=False)} 1 + Length Should Be ${tc.body[0].body[0].body} 1 *** Keywords *** Remove Wait Until Keyword Succeeds with Rebot @@ -30,8 +30,8 @@ Remove Wait Until Keyword Succeeds with Rebot Run Rebot --removekeywords wuKs ${INPUTFILE} Check Number Of Keywords - [Arguments] ${test name} ${expected number} - ${tc}= Check Test Case ${test name} - Length Should Be ${tc.kws[0].kws} ${expected number} - [Return] ${tc} + [Arguments] ${name} ${expected} + ${tc}= Check Test Case ${name} + Length Should Be ${tc.body[0].body.filter(messages=False)} ${expected} + RETURN ${tc} diff --git a/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot b/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot index efd72cc711a..4a7d0b5a812 100644 --- a/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot @@ -4,34 +4,30 @@ Suite Teardown Remove File ${INPUTFILE} Resource remove_keywords_resource.robot *** Variables *** -${0 REMOVED} ${EMPTY} -${1 REMOVED} _1 passing step removed using --RemoveKeywords option._ -${2 REMOVED} _2 passing steps removed using --RemoveKeywords option._ -${3 REMOVED} _3 passing steps removed using --RemoveKeywords option._ -${4 REMOVED} _4 passing steps removed using --RemoveKeywords option._ +${2 REMOVED} 2 passing steps removed using --RemoveKeywords option. +${4 REMOVED} 4 passing steps removed using --RemoveKeywords option. *** Test Cases *** Passed Steps Are Removed Except The Last One ${tc}= Check Test Case Loop executed multiple times - Length Should Be ${tc.kws[0].kws} 1 - Should Be Equal ${tc.kws[0].doc} ${4 REMOVED} - Should Be Equal ${tc.kws[0].kws[0].status} PASS + Length Should Be ${tc.kws[0].kws} 1 + Should Be Equal ${tc.kws[0].message} *HTML* ${4 REMOVED} + Should Be Equal ${tc.kws[0].kws[0].status} PASS Failed Steps Are Not Removed ${tc}= Check Test Case Execution fails after some loops Length Should Be ${tc.kws[0].kws} 1 - Should Be Equal ${tc.kws[0].doc} ${2 REMOVED} + Should Be Equal ${tc.kws[0].message} *HTML* Oh no, got 4
${2 REMOVED} Should Be Equal ${tc.kws[0].kws[0].status} FAIL Length Should Be ${tc.kws[0].kws[0].kws} 3 Should Be Equal ${tc.kws[0].kws[0].kws[-1].status} NOT RUN Steps From Nested Loops Are Removed ${tc}= Check Test Case Loop in loop - Length Should Be ${tc.kws[0].kws} 1 - Should Be Equal ${tc.kws[0].doc} ${4 REMOVED} - Length Should Be ${tc.kws[0].kws[0].kws[2].kws} 1 - Should Be Equal ${tc.kws[0].kws[0].kws[2].doc} ${2 REMOVED} - + Length Should Be ${tc.kws[0].kws} 1 + Should Be Equal ${tc.kws[0].message} *HTML* ${4 REMOVED} + Length Should Be ${tc.kws[0].kws[0].kws[2].kws} 1 + Should Be Equal ${tc.kws[0].kws[0].kws[2].message} *HTML* ${2 REMOVED} *** Keywords *** Remove For Loop Keywords With Rebot diff --git a/atest/robot/output/flatten_keyword.robot b/atest/robot/output/flatten_keyword.robot index 89f27b8df7e..8ff20c2493b 100644 --- a/atest/robot/output/flatten_keyword.robot +++ b/atest/robot/output/flatten_keyword.robot @@ -9,61 +9,61 @@ ${FLATTEN} --FlattenKeywords NAME:Keyword3 ... --flat TAG:flattenNOTkitty ... --flatten "name:Flatten controls in keyword" ... --log log.html -${FLAT TEXT} _*Content flattened.*_ -${FLAT HTML}

Content flattened.\\x3c/b>\\x3c/i>\\x3c/p> +${FLATTENED} Content flattened. ${ERROR} [ ERROR ] Invalid value for option '--flattenkeywords': Expected 'FOR', 'WHILE', 'ITERATION', 'TAG:' or 'NAME:', got 'invalid'.${USAGE TIP}\n *** Test Cases *** Non-matching keyword is not flattened - Should Be Equal ${TC.kws[0].doc} Doc of keyword 2 - Length Should Be ${TC.kws[0].kws} 2 - Length Should Be ${TC.kws[0].msgs} 0 - Check Log Message ${TC.kws[0].kws[0].msgs[0]} 2 - Check Log Message ${TC.kws[0].kws[1].kws[0].msgs[0]} 1 + Should Be Equal ${TC.kws[0].message} ${EMPTY} + Should Be Equal ${TC.kws[0].doc} Doc of keyword 2 + Length Should Be ${TC.kws[0].kws} 2 + Length Should Be ${TC.kws[0].msgs} 0 + Check Log Message ${TC.kws[0].kws[0].msgs[0]} 2 + Check Log Message ${TC.kws[0].kws[1].kws[1].msgs[0]} 1 Exact match - Should Be Equal ${TC.kws[1].doc} Doc of keyword 3\n\n${FLAT TEXT} - Length Should Be ${TC.kws[1].kws} 0 - Length Should Be ${TC.kws[1].msgs} 3 - Check Log Message ${TC.kws[1].msgs[0]} 3 - Check Log Message ${TC.kws[1].msgs[1]} 2 - Check Log Message ${TC.kws[1].msgs[2]} 1 + Should Be Equal ${TC.kws[1].message} *HTML* ${FLATTENED} + Should Be Equal ${TC.kws[1].doc} Doc of keyword 3 + Length Should Be ${TC.kws[1].kws} 0 + Length Should Be ${TC.kws[1].msgs} 3 + Check Log Message ${TC.kws[1].msgs[0]} 3 + Check Log Message ${TC.kws[1].msgs[1]} 2 + Check Log Message ${TC.kws[1].msgs[2]} 1 Pattern match - Should Be Equal ${TC.kws[2].doc} ${FLAT TEXT} - Length Should Be ${TC.kws[2].kws} 0 - Length Should Be ${TC.kws[2].msgs} 6 - Check Log Message ${TC.kws[2].msgs[0]} 3 - Check Log Message ${TC.kws[2].msgs[1]} 2 - Check Log Message ${TC.kws[2].msgs[2]} 1 - Check Log Message ${TC.kws[2].msgs[3]} 2 - Check Log Message ${TC.kws[2].msgs[4]} 1 - Check Log Message ${TC.kws[2].msgs[5]} 1 + Should Be Equal ${TC.kws[2].message} *HTML* ${FLATTENED} + Should Be Equal ${TC.kws[2].doc} ${EMPTY} + Length Should Be ${TC.kws[2].kws} 0 + Length Should Be ${TC.kws[2].msgs} 6 + Check Log Message ${TC.kws[2].msgs[0]} 3 + Check Log Message ${TC.kws[2].msgs[1]} 2 + Check Log Message ${TC.kws[2].msgs[2]} 1 + Check Log Message ${TC.kws[2].msgs[3]} 2 + Check Log Message ${TC.kws[2].msgs[4]} 1 + Check Log Message ${TC.kws[2].msgs[5]} 1 -Tag match when keyword has documentation - Should Be Equal ${TC.kws[5].doc} Doc of flat keyword.\n\n${FLAT TEXT} - Length Should Be ${TC.kws[5].kws} 0 - Length Should Be ${TC.kws[5].msgs} 1 +Tag match when keyword has no message + Should Be Equal ${TC.kws[5].message} *HTML* ${FLATTENED} + Should Be Equal ${TC.kws[5].doc} ${EMPTY} + Length Should Be ${TC.kws[5].kws} 0 + Length Should Be ${TC.kws[5].msgs} 1 -Tag match when keyword has no documentation - Should Be Equal ${TC.kws[6].doc} ${FLAT TEXT} - Length Should Be ${TC.kws[6].kws} 0 - Length Should Be ${TC.kws[6].msgs} 1 +Tag match when keyword has message + Should Be Equal ${TC.kws[6].message} *HTML* Expected e&<aped failure!


${FLATTENED} + Should Be Equal ${TC.kws[6].doc} Doc of flat keyword. + Length Should Be ${TC.kws[6].kws} 0 + Length Should Be ${TC.kws[6].msgs} 1 Match full name - Should Be Equal ${TC.kws[3].doc} Logs the given message with the given level.\n\n${FLAT TEXT} - Length Should Be ${TC.kws[3].kws} 0 - Length Should Be ${TC.kws[3].msgs} 1 + Should Be Equal ${TC.kws[3].message} *HTML* ${FLATTENED} + Should Be Equal ${TC.kws[3].doc} Logs the given message with the given level. + Length Should Be ${TC.kws[3].kws} 0 + Length Should Be ${TC.kws[3].msgs} 1 Check Log Message ${TC.kws[3].msgs[0]} Flatten me too!! Flattened in log after execution - Should Contain X Times ${LOG} Doc of keyword 3 1 - Should Contain X Times ${LOG} Doc of keyword 2 1 - Should Contain X Times ${LOG} Doc of keyword 1 1 - Should Contain X Times ${LOG} ${FLAT HTML} 6 - Should Contain ${LOG} *

Doc of keyword 3\\x3c/p>\\n${FLAT HTML} - Should Contain ${LOG} *${FLAT HTML} - Should Contain ${LOG} *

Logs the given message with the given level.\\x3c/p>\\n${FLAT HTML} + Should Contain ${LOG} "*Content flattened.\\x3c/i>" + Should Contain ${LOG} "*Expected e&<aped failure!


Content flattened.\\x3c/i>" Flatten controls in keyword ${tc} = Check Test Case ${TEST NAME} @@ -80,13 +80,13 @@ Flatten controls in keyword Check Log Message ${msg} ${exp} level=IGNORE END -Flatten for loops +Flatten FOR Run Rebot --flatten For ${OUTFILE COPY} - ${tc} = Check Test Case For loop - Should Be Equal ${tc.kws[0].type} FOR - Should Be Equal ${tc.kws[0].doc} ${FLAT TEXT} - Length Should Be ${tc.kws[0].kws} 0 - Length Should Be ${tc.kws[0].msgs} 60 + ${tc} = Check Test Case FOR loop + Should Be Equal ${tc.kws[0].type} FOR + Should Be Equal ${tc.kws[0].message} *HTML* ${FLATTENED} + Length Should Be ${tc.kws[0].kws} 0 + Length Should Be ${tc.kws[0].msgs} 60 FOR ${index} IN RANGE 10 Check Log Message ${tc.kws[0].msgs[${index * 6 + 0}]} index: ${index} Check Log Message ${tc.kws[0].msgs[${index * 6 + 1}]} 3 @@ -96,18 +96,18 @@ Flatten for loops Check Log Message ${tc.kws[0].msgs[${index * 6 + 5}]} 1 END -Flatten for loop iterations +Flatten FOR iterations Run Rebot --flatten ForItem ${OUTFILE COPY} - ${tc} = Check Test Case For loop - Should Be Equal ${tc.kws[0].type} FOR - Should Be Empty ${tc.kws[0].doc} - Length Should Be ${tc.kws[0].kws} 10 + ${tc} = Check Test Case FOR loop + Should Be Equal ${tc.kws[0].type} FOR + Should Be Equal ${tc.kws[0].message} ${EMPTY} + Length Should Be ${tc.kws[0].kws} 10 Should Be Empty ${tc.kws[0].msgs} FOR ${index} IN RANGE 10 - Should Be Equal ${tc.kws[0].kws[${index}].type} ITERATION - Should Be Equal ${tc.kws[0].kws[${index}].doc} ${FLAT TEXT} - Should Be Empty ${tc.kws[0].kws[${index}].kws} - Length Should Be ${tc.kws[0].kws[${index}].msgs} 6 + Should Be Equal ${tc.kws[0].kws[${index}].type} ITERATION + Should Be Equal ${tc.kws[0].kws[${index}].message} *HTML* ${FLATTENED} + Length Should Be ${tc.kws[0].kws[${index}].kws} 0 + Length Should Be ${tc.kws[0].kws[${index}].msgs} 6 Check Log Message ${tc.kws[0].kws[${index}].msgs[0]} index: ${index} Check Log Message ${tc.kws[0].kws[${index}].msgs[1]} 3 Check Log Message ${tc.kws[0].kws[${index}].msgs[2]} 2 @@ -116,13 +116,13 @@ Flatten for loop iterations Check Log Message ${tc.kws[0].kws[${index}].msgs[5]} 1 END -Flatten while loops +Flatten WHILE Run Rebot --flatten WHile ${OUTFILE COPY} ${tc} = Check Test Case WHILE loop - Should Be Equal ${tc.body[1].type} WHILE - Should Be Equal ${tc.body[1].doc} ${FLAT TEXT} - Length Should Be ${tc.body[1].kws} 0 - Length Should Be ${tc.body[1].msgs} 70 + Should Be Equal ${tc.body[1].type} WHILE + Should Be Equal ${tc.body[1].message} *HTML* ${FLATTENED} + Length Should Be ${tc.body[1].kws} 0 + Length Should Be ${tc.body[1].msgs} 70 FOR ${index} IN RANGE 10 Check Log Message ${tc.body[1].msgs[${index * 7 + 0}]} index: ${index} Check Log Message ${tc.body[1].msgs[${index * 7 + 1}]} 3 @@ -134,18 +134,18 @@ Flatten while loops Check Log Message ${tc.body[1].msgs[${index * 7 + 6}]} \${i} = ${i} END -Flatten while loop iterations +Flatten WHILE iterations Run Rebot --flatten iteration ${OUTFILE COPY} ${tc} = Check Test Case WHILE loop - Should Be Equal ${tc.body[1].type} WHILE - Should Be Empty ${tc.body[1].doc} - Length Should Be ${tc.body[1].body} 10 + Should Be Equal ${tc.body[1].type} WHILE + Should Be Equal ${tc.body[1].message} ${EMPTY} + Length Should Be ${tc.body[1].body} 10 Should Be Empty ${tc.body[1].msgs} FOR ${index} IN RANGE 10 - Should Be Equal ${tc.kws[1].kws[${index}].type} ITERATION - Should Be Equal ${tc.kws[1].kws[${index}].doc} ${FLAT TEXT} - Should Be Empty ${tc.kws[1].kws[${index}].kws} - Length Should Be ${tc.kws[1].kws[${index}].msgs} 7 + Should Be Equal ${tc.kws[1].kws[${index}].type} ITERATION + Should Be Equal ${tc.kws[1].kws[${index}].message} *HTML* ${FLATTENED} + Length Should Be ${tc.kws[1].kws[${index}].kws} 0 + Length Should Be ${tc.kws[1].kws[${index}].msgs} 7 Check Log Message ${tc.kws[1].kws[${index}].msgs[0]} index: ${index} Check Log Message ${tc.kws[1].kws[${index}].msgs[1]} 3 Check Log Message ${tc.kws[1].kws[${index}].msgs[2]} 2 diff --git a/atest/robot/rebot/merge.robot b/atest/robot/rebot/merge.robot index b7beda3bbfe..dc9e33f59ec 100644 --- a/atest/robot/rebot/merge.robot +++ b/atest/robot/rebot/merge.robot @@ -70,9 +70,9 @@ Using other options ... --merge. Most importantly verify that options handled ... by ExecutionResult (--flattenkeyword) work correctly. Re-run tests - Run merge --nomerge --log log.html --merge --flattenkeyword name:BuiltIn.Log --name Custom + Run merge --nomerge --log log.html --merge --flattenkeyword name:BuiltIn.Fail --name Custom Test merge should have been successful suite name=Custom - Log should have been created with all Log keywords flattened + Log should have been created with Fail keywords flattened Merge ignores skip Create Output With Robot ${ORIGINAL} ${EMPTY} rebot/merge_statuses.robot @@ -316,7 +316,6 @@ Create expected multi-merge message ... ${message 1} ...
${message 2} -Log should have been created with all Log keywords flattened +Log should have been created with Fail keywords flattened ${log} = Get File ${OUTDIR}/log.html - Should Not Contain ${log} "*

Logs the given message with the given level.\\x3c/p>" - Should Contain ${log} "*

Logs the given message with the given level.\\x3c/p>\\n

Content flattened.\\x3c/b>\\x3c/i>\\x3c/p>" + Should Contain ${log} "*Expected


Content flattened.\\x3c/i>" diff --git a/atest/robot/running/for/for.robot b/atest/robot/running/for/for.robot index f603b302c66..f55ad67c43a 100644 --- a/atest/robot/running/for/for.robot +++ b/atest/robot/running/for/for.robot @@ -122,7 +122,7 @@ Failure inside FOR Should be equal ${loop.kws[1].status} PASS Should be equal ${loop.kws[2].status} PASS Check log message ${loop.kws[3].kws[0].msgs[0]} Before Check - Check log message ${loop.kws[3].kws[1].msgs[0]} Failure with 4 FAIL + Check log message ${loop.kws[3].kws[1].msgs[0]} Failure with <4> FAIL Should be equal ${loop.kws[3].kws[2].status} NOT RUN Length should be ${loop.kws[3].kws} 3 Should be equal ${loop.kws[3].status} FAIL diff --git a/atest/testdata/cli/remove_keywords/wait_until_keyword_succeeds.robot b/atest/testdata/cli/remove_keywords/wait_until_keyword_succeeds.robot index 54d88dea25c..492e9f85429 100644 --- a/atest/testdata/cli/remove_keywords/wait_until_keyword_succeeds.robot +++ b/atest/testdata/cli/remove_keywords/wait_until_keyword_succeeds.robot @@ -3,8 +3,8 @@ ${COUNTER} ${0} *** Test Cases *** Fail until the end - [Documentation] FAIL Keyword 'Fail' failed after retrying for 200 milliseconds. The last error was: Not gonna happen - Wait Until Keyword Succeeds 0.2 0.05 Fail Not gonna happen + [Documentation] FAIL Keyword 'Fail' failed after retrying for 50 milliseconds. The last error was: Not gonna happen + Wait Until Keyword Succeeds 0.05 0.01 Fail Not gonna happen Passes before timeout Wait Until Keyword Succeeds 2 0.01 Fail Two Times @@ -13,24 +13,24 @@ Warnings Wait Until Keyword Succeeds 2 0.01 Warn And Fail Two Times One Warning - [Documentation] FAIL Keyword 'Warn On First And Fail Two Times' failed after retrying for 500 milliseconds. The last error was: Until the end - Wait Until Keyword Succeeds 0.5 0.01 Warn On First And Fail Two Times + [Documentation] FAIL Keyword 'Warn Once And Fail Afterwards' failed after retrying for 42 milliseconds. The last error was: Until the end + Wait Until Keyword Succeeds 0.042 0.01 Warn Once And Fail Afterwards Nested - [Documentation] FAIL Keyword 'Nested Wait' failed after retrying for 500 milliseconds. The last error was: Keyword 'Fail' failed after retrying for 50 milliseconds. The last error was: Always - Wait Until Keyword Succeeds 0.5 0.01 Nested Wait + [Documentation] FAIL Keyword 'Nested Wait' failed after retrying for 123 milliseconds. The last error was: Keyword 'Fail' failed after retrying for 50 milliseconds. The last error was: Always + Wait Until Keyword Succeeds 0.123 0.01 Nested Wait *** Keywords *** Fail Two Times Set Test Variable $COUNTER ${COUNTER + 1} - Run Keyword If ${COUNTER} != ${3} FAIL not enough tries + IF ${COUNTER} < 3 Fail Not enough attempts Warn And Fail Two Times Log DANGER MR. ROBINSON!! WARN Fail Two Times -Warn On First And Fail Two Times - Run Keyword If ${COUNTER} == ${0} log danger zone WARN +Warn Once And Fail Afterwards + IF ${COUNTER} == 0 Log Danger zone WARN Set Test Variable $COUNTER ${COUNTER + 1} Fail Until the end diff --git a/atest/testdata/output/flatten_keywords.robot b/atest/testdata/output/flatten_keywords.robot index 7bc1c6fa2ee..c0290fd0c19 100644 --- a/atest/testdata/output/flatten_keywords.robot +++ b/atest/testdata/output/flatten_keywords.robot @@ -1,15 +1,16 @@ *** Test Cases *** Flatten stuff + [Documentation] FAIL Expected e& +Control structures could have doc until RF 7.0. +Control structures could have doc until RF 7.0. @@ -2587,15 +2589,19 @@ can be used also for other purposes and extended as needed. +Control structures could have doc until RF 7.0. +Control structures could have doc until RF 7.0. +Control structures could have doc until RF 7.0. ${msg} Ooops! Auts! +Control structures could have doc until RF 7.0. Ooops! ${msg} @@ -2862,7 +2868,9 @@ AssertionError: Ooops! +Control structures could have doc until RF 7.0. +Control structures could have doc until RF 7.0. ${variable} Logs the given message with the given level. diff --git a/atest/testdata/running/for/for.robot b/atest/testdata/running/for/for.robot index ecb615ff36d..ade2f6b21dd 100644 --- a/atest/testdata/running/for/for.robot +++ b/atest/testdata/running/for/for.robot @@ -132,10 +132,10 @@ Failure inside FOR 1 Fail Not executed Failure inside FOR 2 - [Documentation] FAIL Failure with 4 + [Documentation] FAIL Failure with <4> FOR ${num} IN @{NUMS} Log Before Check - Should Not Be Equal ${num} 4 Failure with ${num} no values + Should Not Be Equal ${num} 4 Failure with <${num}> no values Log After Check END Fail Not executed diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index e03f3a61257..31086920a6b 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -120,7 +120,6 @@ - @@ -150,7 +149,6 @@ - @@ -165,7 +163,6 @@ - @@ -181,7 +178,6 @@ - @@ -198,7 +194,6 @@ - @@ -215,7 +210,6 @@ - @@ -235,7 +229,6 @@ - @@ -255,7 +248,6 @@ - diff --git a/src/robot/htmldata/rebot/log.html b/src/robot/htmldata/rebot/log.html index 75451f316c2..2d320efe15d 100644 --- a/src/robot/htmldata/rebot/log.html +++ b/src/robot/htmldata/rebot/log.html @@ -357,6 +357,12 @@

{{= testOrTask('{Test}')}} Execution Errors

Start / End / Elapsed: ${times.startTime} / ${times.endTime} / ${times.elapsedTime} + {{if message()}} + + Message: + {{html message()}} + + {{/if}} diff --git a/src/robot/htmldata/rebot/model.js b/src/robot/htmldata/rebot/model.js index 2c06512eb85..9164b29188f 100644 --- a/src/robot/htmldata/rebot/model.js +++ b/src/robot/htmldata/rebot/model.js @@ -14,7 +14,6 @@ window.model = (function () { suite.populateSuites = createIterablePopulator('Suite'); suite.childrenNames = ['keyword', 'suite', 'test']; suite.callWhenChildrenReady = function (callable) { callable(); }; - suite.message = data.message; suite.children = function () { return suite.keywords().concat(suite.tests()).concat(suite.suites()); }; @@ -110,6 +109,7 @@ window.model = (function () { name: data.name, doc: data.doc, status: data.status, + message: data.message, times: data.times, id: data.parent ? data.parent.id + '-' + data.id : data.id }; @@ -131,7 +131,6 @@ window.model = (function () { return test.keywords(); }; test.tags = data.tags; - test.message = data.message; test.matchesTagPattern = function (pattern) { return containsTagPattern(test.tags, pattern); }; diff --git a/src/robot/htmldata/rebot/testdata.js b/src/robot/htmldata/rebot/testdata.js index 801e86da4f5..7ca4adcf1dc 100644 --- a/src/robot/htmldata/rebot/testdata.js +++ b/src/robot/htmldata/rebot/testdata.js @@ -57,6 +57,7 @@ window.testdata = function () { } function createKeyword(parent, element, strings, index) { + var status = element[8]; var kw = model.Keyword({ parent: parent, type: KEYWORD_TYPES[element[0]], @@ -72,7 +73,12 @@ window.testdata = function () { this.doc = function () { return doc; }; return doc; }, - status: parseStatus(element[8], strings), + status: parseStatus(status), + message: function () { + var msg = status.length == 4 ? strings.get(status[3]) : ''; + this.message = function () { return msg; }; + return msg; + }, times: model.Times(times(element[8])), isChildrenLoaded: typeof(element[9]) !== 'number' }); diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 13d66938654..843c0239c15 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -87,7 +87,6 @@ def end_keyword(self, kw): def start_if(self, if_): self._writer.start('if') - self._writer.element('doc', if_.doc) def end_if(self, if_): self._write_status(if_) @@ -96,7 +95,6 @@ def end_if(self, if_): def start_if_branch(self, branch): self._writer.start('branch', {'type': branch.type, 'condition': branch.condition}) - self._writer.element('doc', branch.doc) def end_if_branch(self, branch): self._write_status(branch) @@ -111,7 +109,6 @@ def start_for(self, for_): self._writer.element('var', name) for value in for_.values: self._writer.element('value', value) - self._writer.element('doc', for_.doc) def end_for(self, for_): self._write_status(for_) @@ -121,7 +118,6 @@ def start_for_iteration(self, iteration): self._writer.start('iter') for name, value in iteration.assign.items(): self._writer.element('var', value, {'name': name}) - self._writer.element('doc', iteration.doc) def end_for_iteration(self, iteration): self._write_status(iteration) @@ -156,7 +152,6 @@ def start_while(self, while_): 'on_limit': while_.on_limit, 'on_limit_message': while_.on_limit_message }) - self._writer.element('doc', while_.doc) def end_while(self, while_): self._write_status(while_) @@ -164,7 +159,6 @@ def end_while(self, while_): def start_while_iteration(self, iteration): self._writer.start('iter') - self._writer.element('doc', iteration.doc) def end_while_iteration(self, iteration): self._write_status(iteration) diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index fc04bca8526..c329f428db0 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -14,12 +14,10 @@ # limitations under the License. from robot.output import LEVELS -from robot.result import (Break, Continue, Error, For, ForIteration, IfBranch, - Keyword, Return, TryBranch, While, WhileIteration) +from robot.result import Error, Keyword, Return from .jsbuildingcontext import JsBuildingContext from .jsexecutionresult import JsExecutionResult -from ..model import BodyItem STATUSES = {'FAIL': 0, 'PASS': 1, 'SKIP': 2, 'NOT RUN': 3} KEYWORD_TYPES = {'KEYWORD': 0, 'SETUP': 1, 'TEARDOWN': 2, @@ -61,7 +59,7 @@ def _get_status(self, item): model = (STATUSES[item.status], self._timestamp(item.start_time), round(item.elapsed_time.total_seconds() * 1000)) - msg = getattr(item, 'message', '') + msg = item.message if not msg: return model elif msg.startswith('*HTML*'): diff --git a/src/robot/result/keywordremover.py b/src/robot/result/keywordremover.py index cf7c16d8cbe..b1daa880e7e 100644 --- a/src/robot/result/keywordremover.py +++ b/src/robot/result/keywordremover.py @@ -15,7 +15,7 @@ from robot.errors import DataError from robot.model import SuiteVisitor, TagPattern -from robot.utils import Matcher, plural_or_not +from robot.utils import html_escape, Matcher, plural_or_not def KeywordRemover(how): @@ -36,7 +36,7 @@ def KeywordRemover(how): class _KeywordRemover(SuiteVisitor): - _message = 'Keyword data removed using --RemoveKeywords option.' + _message = 'Data removed using --RemoveKeywords option.' def __init__(self): self._removal_message = RemovalMessage(self._message) @@ -179,5 +179,11 @@ def set_if_removed(self, kw, len_before): if removed: self.set(kw, self._message % (removed, plural_or_not(removed))) - def set(self, kw, message=None): - kw.doc = ('%s\n\n_%s_' % (kw.doc, message or self._message)).strip() + def set(self, item, message=None): + if not item.message: + start = '' + elif item.message.startswith('*HTML*'): + start = item.message[6:].strip() + '
' + else: + start = html_escape(item.message) + '
' + item.message = f'*HTML* {start}{message or self._message}' diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 9c50f1ab753..15a69db8892 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -290,22 +290,23 @@ class ForIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): type = BodyItem.ITERATION body_class = Body repr_args = ('assign',) - __slots__ = ['assign', 'status', '_start_time', '_end_time', '_elapsed_time', 'doc'] + __slots__ = ['assign', 'message', 'status', '_start_time', '_end_time', + '_elapsed_time'] def __init__(self, assign: 'Mapping[str, str]|None' = None, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - doc: str = '', parent: BodyItemParent = None): self.assign = OrderedDict(assign or ()) self.parent = parent self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - self.doc = doc self.body = () @property @@ -331,7 +332,7 @@ def _name(self): class For(model.For, StatusMixin, DeprecatedAttributesMixin): iteration_class = ForIteration iterations_class = Iterations[iteration_class] - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] def __init__(self, assign: Sequence[str] = (), flavor: Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP'] = 'IN', @@ -340,17 +341,17 @@ def __init__(self, assign: Sequence[str] = (), mode: 'str|None' = None, fill: 'str|None' = None, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - doc: str = '', parent: BodyItemParent = None): super().__init__(assign, flavor, values, start, mode, fill, parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - self.doc = doc @setter def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> iterations_class: @@ -372,20 +373,20 @@ class WhileIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): """Represents one WHILE loop iteration.""" type = BodyItem.ITERATION body_class = Body - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] def __init__(self, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - doc: str = '', parent: BodyItemParent = None): self.parent = parent self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - self.doc = doc self.body = () @setter @@ -400,24 +401,24 @@ def visit(self, visitor: SuiteVisitor): class While(model.While, StatusMixin, DeprecatedAttributesMixin): iteration_class = WhileIteration iterations_class = Iterations[iteration_class] - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] def __init__(self, condition: 'str|None' = None, limit: 'str|None' = None, on_limit: 'str|None' = None, on_limit_message: 'str|None' = None, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - doc: str = '', parent: BodyItemParent = None): super().__init__(condition, limit, on_limit, on_limit_message, parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - self.doc = doc @setter def body(self, iterations: 'Sequence[WhileIteration|DataDict]') -> iterations_class: @@ -439,22 +440,22 @@ def _name(self): class IfBranch(model.IfBranch, StatusMixin, DeprecatedAttributesMixin): body_class = Body - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] def __init__(self, type: str = BodyItem.IF, condition: 'str|None' = None, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - doc: str = '', parent: BodyItemParent = None): super().__init__(type, condition, parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - self.doc = doc @property def _name(self): @@ -465,42 +466,42 @@ def _name(self): class If(model.If, StatusMixin, DeprecatedAttributesMixin): branch_class = IfBranch branches_class = Branches[branch_class] - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] def __init__(self, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - doc: str = '', parent: BodyItemParent = None): super().__init__(parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - self.doc = doc class TryBranch(model.TryBranch, StatusMixin, DeprecatedAttributesMixin): body_class = Body - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] def __init__(self, type: str = BodyItem.TRY, patterns: Sequence[str] = (), pattern_type: 'str|None' = None, assign: 'str|None' = None, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - doc: str = '', parent: BodyItemParent = None): super().__init__(type, patterns, pattern_type, assign, parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - self.doc = doc @property def _name(self): @@ -519,35 +520,37 @@ def _name(self): class Try(model.Try, StatusMixin, DeprecatedAttributesMixin): branch_class = TryBranch branches_class = Branches[branch_class] - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time', 'doc'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] def __init__(self, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - doc: str = '', parent: BodyItemParent = None): super().__init__(parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - self.doc = doc @Body.register class Return(model.Return, StatusMixin, DeprecatedAttributesMixin): - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] body_class = Body def __init__(self, values: Sequence[str] = (), status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, parent: BodyItemParent = None): super().__init__(values, parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time @@ -571,16 +574,18 @@ def doc(self) -> str: @Body.register class Continue(model.Continue, StatusMixin, DeprecatedAttributesMixin): - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] body_class = Body def __init__(self, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, parent: BodyItemParent = None): super().__init__(parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time @@ -604,16 +609,18 @@ def doc(self) -> str: @Body.register class Break(model.Break, StatusMixin, DeprecatedAttributesMixin): - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] body_class = Body def __init__(self, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, parent: BodyItemParent = None): super().__init__(parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time @@ -637,17 +644,19 @@ def doc(self) -> str: @Body.register class Error(model.Error, StatusMixin, DeprecatedAttributesMixin): - __slots__ = ['status', '_start_time', '_end_time', '_elapsed_time'] + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] body_class = Body def __init__(self, values: Sequence[str] = (), status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, parent: BodyItemParent = None): super().__init__(values, parent) self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time @@ -677,8 +686,8 @@ def _name(self): class Keyword(model.Keyword, StatusMixin): """Represents an executed library or user keyword.""" body_class = Body - __slots__ = ['kwname', 'libname', 'doc', 'timeout', 'status', '_teardown', - '_start_time', '_end_time', '_elapsed_time', 'message', 'sourcename'] + __slots__ = ['kwname', 'libname', 'sourcename', 'doc', 'timeout', 'status', + 'message', '_start_time', '_end_time', '_elapsed_time', '_teardown'] def __init__(self, kwname: str = '', libname: str = '', @@ -689,6 +698,7 @@ def __init__(self, kwname: str = '', timeout: 'str|None' = None, type: str = BodyItem.KEYWORD, status: str = 'FAIL', + message: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, @@ -699,17 +709,16 @@ def __init__(self, kwname: str = '', self.kwname = kwname #: Name of the library or resource containing this keyword. self.libname = libname + #: Original name of keyword with embedded arguments. + self.sourcename = sourcename self.doc = doc self.tags = tags self.timeout = timeout self.status = status + self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - #: Keyword status message. Used only if suite teardowns fails. - self.message = '' - #: Original name of keyword with embedded arguments. - self.sourcename = sourcename self._teardown = None self.body = () diff --git a/src/robot/result/modeldeprecation.py b/src/robot/result/modeldeprecation.py index 61bfee39c11..12d9b37629e 100644 --- a/src/robot/result/modeldeprecation.py +++ b/src/robot/result/modeldeprecation.py @@ -65,6 +65,5 @@ def timeout(self): return None @property - # FIXME @deprecated - def message(self): + def doc(self): return '' diff --git a/src/robot/result/resultbuilder.py b/src/robot/result/resultbuilder.py index a3cb4ceb7e0..9f6a6abae4f 100644 --- a/src/robot/result/resultbuilder.py +++ b/src/robot/result/resultbuilder.py @@ -15,7 +15,7 @@ from robot.errors import DataError from robot.model import SuiteVisitor -from robot.utils import ET, ETSource, get_error_message +from robot.utils import ET, ETSource, get_error_message, html_escape from .executionresult import Result, CombinedResult from .flattenkeywordmatcher import (FlattenByNameMatcher, FlattenByTypeMatcher, @@ -144,50 +144,47 @@ def _flatten_keywords(self, context, flattened): name_match, by_name = self._get_matcher(FlattenByNameMatcher, flattened) type_match, by_type = self._get_matcher(FlattenByTypeMatcher, flattened) tags_match, by_tags = self._get_matcher(FlattenByTagMatcher, flattened) - started = -1 # if 0 or more, we are flattening + started = -1 # if 0 or more, we are flattening tags = [] containers = {'kw', 'for', 'while', 'iter', 'if', 'try'} - inside_kw = 0 # to make sure we don't read tags from a test - seen_doc = False + inside = 0 # to make sure we don't read tags from a test for event, elem in context: tag = elem.tag - start = event == 'start' - end = not start - if start: + if event == 'start': if tag in containers: - inside_kw += 1 + inside += 1 if started >= 0: started += 1 elif by_name and name_match(elem.get('name', ''), elem.get('library')): started = 0 - seen_doc = False elif by_type and type_match(tag): started = 0 - seen_doc = False tags = [] else: if tag in containers: - inside_kw -= 1 - if started == 0 and not seen_doc: - doc = ET.Element('doc') - doc.text = '_*Content flattened.*_' - yield 'start', doc - yield 'end', doc - elif by_tags and inside_kw and started < 0 and tag == 'tag': + inside -= 1 + elif by_tags and inside and started < 0 and tag == 'tag': tags.append(elem.text or '') if tags_match(tags): started = 0 - seen_doc = False - elif started == 0 and tag == 'doc': - seen_doc = True - elem.text = f"{elem.text or ''}\n\n_*Content flattened.*_".strip() + elif started == 0 and tag == 'status': + elem.text = self._create_flattened_message(elem.text) if started <= 0 or tag == 'msg': yield event, elem else: elem.clear() - if started >= 0 and end and tag in containers: + if started >= 0 and event == 'end' and tag in containers: started -= 1 + def _create_flattened_message(self, original): + if not original: + start = '' + elif original.startswith('*HTML*'): + start = original[6:].strip() + '
' + else: + start = html_escape(original) + '
' + return f'*HTML* {start}Content flattened.' + def _get_matcher(self, matcher_class, flattened): matcher = matcher_class(flattened) return matcher.match, bool(matcher) diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 9bd56691056..5f7af2a9522 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -337,7 +337,13 @@ class DocHandler(ElementHandler): tag = 'doc' def end(self, elem, result): - result.doc = elem.text or '' + try: + result.doc = elem.text or '' + except AttributeError: + # With RF < 7 control structures can have `` containing information + # about flattening or removing date. Nowadays, they don't have `doc` + # attribute at all and `message` is used for this information. + result.message = elem.text or '' @ElementHandler.register diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index 44e0438eabc..c55b4ab15e9 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -60,8 +60,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): result.status = self.pass_status else: result.status = failure.status - if result.type == result.TEARDOWN: - result.message = failure.message + result.message = failure.message if self.initial_test_status == 'PASS': context.test.status = result.status result.elapsed_time = datetime.now() - result.start_time diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index ca8cc0fdd8f..45a67ced72e 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -4,7 +4,7 @@ from pathlib import Path from robot.utils.asserts import assert_equal, assert_true -from robot.result import Keyword, Message, TestCase, TestSuite, For +from robot.result import Keyword, Message, TestCase, TestSuite, For, ForIteration from robot.result.executionerrors import ExecutionErrors from robot.model import Statistics, BodyItem from robot.reporting.jsmodelbuilders import ( @@ -85,12 +85,12 @@ def test_default_keyword(self): def test_keyword_with_values(self): kw = Keyword('KW Name', 'libname', 'http://doc', ('arg1', 'arg2'), - ('${v1}', '${v2}'), ('tag1', 'tag2'), '1 second', 'SETUP', - 'PASS', '2011-12-04 19:42:42.000', '2011-12-04 19:42:42.042') + ('${v1}', '${v2}'), ('tag1', 'tag2'), '1 second', 'SETUP', 'FAIL', + 'message', '2011-12-04 19:42:42.000', '2011-12-04 19:42:42.042') self._verify_keyword(kw, 1, 'KW Name', 'libname', 'http://doc', 'arg1, arg2', '${v1}, ${v2}', 'tag1, tag2', - '1 second', 1, 0, 42) + '1 second', 0, 0, 42, 'message') def test_default_message(self): self._verify_message(Message()) @@ -135,11 +135,11 @@ def test_nested_structure(self): suite.tests = [TestCase(), TestCase(status='PASS')] S1 = self._verify_suite(suite.suites[0], status=0, tests=(t,), stats=(1, 0, 1, 0)) - suite.tests[0].body = [For(assign=['${x}'], flavor='IN', values=['1', '2']), Keyword()] - suite.tests[0].body[0].body = [Keyword(type=Keyword.ITERATION), Message()] + suite.tests[0].body = [For(assign=['${x}'], values=['1', '2'], message='x'), Keyword()] + suite.tests[0].body[0].body = [ForIteration(), Message()] k = self._verify_keyword(suite.tests[0].body[0].body[0], type=4) m = self._verify_message(suite.tests[0].body[0].body[1]) - k1 = self._verify_keyword(suite.tests[0].body[0], type=3, body=(k, m), kwname='${x} IN [ 1 | 2 ]') + k1 = self._verify_keyword(suite.tests[0].body[0], type=3, body=(k, m), kwname='${x} IN [ 1 | 2 ]', message='x') suite.tests[0].body[1].body = [Message(), Message('msg', level='TRACE')] m1 = self._verify_message(suite.tests[0].body[1].messages[0]) m2 = self._verify_message(suite.tests[0].body[1].messages[1], 'msg', level=0) @@ -211,7 +211,7 @@ def _verify_suite(self, suite, name='', doc='', metadata=(), source='', suites=(), tests=(), keywords=(), stats=(0, 0, 0, 0)): status = (status, start, elapsed, message) \ if message else (status, start, elapsed) - doc = '

%s

' % doc if doc else '' + doc = f'

{doc}

' if doc else '' return self._build_and_verify(SuiteBuilder, suite, name, source, relsource, doc, metadata, status, suites, tests, keywords, stats) @@ -223,15 +223,16 @@ def _verify_test(self, test, name='', doc='', tags=(), timeout='', status=0, message='', start=None, elapsed=0, body=()): status = (status, start, elapsed, message) \ if message else (status, start, elapsed) - doc = '

%s

' % doc if doc else '' + doc = f'

{doc}

' if doc else '' return self._build_and_verify(TestBuilder, test, name, timeout, doc, tags, status, body) def _verify_keyword(self, keyword, type=0, kwname='', libname='', doc='', args='', assign='', tags='', timeout='', status=0, - start=None, elapsed=0, body=()): - status = (status, start, elapsed) - doc = '

%s

' % doc if doc else '' + start=None, elapsed=0, message='', body=()): + status = (status, start, elapsed, message) \ + if message else (status, start, elapsed) + doc = f'

{doc}

' if doc else '' return self._build_and_verify(KeywordBuilder, keyword, type, kwname, libname, timeout, doc, args, assign, tags, status, body) From a097af89ba5ce299297ebe8c8667fdff9c3d1da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 14:29:16 +0300 Subject: [PATCH 0738/1592] Add support to rerun previous failed tests Common usage: atest/run.py atest/run.py -R Also some cleanup. --- atest/interpreter.py | 4 ++ atest/run.py | 87 +++++++++++++++++++++++++------------------- 2 files changed, 54 insertions(+), 37 deletions(-) diff --git a/atest/interpreter.py b/atest/interpreter.py index b170e47caf4..7723d043f0d 100644 --- a/atest/interpreter.py +++ b/atest/interpreter.py @@ -104,5 +104,9 @@ def libdoc(self): def testdoc(self): return self.interpreter + [str(ROBOT_DIR / 'testdoc.py')] + @property + def underline(self): + return '-' * len(str(self)) + def __str__(self): return f'{self.name} {self.version} on {self.os}' diff --git a/atest/run.py b/atest/run.py index 5cb6b1eabdc..c6dfc98b5f6 100755 --- a/atest/run.py +++ b/atest/run.py @@ -2,30 +2,33 @@ """A script for running Robot Framework's own acceptance tests. -Usage: atest/run.py [--interpreter name] [--schema-validation [options] [data] +Usage: atest/run.py [-I name] [-S] [-R] [options] [data] `data` is path (or paths) of the file or directory under the `atest/robot` folder to execute. If `data` is not given, all tests except for tests tagged with `no-ci` are executed. -Available `options` are the same that can be used with Robot Framework. -See its help (e.g. `robot --help`) for more information. +Available `options` are in general normal Robot Framework options, but there +are some exceptions listed below. -By default, uses the same Python interpreter for running tests that is used -for running this script. That can be changed by using the `--interpreter` -(`-I`) option. It can be the name of the interpreter like `pypy3` or a path -to the selected interpreter like `/usr/bin/python39`. If the interpreter -itself needs arguments, the interpreter and its arguments need to be quoted -like `"py -3.9"`. +By default, the same Python interpreter that is used for running this script is +also used for running tests. That can be changed by using the `--interpreter` +(`-I`) option. It can be the name of the interpreter like `pypy3` or a path to +the selected interpreter like `/usr/bin/python39`. If the interpreter itself +needs arguments, the interpreter and its arguments need to be quoted like +`"py -3.12"`. -To enable schema validation for all suites, use `--schema-validation` (`-S`) -option. This is same as setting `ATEST_VALIDATE_OUTPUT` environment variable -to `TRUE`. +To enable schema validation for all suites, use the `--schema-validation` +(`-S`) option. This is the same as setting the `ATEST_VALIDATE_OUTPUT` +environment variable to `TRUE`. + +Use `--rerun-failed (`-R`)` to re-execute failed tests from the previous run. Examples: $ atest/run.py $ atest/run.py --exclude no-ci atest/robot/standard_libraries $ atest/run.py --interpreter pypy3 +$ atest/run.py --rerun-failed The results of the test execution are written into an interpreter specific directory under the `atest/results` directory. Temporary outputs created @@ -34,23 +37,24 @@ import argparse import os -from pathlib import Path import shutil import signal import subprocess import sys import tempfile +from pathlib import Path from interpreter import Interpreter CURDIR = Path(__file__).parent +LATEST = CURDIR / 'results/latest.xml' ARGUMENTS = ''' --doc Robot Framework acceptance tests --metadata interpreter:{interpreter} --variable-file {variable_file};{interpreter.path};{interpreter.name};{interpreter.version} --pythonpath {pythonpath} ---output-dir {outputdir} +--output-dir {output_dir} --splitlog --console dotted --console-width 100 @@ -64,31 +68,32 @@ def atests(interpreter, arguments, schema_validation=False): try: interpreter = Interpreter(interpreter) except ValueError as err: - sys.exit(err) - outputdir, tempdir = _get_directories(interpreter) - arguments = list(_get_arguments(interpreter, outputdir)) + list(arguments) - rc = _run(arguments, tempdir, interpreter, schema_validation) - _rebot(rc, outputdir) + sys.exit(str(err)) + output_dir, temp_dir = _get_directories(interpreter) + arguments = list(_get_arguments(interpreter, output_dir)) + list(arguments) + rc = _run(arguments, temp_dir, interpreter, schema_validation) + if rc < 251: + _rebot(rc, output_dir) return rc def _get_directories(interpreter): name = interpreter.output_name - outputdir = CURDIR / 'results' / name - tempdir = Path(tempfile.gettempdir()) / 'robotatest' / name - if outputdir.exists(): - shutil.rmtree(outputdir) - if tempdir.exists(): - shutil.rmtree(tempdir) - os.makedirs(tempdir) - return outputdir, tempdir + output_dir = CURDIR / 'results' / name + temp_dir = Path(tempfile.gettempdir()) / 'robotatest' / name + if output_dir.exists(): + shutil.rmtree(output_dir) + if temp_dir.exists(): + shutil.rmtree(temp_dir) + os.makedirs(temp_dir) + return output_dir, temp_dir -def _get_arguments(interpreter, outputdir): +def _get_arguments(interpreter, output_dir): arguments = ARGUMENTS.format(interpreter=interpreter, variable_file=CURDIR / 'interpreter.py', pythonpath=CURDIR / 'resources', - outputdir=outputdir) + output_dir=output_dir) for line in arguments.splitlines(): yield from line.split(' ', 1) for exclude in interpreter.excludes: @@ -97,37 +102,45 @@ def _get_arguments(interpreter, outputdir): def _run(args, tempdir, interpreter, schema_validation): - command = [sys.executable, str(CURDIR.parent / 'src/robot/run.py')] + args + command = [str(c) for c in + [sys.executable, CURDIR.parent / 'src/robot/run.py'] + args] environ = dict(os.environ, TEMPDIR=str(tempdir), PYTHONCASEOK='True', PYTHONIOENCODING='') if schema_validation: environ['ATEST_VALIDATE_OUTPUT'] = 'TRUE' - print('%s\n%s\n' % (interpreter, '-' * len(str(interpreter)))) - print('Running command:\n%s\n' % ' '.join(command)) + print(f"{interpreter}\n{interpreter.underline}\n") + print(f"Running command:\n{' '.join(command)}\n") sys.stdout.flush() signal.signal(signal.SIGINT, signal.SIG_IGN) return subprocess.call(command, env=environ) -def _rebot(rc, outputdir): +def _rebot(rc, output_dir): + output = output_dir / 'output.xml' if rc == 0: print('All tests passed, not generating log or report.') - elif rc < 251: + else: command = [sys.executable, str(CURDIR.parent / 'src/robot/rebot.py'), - '--output-dir', str(outputdir), str(outputdir / 'output.xml')] + '--output-dir', str(output_dir), str(output)] subprocess.call(command) + shutil.copy(output, LATEST) if __name__ == '__main__': parser = argparse.ArgumentParser(add_help=False) parser.add_argument('-I', '--interpreter', default=sys.executable) parser.add_argument('-S', '--schema-validation', action='store_true') + parser.add_argument('-R', '--rerun-failed', action='store_true') parser.add_argument('-h', '--help', action='store_true') options, robot_args = parser.parse_known_args() - if not robot_args or not Path(robot_args[-1]).exists(): - robot_args += ['--exclude', 'no-ci', str(CURDIR/'robot')] + if options.rerun_failed: + robot_args = ['--rerun-failed', LATEST] + robot_args + last = Path(robot_args[-1]) if robot_args else None + source_given = last and (last.is_dir() or last.is_file() and last.suffix == '.robot') + if not source_given: + robot_args += ['--exclude', 'no-ci', CURDIR / 'robot'] if options.help: print(__doc__) rc = 251 From 5bb6345698d755b23d143896622438da1a425918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 16:03:34 +0300 Subject: [PATCH 0739/1592] Result model: Enhance storing keyword name - `kwname` -> `name` - `libname` -> `owner` - `name` -> `full_name` - `sourcename` -> `source_name` - Attributes stored to output.xml will match the names in the model exactly. Fixes #4884. See the issue for information about backwards compatibility and deprecations. --- atest/resources/TestCheckerLibrary.py | 24 +++-- atest/resources/atest_resource.robot | 2 +- .../dryrun/executed_builtin_keywords.robot | 6 +- .../remove_keywords_resource.robot | 2 +- atest/robot/cli/runner/exit_on_error.robot | 4 +- atest/robot/cli/runner/exit_on_failure.robot | 4 +- ...verriding_default_settings_with_none.robot | 4 +- .../robot/core/suite_setup_and_teardown.robot | 2 +- .../keywords/duplicate_dynamic_keywords.robot | 4 +- .../keywords/duplicate_hybrid_keywords.robot | 4 +- .../keywords/duplicate_static_keywords.robot | 8 +- atest/robot/keywords/embedded_arguments.robot | 22 ++--- .../embedded_arguments_library_keywords.robot | 28 +++--- atest/robot/keywords/keyword_names.robot | 51 +++++----- .../keywords/optional_given_when_then.robot | 96 +++++++++---------- .../using_run_keyword.robot | 82 ++++++++-------- atest/robot/output/processing_output.robot | 7 +- .../parsing/same_setting_multiple_times.robot | 8 +- atest/robot/parsing/suite_settings.robot | 2 +- atest/robot/parsing/test_case_settings.robot | 4 +- atest/robot/parsing/translations.robot | 78 +++++++-------- .../robot/parsing/user_keyword_settings.robot | 6 +- atest/robot/rebot/merge.robot | 2 +- .../robot/running/for/continue_for_loop.robot | 2 +- atest/robot/running/for/exit_for_loop.robot | 2 +- atest/robot/running/for/for.robot | 22 ++--- atest/robot/running/while/invalid_while.robot | 4 +- .../builtin/repeat_keyword.robot | 2 +- .../builtin/run_keyword.robot | 38 ++++---- .../run_keyword_if_test_passed_failed.robot | 2 +- .../robot/test_libraries/hybrid_library.robot | 4 +- .../internal_modules_not_importable.robot | 4 +- .../resources/embedded_args_in_lk_1.py | 3 +- doc/schema/robot.xsd | 7 +- src/robot/model/keyword.py | 14 +-- src/robot/output/debugfile.py | 6 +- src/robot/output/listenerarguments.py | 20 ++-- src/robot/output/xmllogger.py | 6 +- src/robot/reporting/expandkeywordmatcher.py | 2 +- src/robot/reporting/jsmodelbuilders.py | 10 +- src/robot/result/flattenkeywordmatcher.py | 16 ++-- src/robot/result/keywordremover.py | 8 +- src/robot/result/model.py | 75 +++++++++------ src/robot/result/resultbuilder.py | 3 +- src/robot/result/xmlelementhandlers.py | 24 +++-- src/robot/running/librarykeywordrunner.py | 6 +- src/robot/running/statusreporter.py | 4 +- src/robot/running/usererrorhandler.py | 4 +- src/robot/running/userkeywordrunner.py | 6 +- utest/output/test_listeners.py | 2 +- utest/reporting/test_jsexecutionresult.py | 10 +- utest/reporting/test_jsmodelbuilders.py | 63 ++++++------ utest/reporting/test_reporting.py | 4 +- utest/result/golden.xml | 10 +- utest/result/goldenTwice.xml | 20 ++-- utest/result/test_configurer.py | 12 +-- utest/result/test_keywordremover.py | 2 +- utest/result/test_resultbuilder.py | 8 +- utest/result/test_resultmodel.py | 44 ++++++--- utest/result/test_visitor.py | 18 ++-- 60 files changed, 485 insertions(+), 452 deletions(-) diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index fbe307d96e8..67121c7eea1 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -223,9 +223,9 @@ def _check_test_status(self, test, status=None, message=None): start = self._get_pattern(test, 'STARTS:') if test.message.startswith(start): return - raise AssertionError("Test '%s' had wrong message.\n\n" - "Expected:\n%s\n\nActual:\n%s\n" - % (test.name, test.exp_message, test.message)) + raise AssertionError(f"Test '{test.name}' had wrong message.\n\n" + f"Expected:\n{test.exp_message}\n\n" + f"Actual:\n{test.message}\n") def _get_pattern(self, test, prefix): pattern = test.exp_message[len(prefix):].strip() @@ -253,12 +253,12 @@ def should_contain_tests(self, suite, *names, **names_and_statuses): if len(tests) != len(expected): raise AssertionError("Wrong number of tests." + tests_msg) for test in tests: - logger.info("Verifying test '%s'" % test.name) + logger.info(f"Verifying test '{test.name}'") try: status = self._find_expected_status(test.name, expected) except IndexError: - raise AssertionError("Test '%s' was not expected to be run.%s" - % (test.name, tests_msg)) + raise AssertionError(f"Test '{test.name}' was not expected to be run." + + tests_msg) expected.pop(expected.index((test.name, status))) if status and ':' in status: status, message = status.split(':', 1) @@ -284,14 +284,12 @@ def should_contain_suites(self, suite, *expected): expected = sorted(expected) actual = sorted(s.name for s in suite.suites) if len(actual) != len(expected): - raise AssertionError("Wrong number of suites.\n" - "Expected (%d): %s\n" - "Actual (%d): %s" - % (len(expected), ', '.join(expected), - len(actual), ', '.join(actual))) + raise AssertionError(f"Wrong number of suites.\n" + f"Expected ({len(expected)}): {', '.join(expected)}\n" + f"Actual ({len(actual)}): {', '.join(actual)}") for name in expected: if not utils.Matcher(name).match_any(actual): - raise AssertionError('Suite %s not found' % name) + raise AssertionError(f'Suite {name} not found.') def should_contain_tags(self, test, *tags): logger.info('Test has tags', test.tags) @@ -301,7 +299,7 @@ def should_contain_tags(self, test, *tags): assert_equal(act, exp) def should_contain_keywords(self, item, *kw_names): - actual_names = [kw.name for kw in item.kws] + actual_names = [kw.full_name for kw in item.kws] assert_equal(len(actual_names), len(kw_names), 'Wrong number of keywords') for act, exp in zip(actual_names, kw_names): assert_equal(act, exp) diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index 65870a9d05a..5e0439251cf 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -115,7 +115,7 @@ Check Test Tags Check Keyword Data [Arguments] ${kw} ${name} ${assign}= ${args}= ${status}=PASS ${tags}= ${type}=KEYWORD - Should Be Equal ${kw.name} ${name} + Should Be Equal ${kw.full_name} ${name} Should Be Equal ${{', '.join($kw.assign)}} ${assign} Should Be Equal ${{', '.join($kw.args)}} ${args} Should Be Equal ${kw.status} ${status} diff --git a/atest/robot/cli/dryrun/executed_builtin_keywords.robot b/atest/robot/cli/dryrun/executed_builtin_keywords.robot index 7ad5f635a39..a8e1df246f1 100644 --- a/atest/robot/cli/dryrun/executed_builtin_keywords.robot +++ b/atest/robot/cli/dryrun/executed_builtin_keywords.robot @@ -10,9 +10,9 @@ Import Library Set Library Search Order ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[1].name} Second.Parameters - Should Be Equal ${tc.kws[2].name} First.Parameters - Should Be Equal ${tc.kws[4].name} Dynamic.Parameters + Should Be Equal ${tc.kws[1].full_name} Second.Parameters + Should Be Equal ${tc.kws[2].full_name} First.Parameters + Should Be Equal ${tc.kws[4].full_name} Dynamic.Parameters Set Tags Check Test Tags ${TESTNAME} \${2} \${var} Tag0 Tag1 Tag2 diff --git a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot index 9bc78241006..a6c63a81cb4 100644 --- a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot +++ b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot @@ -35,5 +35,5 @@ Keyword Should Not Be Empty Check Keyword Name And Args [Arguments] ${kw} ${name} @{args} - Should Be Equal ${kw.name} ${name} + Should Be Equal ${kw.full_name} ${name} Lists Should Be Equal ${kw.args} ${args} diff --git a/atest/robot/cli/runner/exit_on_error.robot b/atest/robot/cli/runner/exit_on_error.robot index 23538336dbd..b97367680f1 100644 --- a/atest/robot/cli/runner/exit_on_error.robot +++ b/atest/robot/cli/runner/exit_on_error.robot @@ -64,6 +64,6 @@ Teardowns not executed Teardowns executed [Arguments] ${name} ${suite} = Get Test Suite ${name} - Should Be Equal ${suite.teardown.name} BuiltIn.No Operation + Should Be Equal ${suite.teardown.full_name} BuiltIn.No Operation ${tc} = Check Test Case ${name} FAIL ${MESSAGE} - Should Be Equal ${tc.teardown.name} BuiltIn.No Operation + Should Be Equal ${tc.teardown.full_name} BuiltIn.No Operation diff --git a/atest/robot/cli/runner/exit_on_failure.robot b/atest/robot/cli/runner/exit_on_failure.robot index 18cdfa863af..885747676a9 100644 --- a/atest/robot/cli/runner/exit_on_failure.robot +++ b/atest/robot/cli/runner/exit_on_failure.robot @@ -38,9 +38,9 @@ Imports in subsequent suites are skipped Correct Suite Teardown Is Executed When ExitOnFailure Is Used [Setup] Run Tests -X misc/suites ${tsuite} = Get Test Suite Suites - Should Be Equal ${tsuite.teardown.name} BuiltIn.Log + Should Be Equal ${tsuite.teardown.full_name} BuiltIn.Log ${tsuite} = Get Test Suite Fourth - Should Be Equal ${tsuite.teardown.name} BuiltIn.Log + Should Be Equal ${tsuite.teardown.full_name} BuiltIn.Log ${tsuite} = Get Test Suite Tsuite3 Teardown Should Not Be Defined ${tsuite} diff --git a/atest/robot/core/overriding_default_settings_with_none.robot b/atest/robot/core/overriding_default_settings_with_none.robot index be042e3c300..1c15a93ba03 100644 --- a/atest/robot/core/overriding_default_settings_with_none.robot +++ b/atest/robot/core/overriding_default_settings_with_none.robot @@ -22,7 +22,7 @@ Overriding Test Teardown from Command Line Overriding Test Template ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.body[0].name} BuiltIn.No Operation + Should Be Equal ${tc.body[0].full_name} BuiltIn.No Operation Overriding Test Timeout ${tc}= Check Test Case ${TESTNAME} @@ -44,5 +44,5 @@ Overriding Is Case Insensitive ${tc}= Check Test Case ${TESTNAME} Setup Should Not Be Defined ${tc} Teardown Should Not Be Defined ${tc} - Should Be Equal ${tc.body[0].name} BuiltIn.No Operation + Should Be Equal ${tc.body[0].full_name} BuiltIn.No Operation Should Be Empty ${tc.tags} diff --git a/atest/robot/core/suite_setup_and_teardown.robot b/atest/robot/core/suite_setup_and_teardown.robot index d5f08a8a5bf..55428c0caf1 100644 --- a/atest/robot/core/suite_setup_and_teardown.robot +++ b/atest/robot/core/suite_setup_and_teardown.robot @@ -58,7 +58,7 @@ Erroring Suite Setup Length Should Be ${td.kws[0].msgs} 1 Check Log Message ${td.kws[0].msgs[0]} Hello from suite teardown! Should Be Empty ${td.kws[0].kws} - Should Be Equal ${td.kws[1].name} BuiltIn.No Operation + Should Be Equal ${td.kws[1].full_name} BuiltIn.No Operation Failing Higher Level Suite Setup Run Tests ${EMPTY} core/failing_higher_level_suite_setup diff --git a/atest/robot/keywords/duplicate_dynamic_keywords.robot b/atest/robot/keywords/duplicate_dynamic_keywords.robot index eb587323ca7..576cd759944 100644 --- a/atest/robot/keywords/duplicate_dynamic_keywords.robot +++ b/atest/robot/keywords/duplicate_dynamic_keywords.robot @@ -5,14 +5,14 @@ Resource atest_resource.robot *** Test Cases *** Using keyword defined multiple times fails ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} DupeDynamicKeywords.DEFINED TWICE + Should Be Equal ${tc.kws[0].full_name} DupeDynamicKeywords.DEFINED TWICE Error in library DupeDynamicKeywords ... Adding keyword 'DEFINED TWICE' failed: ... Keyword with same name defined multiple times. Keyword with embedded arguments defined multiple times fails at run-time ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} Embedded twice + Should Be Equal ${tc.kws[0].full_name} Embedded twice Length Should Be ${ERRORS} 1 Exact duplicate is accepted diff --git a/atest/robot/keywords/duplicate_hybrid_keywords.robot b/atest/robot/keywords/duplicate_hybrid_keywords.robot index 992fadcc07a..a6a5f774f4a 100644 --- a/atest/robot/keywords/duplicate_hybrid_keywords.robot +++ b/atest/robot/keywords/duplicate_hybrid_keywords.robot @@ -5,14 +5,14 @@ Resource atest_resource.robot *** Test Cases *** Using keyword defined multiple times fails ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} DupeHybridKeywords.DEFINED TWICE + Should Be Equal ${tc.kws[0].full_name} DupeHybridKeywords.DEFINED TWICE Error in library DupeHybridKeywords ... Adding keyword 'DEFINED TWICE' failed: ... Keyword with same name defined multiple times. Keyword with embedded arguments defined multiple times fails at run-time ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} Embedded twice + Should Be Equal ${tc.kws[0].full_name} Embedded twice Length Should Be ${ERRORS} 1 Exact duplicate is accepted diff --git a/atest/robot/keywords/duplicate_static_keywords.robot b/atest/robot/keywords/duplicate_static_keywords.robot index 37e30927004..178393985df 100644 --- a/atest/robot/keywords/duplicate_static_keywords.robot +++ b/atest/robot/keywords/duplicate_static_keywords.robot @@ -5,20 +5,20 @@ Resource atest_resource.robot *** Test Cases *** Using keyword defined twice fails ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} DupeKeywords.Defined twice + Should Be Equal ${tc.kws[0].full_name} DupeKeywords.Defined twice Creating keyword should have failed 2 Defined twice Using keyword defined thrice fails as well ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} DupeKeywords.Defined Thrice + Should Be Equal ${tc.kws[0].full_name} DupeKeywords.Defined Thrice Creating keyword should have failed 0 Defined Thrice Creating keyword should have failed 1 Defined Thrice Keyword with embedded arguments defined twice fails at run-time ${tc} = Check Test Case ${TESTNAME}: Called with embedded args - Should Be Equal ${tc.kws[0].name} Embedded arguments twice + Should Be Equal ${tc.kws[0].full_name} Embedded arguments twice ${tc} = Check Test Case ${TESTNAME}: Called with exact name - Should Be Equal ${tc.kws[0].name} Embedded \${arguments match} twice + Should Be Equal ${tc.kws[0].full_name} Embedded \${arguments match} twice Length Should Be ${ERRORS} 3 *** Keywords *** diff --git a/atest/robot/keywords/embedded_arguments.robot b/atest/robot/keywords/embedded_arguments.robot index 0a1da5a547b..faf06ff9c9a 100644 --- a/atest/robot/keywords/embedded_arguments.robot +++ b/atest/robot/keywords/embedded_arguments.robot @@ -12,16 +12,16 @@ Embedded Arguments In User Keyword Name File Should Contain ${OUTFILE} ... name="User Peke Selects Advanced Python From Webshop" File Should Contain ${OUTFILE} - ... sourcename="User \${user} Selects \${item} From Webshop" - File Should Not Contain ${OUTFILE} sourcename="Log" + ... source_name="User \${user} Selects \${item} From Webshop" + File Should Not Contain ${OUTFILE} source_name="Log" Complex Embedded Arguments ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].kws[0].msgs[0]} feature-works Check Log Message ${tc.kws[1].kws[0].msgs[0]} test case-is *executed* Check Log Message ${tc.kws[2].kws[0].msgs[0]} issue-is about to be done! - File Should Contain ${OUTFILE} sourcename="\${prefix:Given|When|Then} this - File Should Not Contain ${OUTFILE} sourcename="Log" + File Should Contain ${OUTFILE} source_name="\${prefix:Given|When|Then} this + File Should Not Contain ${OUTFILE} source_name="Log" Embedded Arguments with BDD Prefixes ${tc} = Check Test Case ${TEST NAME} @@ -31,14 +31,14 @@ Embedded Arguments with BDD Prefixes File Should Contain ${OUTFILE} ... name="Given user x selects y from webshop" File Should Contain ${OUTFILE} - ... sourcename="User \${user} Selects \${item} From Webshop" - File Should Not Contain ${OUTFILE} sourcename="Log" + ... source_name="User \${user} Selects \${item} From Webshop" + File Should Not Contain ${OUTFILE} source_name="Log" Argument Namespaces with Embedded Arguments Check Test Case ${TEST NAME} File Should Contain ${OUTFILE} name="My embedded warrior" - File Should Contain ${OUTFILE} sourcename="My embedded \${var}" - File Should Not Contain ${OUTFILE} sourcename="Log" + File Should Contain ${OUTFILE} source_name="My embedded \${var}" + File Should Not Contain ${OUTFILE} source_name="Log" Embedded Arguments as Variables ${tc} = Check Test Case ${TEST NAME} @@ -47,12 +47,12 @@ Embedded Arguments as Variables File Should Contain ${OUTFILE} ... name="User \${42} Selects \${EMPTY} From Webshop" File Should Contain ${OUTFILE} - ... sourcename="User \${user} Selects \${item} From Webshop" + ... source_name="User \${user} Selects \${item} From Webshop" File Should Contain ${OUTFILE} ... name="User \${name} Selects \${SPACE * 10} From Webshop" File Should Contain ${OUTFILE} - ... sourcename="User \${user} Selects \${item} From Webshop" - File Should Not Contain ${OUTFILE} sourcename="Log"> + ... source_name="User \${user} Selects \${item} From Webshop" + File Should Not Contain ${OUTFILE} source_name="Log"> Embedded Arguments as List And Dict Variables ${tc} = Check Test Case ${TEST NAME} diff --git a/atest/robot/keywords/embedded_arguments_library_keywords.robot b/atest/robot/keywords/embedded_arguments_library_keywords.robot index 68f232beefe..66cf0a5ab51 100755 --- a/atest/robot/keywords/embedded_arguments_library_keywords.robot +++ b/atest/robot/keywords/embedded_arguments_library_keywords.robot @@ -12,18 +12,18 @@ Embedded Arguments In Library Keyword Name File Should Contain ${OUTFILE} ... name="User Peke Selects Advanced Python From Webshop" File Should Contain ${OUTFILE} - ... library="embedded_args_in_lk_1" + ... owner="embedded_args_in_lk_1" File Should Contain ${OUTFILE} - ... sourcename="User \${user} Selects \${item} From Webshop" - File Should Not Contain ${OUTFILE} sourcename="Log" + ... source_name="User \${user} Selects \${item} From Webshop" + File Should Not Contain ${OUTFILE} source_name="Log" Complex Embedded Arguments ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} feature-works Check Log Message ${tc.kws[1].msgs[0]} test case-is *executed* Check Log Message ${tc.kws[2].msgs[0]} issue-is about to be done! - File Should Contain ${OUTFILE} sourcename="\${prefix:Given|When|Then} this - File Should Not Contain ${OUTFILE} sourcename="Log" + File Should Contain ${OUTFILE} source_name="\${prefix:Given|When|Then} this + File Should Not Contain ${OUTFILE} source_name="Log" Embedded Arguments with BDD Prefixes ${tc} = Check Test Case ${TEST NAME} @@ -31,28 +31,28 @@ Embedded Arguments with BDD Prefixes Check Keyword Data ${tc.kws[1]} embedded_args_in_lk_1.When user x selects y from webshop Check Keyword Data ${tc.kws[2]} embedded_args_in_lk_1.Then user x selects y from webshop \${x}, \${y} File Should Contain ${OUTFILE} name="Given user x selects y from webshop" - File Should Contain ${OUTFILE} library="embedded_args_in_lk_1" - File Should Contain ${OUTFILE} sourcename="User \${user} Selects \${item} From Webshop" - File Should Not Contain ${OUTFILE} sourcename="Log" + File Should Contain ${OUTFILE} owner="embedded_args_in_lk_1" + File Should Contain ${OUTFILE} source_name="User \${user} Selects \${item} From Webshop" + File Should Not Contain ${OUTFILE} source_name="Log" Argument Namespaces with Embedded Arguments Check Test Case ${TEST NAME} File Should Contain ${OUTFILE} name="My embedded warrior" - File Should Contain ${OUTFILE} library="embedded_args_in_lk_1" - File Should Contain ${OUTFILE} sourcename="My embedded \${var}" - File Should Not Contain ${OUTFILE} sourcename="Log" + File Should Contain ${OUTFILE} owner="embedded_args_in_lk_1" + File Should Contain ${OUTFILE} source_name="My embedded \${var}" + File Should Not Contain ${OUTFILE} source_name="Log" Embedded Arguments as Variables ${tc} = Check Test Case ${TEST NAME} File Should Contain ${OUTFILE} ... name="User \${42} Selects \${EMPTY} From Webshop" File Should Contain ${OUTFILE} - ... library="embedded_args_in_lk_1" + ... owner="embedded_args_in_lk_1" File Should Contain ${OUTFILE} - ... sourcename="User \${user} Selects \${item} From Webshop" + ... source_name="User \${user} Selects \${item} From Webshop" File Should Contain ${OUTFILE} ... name="User \${name} Selects \${SPACE * 10} From Webshop" - File Should Not Contain ${OUTFILE} sourcename="Log" + File Should Not Contain ${OUTFILE} source_name="Log" Embedded Arguments as List And Dict Variables ${tc} = Check Test Case ${TEST NAME} diff --git a/atest/robot/keywords/keyword_names.robot b/atest/robot/keywords/keyword_names.robot index 4fc7cc7b117..88e2ddc7c6a 100644 --- a/atest/robot/keywords/keyword_names.robot +++ b/atest/robot/keywords/keyword_names.robot @@ -21,27 +21,27 @@ Base Keyword Names In Test Case Test Case File User Keyword Names In Test Case File User Keyword ${test} = Check Test Case Test Case File User Keyword Names In Test Case File User Keyword Check Name and Three Keyword Names ${test.body[0]} Using Test Case File User Keywords Keyword Only In Test Case File - Should Be Equal ${test.body[1].name} Using Test Case File User Keywords Nested + Should Be Equal ${test.body[1].full_name} Using Test Case File User Keywords Nested Check Name and Three Keyword Names ${test.body[1].body[0]} Using Test Case File User Keywords Keyword Only In Test Case File Check Name and Three Keyword Names ${test.body[1].body[1]} Using Test Case File User Keywords Keyword Only In Test Case File Resource File User Keyword Names In Test Case File User Keyword ${test} = Check Test Case Resource File User Keyword Names In Test Case File User Keyword Check Name and Three Keyword Names ${test.body[0]} Using Resource File User Keywords my_resource_1.Keyword Only In Resource 1 - Should Be Equal ${test.body[1].name} Using Resource File User Keywords Nested + Should Be Equal ${test.body[1].full_name} Using Resource File User Keywords Nested Check Name and Three Keyword Names ${test.body[1].body[0]} Using Resource File User Keywords my_resource_1.Keyword Only In Resource 1 Check Name and Three Keyword Names ${test.body[1].body[1]} Using Resource File User Keywords my_resource_1.Keyword Only In Resource 1 Base Keyword Names In Test Case File User Keyword ${test} = Check Test Case Base Keyword Names In Test Case File User Keyword Check Name and Three Keyword Names ${test.body[0]} Using Base Keywords MyLibrary1.Keyword Only In Library 1 - Should Be Equal ${test.body[1].name} Using Base Keywords Nested + Should Be Equal ${test.body[1].full_name} Using Base Keywords Nested Check Name and Three Keyword Names ${test.body[1].body[0]} Using Base Keywords MyLibrary1.Keyword Only In Library 1 Check Name and Three Keyword Names ${test.body[1].body[1]} Using Base Keywords MyLibrary1.Keyword Only In Library 1 Test Case File User Keyword Names In Resource File User Keyword ${test} = Check Test Case Test Case File User Keyword Names In Resource File User Keyword - Should Be Equal ${test.body[0].name} my_resource_1.Using Test Case File User Keywords In Resource + Should Be Equal ${test.body[0].full_name} my_resource_1.Using Test Case File User Keywords In Resource Check Name and Three Keyword Names ${test.body[0].body[0]} Using Test Case File User Keywords Keyword Only In Test Case File Resource File User Keyword Names In Resource File User Keyword @@ -61,39 +61,39 @@ User Keyword Name Ending With Dot Name Set Using 'robot_name' Attribute ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} MyLibrary1.Name set using 'robot_name' attribute + Should Be Equal ${tc.kws[0].full_name} MyLibrary1.Name set using 'robot_name' attribute Check Log Message ${tc.kws[0].msgs[0]} My name was set using 'robot_name' attribute! Name Set Using 'robot.api.deco.keyword' Decorator ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} MyLibrary1.Name set using 'robot.api.deco.keyword' decorator + Should Be Equal ${tc.kws[0].full_name} MyLibrary1.Name set using 'robot.api.deco.keyword' decorator Check Log Message ${tc.kws[0].msgs[0]} My name was set using 'robot.api.deco.keyword' decorator! Custom non-ASCII name ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} MyLibrary1.Custom nön-ÄSCII name + Should Be Equal ${tc.kws[0].full_name} MyLibrary1.Custom nön-ÄSCII name Old Name Doesn't Work If Name Set Using 'robot_name' Check Test Case ${TESTNAME} Keyword can just be marked without changing its name ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} MyLibrary1.No Custom Name Given 1 - Should Be Equal ${tc.kws[1].name} MyLibrary1.No Custom Name Given 2 + Should Be Equal ${tc.kws[0].full_name} MyLibrary1.No Custom Name Given 1 + Should Be Equal ${tc.kws[1].full_name} MyLibrary1.No Custom Name Given 2 Functions decorated with @keyword can start with underscrore ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} MyLibrary1.I Start With An Underscore And I Am Ok + Should Be Equal ${tc.kws[0].full_name} MyLibrary1.I Start With An Underscore And I Am Ok Check Log Message ${tc.kws[0].msgs[0]} I'm marked with @keyword - Should Be Equal ${tc.kws[1].name} MyLibrary1.Function name can be whatever + Should Be Equal ${tc.kws[1].full_name} MyLibrary1.Function name can be whatever Check Log Message ${tc.kws[1].msgs[0]} Real name set by @keyword Assignment is not part of name ${tc} = Check Test Case ${TESTNAME} - Keyword name and assign should be ${tc.kws[0]} BuiltIn.Log - Keyword name and assign should be ${tc.kws[1]} BuiltIn.Set Variable \${var} - Keyword name and assign should be ${tc.kws[2]} BuiltIn.Set Variable \${v1} \${v2} - Keyword name and assign should be ${tc.kws[3]} BuiltIn.Evaluate \${first} \@{rest} + Check Keyword Data ${tc.kws[0]} BuiltIn.Log args=No assignment + Check Keyword Data ${tc.kws[1]} BuiltIn.Set Variable assign=\${var} args=value + Check Keyword Data ${tc.kws[2]} BuiltIn.Set Variable assign=\${v1}, \${v2} args=1, 2 + Check Keyword Data ${tc.kws[3]} BuiltIn.Evaluate assign=\${first}, \@{rest} args=range(10) Library name and keyword name are separate ${tc} = Check Test Case ${TESTNAME} @@ -116,26 +116,21 @@ Check Test And Three Keyword Names Check Name And Three Keyword Names [Arguments] ${item} ${exp_name} ${exp_kw_name} - Should Be Equal ${item.name} ${exp_name} + Should Be Equal ${item.full_name} ${exp_name} Check Three Keyword Names ${item} ${exp_kw_name} Check Three Keyword Names [Arguments] ${item} ${exp_kw_name} - Should Be Equal ${item.body[0].name} ${exp_kw_name} - Should Be Equal ${item.body[1].name} ${exp_kw_name} - Should Be Equal ${item.body[2].name} ${exp_kw_name} - -Keyword name and assign should be - [Arguments] ${kw} ${name} @{assign} - Should Be Equal ${kw.name} ${name} - Lists Should Be Equal ${kw.assign} ${assign} + Should Be Equal ${item.body[0].full_name} ${exp_kw_name} + Should Be Equal ${item.body[1].full_name} ${exp_kw_name} + Should Be Equal ${item.body[2].full_name} ${exp_kw_name} Keyword and library names should be [Arguments] ${kw} ${kwname} ${libname}=${None} - Should Be Equal ${kw.kwname} ${kwname} - Should Be Equal ${kw.libname} ${libname} + Should Be Equal ${kw.name} ${kwname} + Should Be Equal ${kw.owner} ${libname} IF $libname is None - Should Be Equal ${kw.name} ${kwname} + Should Be Equal ${kw.full_name} ${kwname} ELSE - Should Be Equal ${kw.name} ${libname}.${kwname} + Should Be Equal ${kw.full_name} ${libname}.${kwname} END diff --git a/atest/robot/keywords/optional_given_when_then.robot b/atest/robot/keywords/optional_given_when_then.robot index 88784053514..9f8dd9f884f 100644 --- a/atest/robot/keywords/optional_given_when_then.robot +++ b/atest/robot/keywords/optional_given_when_then.robot @@ -1,69 +1,69 @@ *** Settings *** -Suite Setup Run Tests --lang fi keywords/optional_given_when_then.robot +Suite Setup Run Tests --lang fi keywords/optional_given_when_then.robot Resource atest_resource.robot *** Test Cases *** In user keyword name with normal arguments - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].name} Given we don't drink too many beers - Should Be Equal ${tc.kws[1].name} When we are in - Should Be Equal ${tc.kws[2].name} But we don't drink too many beers - Should Be Equal ${tc.kws[3].name} And time - Should Be Equal ${tc.kws[4].name} Then we get this feature ready today - Should Be Equal ${tc.kws[5].name} and we don't drink too many beers + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].full_name} Given we don't drink too many beers + Should Be Equal ${tc.kws[1].full_name} When we are in + Should Be Equal ${tc.kws[2].full_name} But we don't drink too many beers + Should Be Equal ${tc.kws[3].full_name} And time + Should Be Equal ${tc.kws[4].full_name} Then we get this feature ready today + Should Be Equal ${tc.kws[5].full_name} and we don't drink too many beers In user keyword name with embedded arguments - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].name} Given we are in Berlin city - Should Be Equal ${tc.kws[1].name} When it does not rain - Should Be Equal ${tc.kws[2].name} And we get this feature implemented - Should Be Equal ${tc.kws[3].name} Then we go to walking tour - Should Be Equal ${tc.kws[4].name} but it does not rain + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].full_name} Given we are in Berlin city + Should Be Equal ${tc.kws[1].full_name} When it does not rain + Should Be Equal ${tc.kws[2].full_name} And we get this feature implemented + Should Be Equal ${tc.kws[3].full_name} Then we go to walking tour + Should Be Equal ${tc.kws[4].full_name} but it does not rain In library keyword name - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].name} BuiltIn.Given Should Be Equal - Should Be Equal ${tc.kws[1].name} BuiltIn.And Should Not Match - Should Be Equal ${tc.kws[2].name} BuiltIn.But Should Match - Should Be Equal ${tc.kws[3].name} BuiltIn.When set test variable - Should Be Equal ${tc.kws[4].name} BuiltIn.THEN should be equal + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].full_name} BuiltIn.Given Should Be Equal + Should Be Equal ${tc.kws[1].full_name} BuiltIn.And Should Not Match + Should Be Equal ${tc.kws[2].full_name} BuiltIn.But Should Match + Should Be Equal ${tc.kws[3].full_name} BuiltIn.When set test variable + Should Be Equal ${tc.kws[4].full_name} BuiltIn.THEN should be equal In user keyword in resource file - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].name} optional_given_when_then.Given Keyword Is In Resource File - Should Be Equal ${tc.kws[1].name} optional_given_when_then.and another resource file + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].full_name} optional_given_when_then.Given Keyword Is In Resource File + Should Be Equal ${tc.kws[1].full_name} optional_given_when_then.and another resource file Correct Name Shown in Keyword Not Found Error - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Keyword can be used with and without prefix - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].name} GiveN we don't drink too many beers - Should Be Equal ${tc.kws[1].name} and we don't drink too many beers - Should Be Equal ${tc.kws[2].name} We don't drink too many beers - Should Be Equal ${tc.kws[3].name} When time - Should Be Equal ${tc.kws[4].name} Time - Should Be Equal ${tc.kws[5].name} Then we are in Berlin city - Should Be Equal ${tc.kws[6].name} we are in Berlin city + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].full_name} GiveN we don't drink too many beers + Should Be Equal ${tc.kws[1].full_name} and we don't drink too many beers + Should Be Equal ${tc.kws[2].full_name} We don't drink too many beers + Should Be Equal ${tc.kws[3].full_name} When time + Should Be Equal ${tc.kws[4].full_name} Time + Should Be Equal ${tc.kws[5].full_name} Then we are in Berlin city + Should Be Equal ${tc.kws[6].full_name} we are in Berlin city Localized prefixes - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].name} Oletetaan we don't drink too many beers - Should Be Equal ${tc.kws[1].name} Kun we are in - Should Be Equal ${tc.kws[2].name} mutta we don't drink too many beers - Should Be Equal ${tc.kws[3].name} Ja time - Should Be Equal ${tc.kws[4].name} Niin we get this feature ready today - Should Be Equal ${tc.kws[5].name} ja we don't drink too many beers + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].full_name} Oletetaan we don't drink too many beers + Should Be Equal ${tc.kws[1].full_name} Kun we are in + Should Be Equal ${tc.kws[2].full_name} mutta we don't drink too many beers + Should Be Equal ${tc.kws[3].full_name} Ja time + Should Be Equal ${tc.kws[4].full_name} Niin we get this feature ready today + Should Be Equal ${tc.kws[5].full_name} ja we don't drink too many beers Prefix consisting of multiple words - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].name} Étant donné multipart prefixes didn't work with RF 6.0 - Should Be Equal ${tc.kws[1].name} Zakładając, że multipart prefixes didn't work with RF 6.0 - Should Be Equal ${tc.kws[2].name} Diyelim ki multipart prefixes didn't work with RF 6.0 - Should Be Equal ${tc.kws[3].name} Eğer ki multipart prefixes didn't work with RF 6.0 - Should Be Equal ${tc.kws[4].name} O zaman multipart prefixes didn't work with RF 6.0 - Should Be Equal ${tc.kws[5].name} В случай че multipart prefixes didn't work with RF 6.0 - Should Be Equal ${tc.kws[6].name} Fie ca multipart prefixes didn't work with RF 6.0 + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.kws[0].full_name} Étant donné multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[1].full_name} Zakładając, że multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[2].full_name} Diyelim ki multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[3].full_name} Eğer ki multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[4].full_name} O zaman multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[5].full_name} В случай че multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc.kws[6].full_name} Fie ca multipart prefixes didn't work with RF 6.0 Prefix must be followed by space - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} diff --git a/atest/robot/output/listener_interface/using_run_keyword.robot b/atest/robot/output/listener_interface/using_run_keyword.robot index 23f9a005f3f..1682371cee5 100644 --- a/atest/robot/output/listener_interface/using_run_keyword.robot +++ b/atest/robot/output/listener_interface/using_run_keyword.robot @@ -4,36 +4,36 @@ Resource listener_resource.robot *** Test Cases *** In start_suite when suite has no setup - Should Be Equal ${SUITE.setup.name} Implicit setup - Should Be Equal ${SUITE.setup.body[0].name} BuiltIn.Log + Should Be Equal ${SUITE.setup.full_name} Implicit setup + Should Be Equal ${SUITE.setup.body[0].full_name} BuiltIn.Log Check Log Message ${SUITE.setup.body[0].body[0]} start_suite Length Should Be ${SUITE.setup.body} 1 In end_suite when suite has no teardown - Should Be Equal ${SUITE.teardown.name} Implicit teardown - Should Be Equal ${SUITE.teardown.body[0].name} BuiltIn.Log + Should Be Equal ${SUITE.teardown.full_name} Implicit teardown + Should Be Equal ${SUITE.teardown.body[0].full_name} BuiltIn.Log Check Log Message ${SUITE.teardown.body[0].body[0]} end_suite Length Should Be ${SUITE.teardown.body} 1 In start_suite when suite has setup ${suite} = Set Variable ${SUITE.suites[1]} - Should Be Equal ${suite.setup.name} Suite Setup - Should Be Equal ${suite.setup.body[0].name} BuiltIn.Log + Should Be Equal ${suite.setup.full_name} Suite Setup + Should Be Equal ${suite.setup.body[0].full_name} BuiltIn.Log Check Log Message ${suite.setup.body[0].body[0]} start_suite Length Should Be ${suite.setup.body} 5 In end_suite when suite has teardown ${suite} = Set Variable ${SUITE.suites[1]} - Should Be Equal ${suite.teardown.name} Suite Teardown - Should Be Equal ${suite.teardown.body[-1].name} BuiltIn.Log + Should Be Equal ${suite.teardown.full_name} Suite Teardown + Should Be Equal ${suite.teardown.body[-1].full_name} BuiltIn.Log Check Log Message ${suite.teardown.body[-1].body[0]} end_suite Length Should Be ${suite.teardown.body} 5 In start_test and end_test when test has no setup or teardown ${tc} = Check Test Case First One - Should Be Equal ${tc.body[0].name} BuiltIn.Log + Should Be Equal ${tc.body[0].full_name} BuiltIn.Log Check Log Message ${tc.body[0].body[0]} start_test - Should Be Equal ${tc.body[-1].name} BuiltIn.Log + Should Be Equal ${tc.body[-1].full_name} BuiltIn.Log Check Log Message ${tc.body[-1].body[0]} end_test Length Should Be ${tc.body} 5 Should Not Be True ${tc.setup} @@ -41,36 +41,36 @@ In start_test and end_test when test has no setup or teardown In start_test and end_test when test has setup and teardown ${tc} = Check Test Case Test with setup and teardown - Should Be Equal ${tc.body[0].name} BuiltIn.Log + Should Be Equal ${tc.body[0].full_name} BuiltIn.Log Check Log Message ${tc.body[0].body[0]} start_test - Should Be Equal ${tc.body[-1].name} BuiltIn.Log + Should Be Equal ${tc.body[-1].full_name} BuiltIn.Log Check Log Message ${tc.body[-1].body[0]} end_test Length Should Be ${tc.body} 3 - Should Be Equal ${tc.setup.name} Test Setup - Should Be Equal ${tc.teardown.name} Test Teardown + Should Be Equal ${tc.setup.full_name} Test Setup + Should Be Equal ${tc.teardown.full_name} Test Teardown In start_keyword and end_keyword with library keyword ${tc} = Check Test Case First One - Should Be Equal ${tc.body[1].name} BuiltIn.Log - Should Be Equal ${tc.body[1].body[0].name} BuiltIn.Log + Should Be Equal ${tc.body[1].full_name} BuiltIn.Log + Should Be Equal ${tc.body[1].body[0].full_name} BuiltIn.Log Check Log Message ${tc.body[1].body[0].body[0]} start_keyword Check Log Message ${tc.body[1].body[1]} Test 1 - Should Be Equal ${tc.body[1].body[2].name} BuiltIn.Log + Should Be Equal ${tc.body[1].body[2].full_name} BuiltIn.Log Check Log Message ${tc.body[1].body[2].body[0]} end_keyword Length Should Be ${tc.body[1].body} 3 In start_keyword and end_keyword with user keyword ${tc} = Check Test Case First One - Should Be Equal ${tc.body[3].name} logs on trace - Should Be Equal ${tc.body[3].body[0].name} BuiltIn.Log + Should Be Equal ${tc.body[3].full_name} logs on trace + Should Be Equal ${tc.body[3].body[0].full_name} BuiltIn.Log Check Log Message ${tc.body[3].body[0].body[0]} start_keyword - Should Be Equal ${tc.body[3].body[1].name} BuiltIn.Log - Should Be Equal ${tc.body[3].body[1].body[0].name} BuiltIn.Log + Should Be Equal ${tc.body[3].body[1].full_name} BuiltIn.Log + Should Be Equal ${tc.body[3].body[1].body[0].full_name} BuiltIn.Log Check Log Message ${tc.body[3].body[1].body[0].body[0]} start_keyword - Should Be Equal ${tc.body[3].body[1].body[1].name} BuiltIn.Log + Should Be Equal ${tc.body[3].body[1].body[1].full_name} BuiltIn.Log Check Log Message ${tc.body[3].body[1].body[1].body[0]} end_keyword Length Should Be ${tc.body[3].body[1].body} 2 - Should Be Equal ${tc.body[3].body[2].name} BuiltIn.Log + Should Be Equal ${tc.body[3].body[2].full_name} BuiltIn.Log Check Log Message ${tc.body[3].body[2].body[0]} end_keyword Length Should Be ${tc.body[3].body} 3 @@ -80,9 +80,9 @@ In start_keyword and end_keyword with FOR loop Should Be Equal ${for.type} FOR Length Should Be ${for.body} 5 Length Should Be ${for.body.filter(keywords=True)} 2 - Should Be Equal ${for.body[0].name} BuiltIn.Log + Should Be Equal ${for.body[0].full_name} BuiltIn.Log Check Log Message ${for.body[0].body[0]} start_keyword - Should Be Equal ${for.body[-1].name} BuiltIn.Log + Should Be Equal ${for.body[-1].full_name} BuiltIn.Log Check Log Message ${for.body[-1].body[0]} end_keyword In start_keyword and end_keyword with WHILE @@ -91,9 +91,9 @@ In start_keyword and end_keyword with WHILE Should Be Equal ${while.type} WHILE Length Should Be ${while.body} 7 Length Should Be ${while.body.filter(keywords=True)} 2 - Should Be Equal ${while.body[0].name} BuiltIn.Log + Should Be Equal ${while.body[0].full_name} BuiltIn.Log Check Log Message ${while.body[0].body[0]} start_keyword - Should Be Equal ${while.body[-1].name} BuiltIn.Log + Should Be Equal ${while.body[-1].full_name} BuiltIn.Log Check Log Message ${while.body[-1].body[0]} end_keyword In start_keyword and end_keyword with IF/ELSE @@ -118,23 +118,23 @@ In start_keyword and end_keyword with BREAK and CONTINUE ${tc} = Check Test Case WHILE loop in keyword FOR ${iter} IN @{tc.body[1].body[2].body[1:-1]} Should Be Equal ${iter.body[3].body[0].body[1].type} CONTINUE - Should Be Equal ${iter.body[3].body[0].body[1].body[0].name} BuiltIn.Log + Should Be Equal ${iter.body[3].body[0].body[1].body[0].full_name} BuiltIn.Log Check Log Message ${iter.body[3].body[0].body[1].body[0].body[0]} start_keyword - Should Be Equal ${iter.body[3].body[0].body[1].body[1].name} BuiltIn.Log + Should Be Equal ${iter.body[3].body[0].body[1].body[1].full_name} BuiltIn.Log Check Log Message ${iter.body[3].body[0].body[1].body[1].body[0]} end_keyword Should Be Equal ${iter.body[4].body[0].body[1].type} BREAK - Should Be Equal ${iter.body[4].body[0].body[1].body[0].name} BuiltIn.Log + Should Be Equal ${iter.body[4].body[0].body[1].body[0].full_name} BuiltIn.Log Check Log Message ${iter.body[4].body[0].body[1].body[0].body[0]} start_keyword - Should Be Equal ${iter.body[4].body[0].body[1].body[1].name} BuiltIn.Log + Should Be Equal ${iter.body[4].body[0].body[1].body[1].full_name} BuiltIn.Log Check Log Message ${iter.body[4].body[0].body[1].body[1].body[0]} end_keyword END In start_keyword and end_keyword with RETURN ${tc} = Check Test Case Second One Should Be Equal ${tc.body[3].body[1].body[1].body[2].type} RETURN - Should Be Equal ${tc.body[3].body[1].body[1].body[2].body[0].name} BuiltIn.Log + Should Be Equal ${tc.body[3].body[1].body[1].body[2].body[0].full_name} BuiltIn.Log Check Log Message ${tc.body[3].body[1].body[1].body[2].body[0].body[0]} start_keyword - Should Be Equal ${tc.body[3].body[1].body[1].body[2].body[1].name} BuiltIn.Log + Should Be Equal ${tc.body[3].body[1].body[1].body[2].body[1].full_name} BuiltIn.Log Check Log Message ${tc.body[3].body[1].body[1].body[2].body[1].body[0]} end_keyword *** Keywords *** @@ -155,27 +155,27 @@ Validate IF branch Should Be Equal ${branch.type} ${type} Should Be Equal ${branch.status} ${status} Length Should Be ${branch.body} 3 - Should Be Equal ${branch.body[0].name} BuiltIn.Log + Should Be Equal ${branch.body[0].full_name} BuiltIn.Log Check Log Message ${branch.body[0].body[0]} start_keyword IF $status == 'PASS' - Should Be Equal ${branch.body[1].name} BuiltIn.Log - Should Be Equal ${branch.body[1].body[0].name} BuiltIn.Log + Should Be Equal ${branch.body[1].full_name} BuiltIn.Log + Should Be Equal ${branch.body[1].body[0].full_name} BuiltIn.Log Check Log Message ${branch.body[1].body[0].body[0]} start_keyword Check Log Message ${branch.body[1].body[1]} else if branch - Should Be Equal ${branch.body[1].body[2].name} BuiltIn.Log + Should Be Equal ${branch.body[1].body[2].full_name} BuiltIn.Log Check Log Message ${branch.body[1].body[2].body[0]} end_keyword ELSE - Should Be Equal ${branch.body[1].name} BuiltIn.Fail + Should Be Equal ${branch.body[1].full_name} BuiltIn.Fail Should Be Equal ${branch.body[1].status} NOT RUN END - Should Be Equal ${branch.body[-1].name} BuiltIn.Log + Should Be Equal ${branch.body[-1].full_name} BuiltIn.Log Check Log Message ${branch.body[-1].body[0]} end_keyword Validate FOR branch [Arguments] ${branch} ${type} ${status} Should Be Equal ${branch.type} ${type} Should Be Equal ${branch.status} ${status} - Should Be Equal ${branch.body[0].name} BuiltIn.Log + Should Be Equal ${branch.body[0].full_name} BuiltIn.Log Check Log Message ${branch.body[0].body[0]} start_keyword - Should Be Equal ${branch.body[-1].name} BuiltIn.Log + Should Be Equal ${branch.body[-1].full_name} BuiltIn.Log Check Log Message ${branch.body[-1].body[0]} end_keyword diff --git a/atest/robot/output/processing_output.robot b/atest/robot/output/processing_output.robot index 86426fb8e6b..0f9841f7e0e 100644 --- a/atest/robot/output/processing_output.robot +++ b/atest/robot/output/processing_output.robot @@ -50,7 +50,6 @@ My Run Robot And Rebot Check Normal Suite Defaults [Arguments] ${suite} ${message}= ${setup}=${None} ${teardown}=${None} - Log ${suite.name} Check Suite Defaults ${suite} ${message} ${setup} ${teardown} Check Normal Suite Times ${suite} @@ -73,9 +72,9 @@ Check Minimal Suite Times Check Suite Defaults [Arguments] ${suite} ${message}= ${setup}=${None} ${teardown}=${None} - Should Be Equal ${suite.message} ${message} - Should Be Equal ${suite.setup.name} ${setup} - Should Be Equal ${suite.teardown.name} ${teardown} + Should Be Equal ${suite.message} ${message} + Should Be Equal ${suite.setup.full_name} ${setup} + Should Be Equal ${suite.teardown.full_name} ${teardown} Check Suite Got From Misc/suites/ Directory Check Normal Suite Defaults ${SUITE} teardown=BuiltIn.Log diff --git a/atest/robot/parsing/same_setting_multiple_times.robot b/atest/robot/parsing/same_setting_multiple_times.robot index 50beeddefd3..a6fbacd1043 100644 --- a/atest/robot/parsing/same_setting_multiple_times.robot +++ b/atest/robot/parsing/same_setting_multiple_times.robot @@ -10,17 +10,17 @@ Suite Metadata Should Be Equal ${SUITE.metadata['Foo']} M2 Suite Setup - Should Be Equal ${SUITE.setup.name} BuiltIn.Log Many + Should Be Equal ${SUITE.setup.full_name} BuiltIn.Log Many Suite Teardown - Should Be Equal ${SUITE.teardown.name} BuiltIn.Comment + Should Be Equal ${SUITE.teardown.full_name} BuiltIn.Comment Force and Default Tags Check Test Tags Use Defaults D1 Test Setup ${tc} = Check Test Case Use Defaults - Should Be Equal ${tc.setup.name} BuiltIn.Log Many + Should Be Equal ${tc.setup.full_name} BuiltIn.Log Many Test Teardown ${tc} = Check Test Case Use Defaults @@ -45,7 +45,7 @@ Test [Tags] Test [Setup] ${tc} = Check Test Case Test Settings - Should Be Equal ${tc.setup.name} BuiltIn.Log Many + Should Be Equal ${tc.setup.full_name} BuiltIn.Log Many Test [Teardown] ${tc} = Check Test Case Test Settings diff --git a/atest/robot/parsing/suite_settings.robot b/atest/robot/parsing/suite_settings.robot index 377d4963020..40a69865eee 100644 --- a/atest/robot/parsing/suite_settings.robot +++ b/atest/robot/parsing/suite_settings.robot @@ -74,5 +74,5 @@ Verify Teardown Verify Fixture [Arguments] ${fixture} ${expected_name} ${expected_message} - Should be Equal ${fixture.name} ${expected_name} + Should be Equal ${fixture.full_name} ${expected_name} Check Log Message ${fixture.messages[0]} ${expected_message} diff --git a/atest/robot/parsing/test_case_settings.robot b/atest/robot/parsing/test_case_settings.robot index 2ad222c2a9c..2991e73a3b8 100644 --- a/atest/robot/parsing/test_case_settings.robot +++ b/atest/robot/parsing/test_case_settings.robot @@ -197,13 +197,13 @@ Verify Tags Verify Setup [Arguments] ${message} ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.setup.name} BuiltIn.Log + Should Be Equal ${tc.setup.full_name} BuiltIn.Log Check Log Message ${tc.setup.msgs[0]} ${message} Verify Teardown [Arguments] ${message} ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.teardown.name} BuiltIn.Log + Should Be Equal ${tc.teardown.full_name} BuiltIn.Log Check Log Message ${tc.teardown.msgs[0]} ${message} Verify Timeout diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index b06b83b686d..a9fe09e2799 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -62,44 +62,44 @@ Per file configuration bleeds to other files *** Keywords *** Validate Translations [Arguments] ${suite}=${SUITE} - Should Be Equal ${suite.name} Custom name - Should Be Equal ${suite.doc} Suite documentation. - Should Be Equal ${suite.metadata}[Metadata] Value - Should Be Equal ${suite.setup.name} Suite Setup - Should Be Equal ${suite.teardown.name} Suite Teardown - Should Be Equal ${suite.status} PASS - ${tc} = Check Test Case Test without settings - Should Be Equal ${tc.doc} ${EMPTY} - Should Be Equal ${tc.tags} ${{['test', 'tags']}} - Should Be Equal ${tc.timeout} 1 minute - Should Be Equal ${tc.setup.name} Test Setup - Should Be Equal ${tc.teardown.name} Test Teardown - Should Be Equal ${tc.body[0].name} Test Template - Should Be Equal ${tc.body[0].tags} ${{['keyword', 'tags']}} - ${tc} = Check Test Case Test with settings - Should Be Equal ${tc.doc} Test documentation. - Should Be Equal ${tc.tags} ${{['test', 'tags', 'own tag']}} - Should Be Equal ${tc.timeout} ${NONE} - Should Be Equal ${tc.setup.name} ${NONE} - Should Be Equal ${tc.teardown.name} ${NONE} - Should Be Equal ${tc.body[0].name} Keyword - Should Be Equal ${tc.body[0].doc} Keyword documentation. - Should Be Equal ${tc.body[0].tags} ${{['keyword', 'tags', 'own tag']}} - Should Be Equal ${tc.body[0].timeout} 1 hour - Should Be Equal ${tc.body[0].teardown.name} BuiltIn.No Operation + Should Be Equal ${suite.name} Custom name + Should Be Equal ${suite.doc} Suite documentation. + Should Be Equal ${suite.metadata}[Metadata] Value + Should Be Equal ${suite.setup.full_name} Suite Setup + Should Be Equal ${suite.teardown.full_name} Suite Teardown + Should Be Equal ${suite.status} PASS + ${tc} = Check Test Case Test without settings + Should Be Equal ${tc.doc} ${EMPTY} + Should Be Equal ${tc.tags} ${{['test', 'tags']}} + Should Be Equal ${tc.timeout} 1 minute + Should Be Equal ${tc.setup.full_name} Test Setup + Should Be Equal ${tc.teardown.full_name} Test Teardown + Should Be Equal ${tc.body[0].full_name} Test Template + Should Be Equal ${tc.body[0].tags} ${{['keyword', 'tags']}} + ${tc} = Check Test Case Test with settings + Should Be Equal ${tc.doc} Test documentation. + Should Be Equal ${tc.tags} ${{['test', 'tags', 'own tag']}} + Should Be Equal ${tc.timeout} ${NONE} + Should Be Equal ${tc.setup.full_name} ${NONE} + Should Be Equal ${tc.teardown.full_name} ${NONE} + Should Be Equal ${tc.body[0].full_name} Keyword + Should Be Equal ${tc.body[0].doc} Keyword documentation. + Should Be Equal ${tc.body[0].tags} ${{['keyword', 'tags', 'own tag']}} + Should Be Equal ${tc.body[0].timeout} 1 hour + Should Be Equal ${tc.body[0].teardown.full_name} BuiltIn.No Operation Validate Task Translations - ${tc} = Check Test Case Task without settings - Should Be Equal ${tc.doc} ${EMPTY} - Should Be Equal ${tc.tags} ${{['task', 'tags']}} - Should Be Equal ${tc.timeout} 1 minute - Should Be Equal ${tc.setup.name} Task Setup - Should Be Equal ${tc.teardown.name} Task Teardown - Should Be Equal ${tc.body[0].name} Task Template - ${tc} = Check Test Case Task with settings - Should Be Equal ${tc.doc} Task documentation. - Should Be Equal ${tc.tags} ${{['task', 'tags', 'own tag']}} - Should Be Equal ${tc.timeout} ${NONE} - Should Be Equal ${tc.setup.name} ${NONE} - Should Be Equal ${tc.teardown.name} ${NONE} - Should Be Equal ${tc.body[0].name} BuiltIn.Log + ${tc} = Check Test Case Task without settings + Should Be Equal ${tc.doc} ${EMPTY} + Should Be Equal ${tc.tags} ${{['task', 'tags']}} + Should Be Equal ${tc.timeout} 1 minute + Should Be Equal ${tc.setup.full_name} Task Setup + Should Be Equal ${tc.teardown.full_name} Task Teardown + Should Be Equal ${tc.body[0].full_name} Task Template + ${tc} = Check Test Case Task with settings + Should Be Equal ${tc.doc} Task documentation. + Should Be Equal ${tc.tags} ${{['task', 'tags', 'own tag']}} + Should Be Equal ${tc.timeout} ${NONE} + Should Be Equal ${tc.setup.full_name} ${NONE} + Should Be Equal ${tc.teardown.full_name} ${NONE} + Should Be Equal ${tc.body[0].full_name} BuiltIn.Log diff --git a/atest/robot/parsing/user_keyword_settings.robot b/atest/robot/parsing/user_keyword_settings.robot index d74604ea7cf..d0961ccb433 100644 --- a/atest/robot/parsing/user_keyword_settings.robot +++ b/atest/robot/parsing/user_keyword_settings.robot @@ -5,12 +5,12 @@ Resource atest_resource.robot *** Test Cases *** Name ${tc} = Check Test Case Normal name - Should Be Equal ${tc.kws[0].name} Normal name + Should Be Equal ${tc.kws[0].full_name} Normal name Names are not formatted ${tc} = Check Test Case Names are not formatted FOR ${kw} IN @{tc.kws} - Should Be Equal ${kw.name} user_keyword nameS _are_not_ FORmatted + Should Be Equal ${kw.full_name} user_keyword nameS _are_not_ FORmatted END No documentation @@ -115,7 +115,7 @@ Verify Documentation Verify Teardown [Arguments] ${message} ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].teardown.name} BuiltIn.Log + Should Be Equal ${tc.kws[0].teardown.full_name} BuiltIn.Log Check Log Message ${tc.kws[0].teardown.msgs[0]} ${message} Verify Timeout diff --git a/atest/robot/rebot/merge.robot b/atest/robot/rebot/merge.robot index dc9e33f59ec..fec795fb92f 100644 --- a/atest/robot/rebot/merge.robot +++ b/atest/robot/rebot/merge.robot @@ -175,7 +175,7 @@ Test merge should have been successful ... ${SUITE.suites[7]} Suite setup and teardown should have been merged - Should Be Equal ${SUITE.setup.name} BuiltIn.No Operation + Should Be Equal ${SUITE.setup.full_name} BuiltIn.No Operation Should Be Equal ${SUITE.teardown.name} ${NONE} Should Be Equal ${SUITE.suites[1].name} Fourth Check Log Message ${SUITE.suites[1].setup.msgs[0]} Rerun! diff --git a/atest/robot/running/for/continue_for_loop.robot b/atest/robot/running/for/continue_for_loop.robot index 5f50a6bd23e..4b4cc9f3c8a 100644 --- a/atest/robot/running/for/continue_for_loop.robot +++ b/atest/robot/running/for/continue_for_loop.robot @@ -26,7 +26,7 @@ Continue For Loop In User Keyword Without For Loop Should Fail Continue For Loop Keyword Should Log Info ${tc} = Check Test Case Simple Continue For Loop - Should Be Equal ${tc.kws[0].kws[0].kws[0].name} BuiltIn.Continue For Loop + Should Be Equal ${tc.kws[0].kws[0].kws[0].full_name} BuiltIn.Continue For Loop Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Continuing for loop from the next iteration. Continue For Loop In Test Teardown diff --git a/atest/robot/running/for/exit_for_loop.robot b/atest/robot/running/for/exit_for_loop.robot index 00d3062c9de..127d8708d00 100644 --- a/atest/robot/running/for/exit_for_loop.robot +++ b/atest/robot/running/for/exit_for_loop.robot @@ -29,7 +29,7 @@ Exit For Loop In User Keyword Without For Loop Should Fail Exit For Loop Keyword Should Log Info ${tc} = Check Test Case Simple Exit For Loop - Should Be Equal ${tc.kws[0].kws[0].kws[0].name} BuiltIn.Exit For Loop + Should Be Equal ${tc.kws[0].kws[0].kws[0].full_name} BuiltIn.Exit For Loop Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Exiting for loop altogether. Exit For Loop In Test Teardown diff --git a/atest/robot/running/for/for.robot b/atest/robot/running/for/for.robot index f55ad67c43a..0d7a2ec21e1 100644 --- a/atest/robot/running/for/for.robot +++ b/atest/robot/running/for/for.robot @@ -294,26 +294,26 @@ Header at the end of file *** Keywords *** "Variables in values" helper [Arguments] ${kw} ${num} - Check log message ${kw.kws[0].msgs[0]} ${num} - Check log message ${kw.kws[1].msgs[0]} Hello from for loop - Should be equal ${kw.kws[2].name} BuiltIn.No Operation + Check log message ${kw.kws[0].msgs[0]} ${num} + Check log message ${kw.kws[1].msgs[0]} Hello from for loop + Should be equal ${kw.kws[2].full_name} BuiltIn.No Operation Check kw "My UK" [Arguments] ${kw} - Should be equal ${kw.name} My UK - Should be equal ${kw.kws[0].name} BuiltIn.No Operation - Check log message ${kw.kws[1].msgs[0]} We are in My UK + Should be equal ${kw.full_name} My UK + Should be equal ${kw.kws[0].full_name} BuiltIn.No Operation + Check log message ${kw.kws[1].msgs[0]} We are in My UK Check kw "My UK 2" [Arguments] ${kw} ${arg} - Should be equal ${kw.name} My UK 2 + Should be equal ${kw.full_name} My UK 2 Check kw "My UK" ${kw.kws[0]} - Check log message ${kw.kws[1].msgs[0]} My UK 2 got argument "${arg}" + Check log message ${kw.kws[1].msgs[0]} My UK 2 got argument "${arg}" Check kw "My UK" ${kw.kws[2]} Check kw "For In UK" [Arguments] ${kw} - Should be equal ${kw.name} For In UK + Should be equal ${kw.full_name} For In UK Check log message ${kw.kws[0].msgs[0]} Not for yet Should be FOR loop ${kw.kws[1]} 2 Check log message ${kw.kws[1].kws[0].kws[0].msgs[0]} This is for with 1 @@ -324,7 +324,7 @@ Check kw "For In UK" Check kw "For In UK With Args" [Arguments] ${kw} ${arg_count} ${first_arg} - Should be equal ${kw.name} For In UK With Args + Should be equal ${kw.full_name} For In UK With Args Should be FOR loop ${kw.kws[0]} ${arg_count} Check kw "My UK 2" ${kw.kws[0].kws[0].kws[0]} ${first_arg} Should be FOR loop ${kw.kws[2]} 1 @@ -335,7 +335,7 @@ Check kw "Nested For In UK" Should be FOR loop ${kw.kws[0]} 1 FAIL Check kw "For In UK" ${kw.kws[0].kws[0].kws[0]} ${nested2} = Set Variable ${kw.kws[0].kws[0].kws[1]} - Should be equal ${nested2.name} Nested For In UK 2 + Should be equal ${nested2.full_name} Nested For In UK 2 Should be FOR loop ${nested2.kws[0]} 2 Check kw "For In UK" ${nested2.kws[0].kws[0].kws[0]} Check log message ${nested2.kws[0].kws[0].kws[1].msgs[0]} Got arg: ${first_arg} diff --git a/atest/robot/running/while/invalid_while.robot b/atest/robot/running/while/invalid_while.robot index 553ce83046c..b0ec2f3a5aa 100644 --- a/atest/robot/running/while/invalid_while.robot +++ b/atest/robot/running/while/invalid_while.robot @@ -46,7 +46,7 @@ Check Invalid WHILE Test Case Should Be Equal ${tc.body[0].body[0].type} ITERATION Should Be Equal ${tc.body[0].body[0].status} NOT RUN IF ${body} - Should Be Equal ${tc.body[0].body[0].body[0].name} BuiltIn.Fail - Should Be Equal ${tc.body[0].body[0].body[0].status} NOT RUN + Should Be Equal ${tc.body[0].body[0].body[0].full_name} BuiltIn.Fail + Should Be Equal ${tc.body[0].body[0].body[0].status} NOT RUN END RETURN ${tc} diff --git a/atest/robot/standard_libraries/builtin/repeat_keyword.robot b/atest/robot/standard_libraries/builtin/repeat_keyword.robot index e7ebaacf2fb..289eb94822e 100644 --- a/atest/robot/standard_libraries/builtin/repeat_keyword.robot +++ b/atest/robot/standard_libraries/builtin/repeat_keyword.robot @@ -98,5 +98,5 @@ Check Repeated Keyword Name [Arguments] ${kw} ${count} ${name}=${None} Should Be Equal As Integers ${kw.kw_count} ${count} FOR ${i} IN RANGE ${count} - Should Be Equal ${kw.kws[${i}].name} ${name} + Should Be Equal ${kw.kws[${i}].full_name} ${name} END diff --git a/atest/robot/standard_libraries/builtin/run_keyword.robot b/atest/robot/standard_libraries/builtin/run_keyword.robot index 7116e8d7051..a887910bc04 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword.robot @@ -5,17 +5,17 @@ Resource atest_resource.robot *** Test Cases *** Run Keyword ${tc} = Check test Case ${TEST NAME} - Check Run Keyword ${tc.kws[0]} BuiltIn.Log This is logged with Run Keyword + Check Run Keyword ${tc.kws[0]} BuiltIn.Log This is logged with Run Keyword Check Keyword Data ${tc.kws[1].kws[0]} BuiltIn.No Operation - Check Run Keyword ${tc.kws[2]} BuiltIn.Log Many 1 2 3 4 5 - Check Run Keyword ${tc.kws[4]} BuiltIn.Log Run keyword with variable: Log - Check Run Keyword ${tc.kws[6]} BuiltIn.Log Many one two + Check Run Keyword ${tc.kws[2]} BuiltIn.Log Many 1 2 3 4 5 + Check Run Keyword ${tc.kws[4]} BuiltIn.Log Run keyword with variable: Log + Check Run Keyword ${tc.kws[6]} BuiltIn.Log Many one two Run Keyword Returning Value ${tc} = Check test Case ${TEST NAME} - Check Keyword Data ${tc.kws[0]} BuiltIn.Run Keyword \${ret} Set Variable, hello world + Check Keyword Data ${tc.kws[0]} BuiltIn.Run Keyword \${ret} Set Variable, hello world Check Keyword Data ${tc.kws[0].kws[0]} BuiltIn.Set Variable args=hello world - Check Keyword Data ${tc.kws[2]} BuiltIn.Run Keyword \${ret} Evaluate, 1+2 + Check Keyword Data ${tc.kws[2]} BuiltIn.Run Keyword \${ret} Evaluate, 1+2 Check Keyword Data ${tc.kws[2].kws[0]} BuiltIn.Evaluate args=1+2 Run Keyword With Arguments That Needs To Be Escaped @@ -66,9 +66,9 @@ With library keyword accepting embedded arguments as variables containing object Run Keyword In For Loop ${tc} = Check test Case ${TEST NAME} - Check Run Keyword ${tc.kws[0].kws[0].kws[0]} BuiltIn.Log hello from for loop + Check Run Keyword ${tc.kws[0].kws[0].kws[0]} BuiltIn.Log hello from for loop Check Run Keyword In UK ${tc.kws[0].kws[2].kws[0]} BuiltIn.Log hei maailma - Check Run Keyword ${tc.kws[1].kws[0].kws[0]} BuiltIn.Log hello from second for loop + Check Run Keyword ${tc.kws[1].kws[0].kws[0]} BuiltIn.Log hello from second for loop Run Keyword With Test Timeout Check Test Case ${TEST NAME} Passing @@ -98,26 +98,26 @@ Stdout and stderr are not captured when running Run Keyword *** Keywords *** Check Run Keyword [Arguments] ${kw} ${subkw_name} @{msgs} - Should Be Equal ${kw.name} BuiltIn.Run Keyword - Should Be Equal ${kw.kws[0].name} ${subkw_name} + Should Be Equal ${kw.full_name} BuiltIn.Run Keyword + Should Be Equal ${kw.kws[0].full_name} ${subkw_name} FOR ${index} ${msg} IN ENUMERATE @{msgs} Check Log Message ${kw.kws[0].msgs[${index}]} ${msg} END Check Run Keyword In Uk [Arguments] ${kw} ${subkw_name} @{msgs} - Should Be Equal ${kw.name} BuiltIn.Run Keyword - Should Be Equal ${kw.kws[0].name} My UK - Check Run Keyword ${kw.kws[0].kws[0]} ${subkw_name} @{msgs} + Should Be Equal ${kw.full_name} BuiltIn.Run Keyword + Should Be Equal ${kw.kws[0].full_name} My UK + Check Run Keyword ${kw.kws[0].kws[0]} ${subkw_name} @{msgs} Check Run Keyword With Embedded Args [Arguments] ${kw} ${subkw_name} ${msg} - Should Be Equal ${kw.name} BuiltIn.Run Keyword + Should Be Equal ${kw.full_name} BuiltIn.Run Keyword IF ${subkw_name.endswith('library')} - Should Be Equal ${kw.kws[0].name} embedded_args.${subkw_name} - Check Log Message ${kw.kws[0].msgs[0]} ${msg} + Should Be Equal ${kw.kws[0].full_name} embedded_args.${subkw_name} + Check Log Message ${kw.kws[0].msgs[0]} ${msg} ELSE - Should Be Equal ${kw.kws[0].name} ${subkw_name} - Should Be Equal ${kw.kws[0].kws[0].name} BuiltIn.Log - Check Log Message ${kw.kws[0].kws[0].msgs[0]} ${msg} + Should Be Equal ${kw.kws[0].full_name} ${subkw_name} + Should Be Equal ${kw.kws[0].kws[0].full_name} BuiltIn.Log + Check Log Message ${kw.kws[0].kws[0].msgs[0]} ${msg} END diff --git a/atest/robot/standard_libraries/builtin/run_keyword_if_test_passed_failed.robot b/atest/robot/standard_libraries/builtin/run_keyword_if_test_passed_failed.robot index d162baf3204..5d8a75270be 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_if_test_passed_failed.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_if_test_passed_failed.robot @@ -5,7 +5,7 @@ Resource atest_resource.robot *** Test Cases *** Run Keyword If Test Failed when test fails ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.teardown.body[0].name} BuiltIn.Log + Should Be Equal ${tc.teardown.body[0].full_name} BuiltIn.Log Check Log Message ${tc.teardown.body[0].msgs[0]} Hello from teardown! Run Keyword If Test Failed in user keyword when test fails diff --git a/atest/robot/test_libraries/hybrid_library.robot b/atest/robot/test_libraries/hybrid_library.robot index 970ba79ce1d..0e96475407e 100644 --- a/atest/robot/test_libraries/hybrid_library.robot +++ b/atest/robot/test_libraries/hybrid_library.robot @@ -46,8 +46,8 @@ Embedded Keyword Arguments Name starting with an underscore is OK ${tc} = Check Test Case ${TESTNAME} - Should be equal ${tc.kws[0].name} GetKeywordNamesLibrary.Starting With Underscore Is Ok - Check log message ${tc.kws[0].msgs[0]} This is explicitly returned from 'get_keyword_names' anyway. + Check Keyword Data ${tc.body[0]} GetKeywordNamesLibrary.Starting With Underscore Is Ok + Check Log Message ${tc.body[0].msgs[0]} This is explicitly returned from 'get_keyword_names' anyway. Invalid get_keyword_names Error in file 3 test_libraries/hybrid_library.robot 3 diff --git a/atest/robot/test_libraries/internal_modules_not_importable.robot b/atest/robot/test_libraries/internal_modules_not_importable.robot index 728968d2f03..f0867d1796f 100644 --- a/atest/robot/test_libraries/internal_modules_not_importable.robot +++ b/atest/robot/test_libraries/internal_modules_not_importable.robot @@ -24,5 +24,5 @@ Standard libraries can be imported through `robot.libraries.` In test data standard libraries can be imported directly ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} OperatingSystem.Directory Should Exist - Should Be Equal ${tc.kws[1].name} OperatingSystem.Directory Should Exist + Should Be Equal ${tc.kws[0].full_name} OperatingSystem.Directory Should Exist + Should Be Equal ${tc.kws[1].full_name} OperatingSystem.Directory Should Exist diff --git a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py index 06fc0c3ecd3..e4b6bc586bf 100755 --- a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py +++ b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py @@ -3,6 +3,7 @@ from robot.libraries.BuiltIn import BuiltIn +ROBOT_AUTO_KEYWORDS = False should_be_equal = BuiltIn().should_be_equal log = logger.write @@ -81,7 +82,7 @@ def literal_opening_curly_brace(curly): should_be_equal(curly, "{") -@keyword(name="Literal ${Curly:\}} Brace") +@keyword(name=r"Literal ${Curly:\}} Brace") def literal_closing_curly_brace(curly): should_be_equal(curly, "}") diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index 31086920a6b..fca11940804 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -103,12 +103,13 @@ - - - + + + + diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index c141ac00339..293a1fe1cd5 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -13,11 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import cast, Sequence, Type, TYPE_CHECKING -import warnings +from typing import Sequence, TYPE_CHECKING from .body import Body, BodyItem, BodyItemParent -from .itemlist import ItemList from .modelobject import DataDict if TYPE_CHECKING: @@ -32,7 +30,7 @@ class Keyword(BodyItem): :class:`robot.result.model.Keyword`. """ repr_args = ('name', 'args', 'assign') - __slots__ = ['_name', 'args', 'assign', 'type'] + __slots__ = ['name', 'args', 'assign', 'type'] def __init__(self, name: 'str|None' = '', args: Sequence[str] = (), @@ -45,14 +43,6 @@ def __init__(self, name: 'str|None' = '', self.type = type self.parent = parent - @property - def name(self) -> 'str|None': - return self._name - - @name.setter - def name(self, name: 'str|None'): - self._name = name - @property def id(self) -> 'str|None': if not self: diff --git a/src/robot/output/debugfile.py b/src/robot/output/debugfile.py index 5b81f4026a9..40597b4a262 100644 --- a/src/robot/output/debugfile.py +++ b/src/robot/output/debugfile.py @@ -70,11 +70,13 @@ def end_test(self, test): def start_keyword(self, kw): if self._kw_level == 0: self._separator('KEYWORD') - self._start(kw.type, kw.name, kw.start_time, seq2str2(kw.args)) + name = kw.full_name if kw.type in kw.KEYWORD_TYPES else kw._name + self._start(kw.type, name, kw.start_time, seq2str2(kw.args)) self._kw_level += 1 def end_keyword(self, kw): - self._end(kw.type, kw.name, kw.end_time, kw.elapsed_time) + name = kw.full_name if kw.type in kw.KEYWORD_TYPES else kw._name + self._end(kw.type, name, kw.end_time, kw.elapsed_time) self._kw_level -= 1 def log_message(self, msg): diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 84f0babd186..0ee68a8b2e6 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -75,7 +75,10 @@ def _get_version2_arguments(self, item): attributes = dict((name, self._get_attribute_value(item, name)) for name in self._attribute_names) attributes.update(self._get_extra_attributes(item)) - return item.name or '', attributes + return self._get_name(item) or '', attributes + + def _get_name(self, item): + return item.name def _get_attribute_value(self, item, name): value = getattr(item, name) @@ -146,21 +149,24 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): 'IN ZIP': ('mode', 'fill') } + def _get_name(self, kw): + return kw.full_name if kw.type in kw.KEYWORD_TYPES else kw._name + def _get_extra_attributes(self, kw): # FOR and TRY model objects use `assign` starting from RF 7.0, but for # backwards compatibility reasons we pass them as `variable(s)`. if kw.type in kw.KEYWORD_TYPES: assign = list(kw.assign) - kwname = kw.kwname or '' - libname = kw.libname or '' + name = kw.name or '' + owner = kw.owner or '' args = [a if is_string(a) else safe_str(a) for a in kw.args] else: assign = [] - kwname = kw._name - libname = '' + name = kw._name + owner = '' args = [] - attrs = {'kwname': kwname, - 'libname': libname, + attrs = {'kwname': name, + 'libname': owner, 'args': args, 'assign': assign, 'source': str(kw.source or '')} diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 843c0239c15..1e9e4c57251 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -67,11 +67,11 @@ def _write_message(self, msg): self._writer.element('msg', msg.message, attrs) def start_keyword(self, kw): - attrs = {'name': kw.kwname, 'library': kw.libname} + attrs = {'name': kw.name, 'owner': kw.owner} if kw.type != 'KEYWORD': attrs['type'] = kw.type - if kw.sourcename: - attrs['sourcename'] = kw.sourcename + if kw.source_name: + attrs['source_name'] = kw.source_name self._writer.start('kw', attrs) self._write_list('var', kw.assign) self._write_list('arg', [safe_str(a) for a in kw.args]) diff --git a/src/robot/reporting/expandkeywordmatcher.py b/src/robot/reporting/expandkeywordmatcher.py index 0b9731955b2..921180b0a4e 100644 --- a/src/robot/reporting/expandkeywordmatcher.py +++ b/src/robot/reporting/expandkeywordmatcher.py @@ -33,6 +33,6 @@ def __init__(self, expand_keywords: 'str|Sequence[str]'): self._match_tags = MultiMatcher(tags).match_any def match(self, kw: Keyword): - if (self._match_name(kw.name or '') + if (self._match_name(kw.full_name or '') or self._match_tags(kw.tags)) and not kw.not_run: self.matched_ids.append(kw.id) diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index c329f428db0..f0152974dd0 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -150,13 +150,13 @@ def build(self, item, split=False): return self.build_body_item(item, split) def build_body_item(self, item, split=False): - self._context.check_expansion(item) with self._context.prune_input(item.body): if isinstance (item, Keyword): + self._context.check_expansion(item) items = item.body.flatten() if item.has_teardown: items.append(item.teardown) - return self._build(item, item.kwname, item.libname, item.timeout, item.doc, item.args, + return self._build(item, item.name, item.owner, item.timeout, item.doc, item.args, item.assign, item.tags, split=split) if isinstance(item, Return): return self._build(item, args=item.values, split=split) @@ -164,11 +164,11 @@ def build_body_item(self, item, split=False): return self._build(item, item._name, args=item.values[1:], split=split) return self._build(item, item._name, split=split) - def _build(self, item, kwname='', libname='', timeout='', doc='', args=(), assign=(), + def _build(self, item, name='', owner='', timeout='', doc='', args=(), assign=(), tags=(), items=None, split =False): return (KEYWORD_TYPES[item.type], - self._string(kwname, attr=True), - self._string(libname, attr=True), + self._string(name, attr=True), + self._string(owner, attr=True), self._string(timeout), self._html(item.doc), self._string(', '.join(args)), diff --git a/src/robot/result/flattenkeywordmatcher.py b/src/robot/result/flattenkeywordmatcher.py index a5a7678fec7..a1fe5b77173 100644 --- a/src/robot/result/flattenkeywordmatcher.py +++ b/src/robot/result/flattenkeywordmatcher.py @@ -15,7 +15,7 @@ from robot.errors import DataError from robot.model import TagPatterns -from robot.utils import MultiMatcher, is_list_like +from robot.utils import MultiMatcher def validate_flatten_keyword(options): @@ -34,7 +34,7 @@ def validate_flatten_keyword(options): class FlattenByTypeMatcher: def __init__(self, flatten): - if not is_list_like(flatten): + if isinstance(flatten, str): flatten = [flatten] flatten = [f.lower() for f in flatten] self.types = set() @@ -55,13 +55,13 @@ def __bool__(self): class FlattenByNameMatcher: def __init__(self, flatten): - if not is_list_like(flatten): + if isinstance(flatten, str): flatten = [flatten] names = [n[5:] for n in flatten if n[:5].lower() == 'name:'] self._matcher = MultiMatcher(names) - def match(self, kwname, libname=None): - name = '%s.%s' % (libname, kwname) if libname else kwname + def match(self, name, owner=None): + name = f'{owner}.{name}' if owner else name return self._matcher.match(name) def __bool__(self): @@ -71,13 +71,13 @@ def __bool__(self): class FlattenByTagMatcher: def __init__(self, flatten): - if not is_list_like(flatten): + if isinstance(flatten, str): flatten = [flatten] patterns = [p[4:] for p in flatten if p[:4].lower() == 'tag:'] self._matcher = TagPatterns(patterns) - def match(self, kwtags): - return self._matcher.match(kwtags) + def match(self, tags): + return self._matcher.match(tags) def __bool__(self): return bool(self._matcher) diff --git a/src/robot/result/keywordremover.py b/src/robot/result/keywordremover.py index b1daa880e7e..f174baac334 100644 --- a/src/robot/result/keywordremover.py +++ b/src/robot/result/keywordremover.py @@ -86,18 +86,18 @@ def visit_keyword(self, keyword): class ByNameKeywordRemover(_KeywordRemover): def __init__(self, pattern): - _KeywordRemover.__init__(self) + super().__init__() self._matcher = Matcher(pattern, ignore='_') def start_keyword(self, kw): - if self._matcher.match(kw.name) and not self._warning_or_error(kw): + if self._matcher.match(kw.full_name) and not self._warning_or_error(kw): self._clear_content(kw) class ByTagKeywordRemover(_KeywordRemover): def __init__(self, pattern): - _KeywordRemover.__init__(self) + super().__init__() self._pattern = TagPattern.from_string(pattern) def start_keyword(self, kw): @@ -136,7 +136,7 @@ class WaitUntilKeywordSucceedsRemover(_KeywordRemover): _message = '%d failing step%s removed using --RemoveKeywords option.' def start_keyword(self, kw): - if kw.libname == 'BuiltIn' and kw.kwname == 'Wait Until Keyword Succeeds': + if kw.owner == 'BuiltIn' and kw.name == 'Wait Until Keyword Succeeds': before = len(kw.body) self._remove_keywords(kw.body) self._removal_message.set_if_removed(kw, before) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 15a69db8892..8c3b1f0b293 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -686,11 +686,12 @@ def _name(self): class Keyword(model.Keyword, StatusMixin): """Represents an executed library or user keyword.""" body_class = Body - __slots__ = ['kwname', 'libname', 'sourcename', 'doc', 'timeout', 'status', - 'message', '_start_time', '_end_time', '_elapsed_time', '_teardown'] + __slots__ = ['owner', 'source_name', 'doc', 'timeout', 'status', 'message', + '_start_time', '_end_time', '_elapsed_time', '_teardown'] - def __init__(self, kwname: str = '', - libname: str = '', + def __init__(self, name: 'str|None' = '', + owner: 'str|None' = None, + source_name: 'str|None' = None, doc: str = '', args: Sequence[str] = (), assign: Sequence[str] = (), @@ -702,15 +703,12 @@ def __init__(self, kwname: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - sourcename: 'str|None' = None, parent: BodyItemParent = None): - super().__init__(None, args, assign, type, parent) - #: Name of the keyword without library or resource name. - self.kwname = kwname + super().__init__(name, args, assign, type, parent) #: Name of the library or resource containing this keyword. - self.libname = libname + self.owner = owner #: Original name of keyword with embedded arguments. - self.sourcename = sourcename + self.source_name = source_name self.doc = doc self.tags = tags self.timeout = timeout @@ -749,27 +747,48 @@ def messages(self) -> 'list[Message]': return self.body.filter(messages=True) # type: ignore @property - def name(self) -> 'str|None': - """Keyword name in format ``libname.kwname``. + def full_name(self) -> 'str|None': + """Keyword name in format ``owner.name``. - Just ``kwname`` if :attr:`libname` is empty. In practice that is the - case only with user keywords in the same file as the executed test case - or test suite. + Just ``name`` if :attr:`owner` is not set. In practice this is the + case only with user keywords in the suite file. - Cannot be set directly. Set :attr:`libname` and :attr:`kwname` - separately instead. + Cannot be set directly. Set :attr:`name` and :attr:`owner` separately + instead. + + Notice that prior to Robot Framework 7.0, the ``name`` attribute contained + the full name and keyword and owner names were in ``kwname`` and ``libname``, + respectively. """ - if not self.libname: - return self.kwname - return f'{self.libname}.{self.kwname}' - - @name.setter - def name(self, name): - if name is not None: - raise AttributeError("Cannot set 'name' attribute directly. " - "Set 'kwname' and 'libname' separately instead.") - self.kwname = None - self.libname = None + return f'{self.owner}.{self.name}' if self.owner else self.name + + # TODO: Deprecate 'kwname', 'libname' and 'sourcename' loudly in RF 8. + @property + def kwname(self) -> 'str|None': + """Deprecated since Robot Framework 7.0. Use :attr:``name` instead.""" + return self.name + + @kwname.setter + def kwname(self, name: 'str|None'): + self.name = name + + @property + def libname(self) -> 'str|None': + """Deprecated since Robot Framework 7.0. Use :attr:``owner` instead.""" + return self.owner + + @libname.setter + def libname(self, name: 'str|None'): + self.owner = name + + @property + def sourcename(self) -> str: + """Deprecated since Robot Framework 7.0. Use :attr:``source_name` instead.""" + return self.source_name + + @sourcename.setter + def sourcename(self, name: str): + self.source_name = name @property # Cannot use @setter because it would create teardowns recursively. def teardown(self) -> 'Keyword': diff --git a/src/robot/result/resultbuilder.py b/src/robot/result/resultbuilder.py index 9f6a6abae4f..480a3b3806b 100644 --- a/src/robot/result/resultbuilder.py +++ b/src/robot/result/resultbuilder.py @@ -155,7 +155,8 @@ def _flatten_keywords(self, context, flattened): inside += 1 if started >= 0: started += 1 - elif by_name and name_match(elem.get('name', ''), elem.get('library')): + elif by_name and name_match(elem.get('name', ''), elem.get('owner') + or elem.get('library')): started = 0 elif by_type and type_match(tag): started = 0 diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 5f7af2a9522..31642636c35 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -142,9 +142,15 @@ def _create_keyword(self, elem, result): body = result.body except AttributeError: body = self._get_body_for_suite_level_keyword(result) - return body.create_keyword(kwname=elem.get('name', ''), - libname=elem.get('library'), - sourcename=elem.get('sourcename')) + return body.create_keyword(**self._get_keyword_attrs(elem)) + + def _get_keyword_attrs(self, elem): + # 'library' and 'sourcename' are RF < 7 compatibility. + return { + 'name': elem.get('name', ''), + 'owner': elem.get('owner') or elem.get('library'), + 'source_name': elem.get('source_name') or elem.get('sourcename') + } def _get_body_for_suite_level_keyword(self, result): # Someone, most likely a listener, has created a `` element on suite level. @@ -155,24 +161,22 @@ def _get_body_for_suite_level_keyword(self, result): kw_type = 'teardown' if result.tests or result.suites else 'setup' keyword = getattr(result, kw_type) if not keyword: - keyword.config(kwname=f'Implicit {kw_type}', status=keyword.PASS) + keyword.config(name=f'Implicit {kw_type}', status=keyword.PASS) return keyword.body def _create_setup(self, elem, result): - return result.setup.config(kwname=elem.get('name', ''), - libname=elem.get('library')) + return result.setup.config(**self._get_keyword_attrs(elem)) def _create_teardown(self, elem, result): - return result.teardown.config(kwname=elem.get('name', ''), - libname=elem.get('library')) + return result.teardown.config(**self._get_keyword_attrs(elem)) # RF < 4 compatibility. def _create_for(self, elem, result): - return result.body.create_keyword(kwname=elem.get('name'), type='FOR') + return result.body.create_keyword(name=elem.get('name'), type='FOR') def _create_foritem(self, elem, result): - return result.body.create_keyword(kwname=elem.get('name'), type='ITERATION') + return result.body.create_keyword(name=elem.get('name'), type='ITERATION') _create_iteration = _create_foritem diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index 44004141445..11187ba3830 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -58,8 +58,8 @@ def run(self, kw, context, run=True): def _get_result(self, kw, assignment): handler = self._handler - return KeywordResult(kwname=self.name, - libname=handler.libname, + return KeywordResult(name=self.name, + owner=handler.libname, doc=handler.shortdoc, args=kw.args, assign=tuple(assignment), @@ -149,7 +149,7 @@ def _dry_run(self, context, args): def _get_result(self, kw, assignment): result = super()._get_result(kw, assignment) - result.sourcename = self._handler.name + result.source_name = self._handler.name return result diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index c55b4ab15e9..dbfc73e6060 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -44,13 +44,13 @@ def __enter__(self): result.start_time = datetime.now() context.start_keyword(ModelCombiner(self.data, result)) if result.type in result.KEYWORD_TYPES: - self._warn_if_deprecated(result.doc, result.name) + self._warn_if_deprecated(result.doc, result.full_name) return self def _warn_if_deprecated(self, doc, name): if doc.startswith('*DEPRECATED') and '*' in doc[1:]: message = ' ' + doc.split('*', 2)[-1].strip() - self.context.warn("Keyword '%s' is deprecated.%s" % (name, message)) + self.context.warn(f"Keyword '{name}' is deprecated.{message}") def __exit__(self, exc_type, exc_val, exc_tb): context = self.context diff --git a/src/robot/running/usererrorhandler.py b/src/robot/running/usererrorhandler.py index 6f6be062cf9..babb963c779 100644 --- a/src/robot/running/usererrorhandler.py +++ b/src/robot/running/usererrorhandler.py @@ -63,8 +63,8 @@ def create_runner(self, name, languages=None): return self def run(self, kw, context, run=True): - result = KeywordResult(kwname=self.name, - libname=self.libname, + result = KeywordResult(name=self.name, + owner=self.libname, args=kw.args, assign=tuple(VariableAssignment(kw.assign)), type=kw.type) diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 911b60f5340..775e1624483 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -74,8 +74,8 @@ def _get_result(self, kw, assignment, variables): doc = variables.replace_string(handler.doc, ignore_errors=True) doc, tags = split_tags_from_doc(doc) tags = variables.replace_list(handler.tags, ignore_errors=True) + tags - return KeywordResult(kwname=self.name, - libname=handler.libname, + return KeywordResult(name=self.name, + owner=handler.libname, doc=getshortdoc(doc), args=kw.args, assign=tuple(assignment), @@ -270,5 +270,5 @@ def _trace_log_args_message(self, variables): def _get_result(self, kw, assignment, variables): result = UserKeywordRunner._get_result(self, kw, assignment, variables) - result.sourcename = self._handler.name + result.source_name = self._handler.name return result diff --git a/utest/output/test_listeners.py b/utest/output/test_listeners.py index a580294a3da..7af5cb0c263 100644 --- a/utest/output/test_listeners.py +++ b/utest/output/test_listeners.py @@ -47,7 +47,7 @@ class KwMock(Mock, BodyItem): non_existing = ('branch_status',) def __init__(self): - self.name = 'kwmock' + self.full_name = self.name = 'kwmock' self.args = ['a1', 'a2'] self.status = 'PASS' self.type = BodyItem.KEYWORD diff --git a/utest/reporting/test_jsexecutionresult.py b/utest/reporting/test_jsexecutionresult.py index efb9806ed3e..f66abe2213d 100644 --- a/utest/reporting/test_jsexecutionresult.py +++ b/utest/reporting/test_jsexecutionresult.py @@ -23,14 +23,14 @@ def _create_suite_model(self): def _get_suite(self): suite = TestSuite(name='root', doc='sdoc', metadata={'m': 'v'}) - suite.setup.config(kwname='keyword') + suite.setup.config(name='keyword') sub = suite.suites.create(name='suite', metadata={'a': '1', 'b': '2'}) - sub.setup.config(kwname='keyword') + sub.setup.config(name='keyword') t1 = sub.tests.create(name='test', tags=['t1']) - t1.body.create_keyword(kwname='keyword') - t1.body.create_keyword(kwname='keyword') + t1.body.create_keyword(name='keyword') + t1.body.create_keyword(name='keyword') t2 = sub.tests.create(name='test', tags=['t1', 't2']) - t2.body.create_keyword(kwname='keyword') + t2.body.create_keyword(name='keyword') return suite def _get_expected_suite_model(self, suite): diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index 45a67ced72e..d3d13453e9d 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -65,15 +65,15 @@ def test_default_test(self): def test_test_with_values(self): test = TestCase('Name', '*Doc*', ['t1', 't2'], '1 minute', 42, 'PASS', 'Msg', '2011-12-04 19:22:22.222', '2011-12-04 19:22:22.333') - test.setup.config(kwname='setup') - test.teardown.config(kwname='td') - k1 = self._verify_keyword(test.setup, type=1, kwname='setup') - k2 = self._verify_keyword(test.teardown, type=2, kwname='td') + test.setup.config(name='setup') + test.teardown.config(name='td') + k1 = self._verify_keyword(test.setup, type=1, name='setup') + k2 = self._verify_keyword(test.teardown, type=2, name='td') self._verify_test(test, 'Name', 'Doc', ('t1', 't2'), '1 minute', 1, 'Msg', 0, 111, (k1, k2)) def test_name_escaping(self): - kw = Keyword('quote:"', 'and *url* https://url.com', '*"Doc"*',) + kw = Keyword('quote:"', 'and *url* https://url.com', doc='*"Doc"*',) self._verify_keyword(kw, 0, 'quote:"', 'and *url* https://url.com', '"Doc"') test = TestCase('quote:" and *url* https://url.com', '*"Doc"*',) self._verify_test(test, 'quote:" and *url* https://url.com', '"Doc"') @@ -84,7 +84,7 @@ def test_default_keyword(self): self._verify_keyword(Keyword()) def test_keyword_with_values(self): - kw = Keyword('KW Name', 'libname', 'http://doc', ('arg1', 'arg2'), + kw = Keyword('KW Name', 'libname', '', 'http://doc', ('arg1', 'arg2'), ('${v1}', '${v2}'), ('tag1', 'tag2'), '1 second', 'SETUP', 'FAIL', 'message', '2011-12-04 19:42:42.000', '2011-12-04 19:42:42.042') self._verify_keyword(kw, 1, 'KW Name', 'libname', @@ -125,10 +125,10 @@ def test_message_with_html(self): def test_nested_structure(self): suite = TestSuite() - suite.setup.config(kwname='setup') - suite.teardown.config(kwname='td') - K1 = self._verify_keyword(suite.setup, type=1, kwname='setup') - K2 = self._verify_keyword(suite.teardown, type=2, kwname='td') + suite.setup.config(name='setup') + suite.teardown.config(name='td') + K1 = self._verify_keyword(suite.setup, type=1, name='setup') + K2 = self._verify_keyword(suite.teardown, type=2, name='td') suite.suites = [TestSuite()] suite.suites[0].tests = [TestCase(tags=['crit', 'xxx'])] t = self._verify_test(suite.suites[0].tests[0], tags=('crit', 'xxx')) @@ -139,7 +139,7 @@ def test_nested_structure(self): suite.tests[0].body[0].body = [ForIteration(), Message()] k = self._verify_keyword(suite.tests[0].body[0].body[0], type=4) m = self._verify_message(suite.tests[0].body[0].body[1]) - k1 = self._verify_keyword(suite.tests[0].body[0], type=3, body=(k, m), kwname='${x} IN [ 1 | 2 ]', message='x') + k1 = self._verify_keyword(suite.tests[0].body[0], type=3, body=(k, m), name='${x} IN [ 1 | 2 ]', message='x') suite.tests[0].body[1].body = [Message(), Message('msg', level='TRACE')] m1 = self._verify_message(suite.tests[0].body[1].messages[0]) m2 = self._verify_message(suite.tests[0].body[1].messages[1], 'msg', level=0) @@ -152,7 +152,7 @@ def test_nested_structure(self): def test_timestamps(self): suite = TestSuite(start_time='2011-12-05 00:33:33.333') - suite.setup.config(kwname='s1', start_time='2011-12-05 00:33:33.334') + suite.setup.config(name='s1', start_time='2011-12-05 00:33:33.334') suite.setup.body.create_message('Message', timestamp='2011-12-05 00:33:33.343') suite.setup.body.create_message(level='DEBUG', timestamp='2011-12-05 00:33:33.344') suite.tests.create(start_time='2011-12-05 00:33:34.333') @@ -227,15 +227,14 @@ def _verify_test(self, test, name='', doc='', tags=(), timeout='', return self._build_and_verify(TestBuilder, test, name, timeout, doc, tags, status, body) - def _verify_keyword(self, keyword, type=0, kwname='', libname='', doc='', + def _verify_keyword(self, keyword, type=0, name='', owner='', doc='', args='', assign='', tags='', timeout='', status=0, start=None, elapsed=0, message='', body=()): status = (status, start, elapsed, message) \ if message else (status, start, elapsed) doc = f'

{doc}

' if doc else '' - return self._build_and_verify(KeywordBuilder, keyword, type, kwname, - libname, timeout, doc, args, assign, tags, - status, body) + return self._build_and_verify(KeywordBuilder, keyword, type, name, owner, + timeout, doc, args, assign, tags, status, body) def _verify_message(self, msg, message='', level=2, timestamp=None): return self._build_and_verify(MessageBuilder, msg, timestamp, level, message) @@ -301,8 +300,8 @@ def test_suite_keywords(self): def _get_suite_with_keywords(self): suite = TestSuite(name='root') - suite.setup.config(kwname='k1') - suite.teardown.config(kwname='k2') + suite.setup.config(name='k1') + suite.teardown.config(name='k2') suite.setup.body.create_keyword('k1-k2') return suite @@ -324,7 +323,7 @@ def _get_nested_suite_with_tests_and_keywords(self): suite = self._get_suite_with_keywords() sub = TestSuite(name='suite2') suite.suites = [self._get_suite_with_tests(), sub] - sub.setup.config(kwname='kw') + sub.setup.config(name='kw') sub.setup.body.create_keyword('skw').body.create_message('Message') sub.tests.create('test', doc='tdoc').body.create_keyword('koowee', doc='kdoc') return suite @@ -356,16 +355,16 @@ class TestPruneInput(unittest.TestCase): def setUp(self): self.suite = TestSuite() - self.suite.setup.config(kwname='s') - self.suite.teardown.config(kwname='t') + self.suite.setup.config(name='s') + self.suite.teardown.config(name='t') s1 = self.suite.suites.create() - s1.setup.config(kwname='s1') + s1.setup.config(name='s1') tc = s1.tests.create() - tc.setup.config(kwname='tcs') - tc.teardown.config(kwname='tct') + tc.setup.config(name='tcs') + tc.teardown.config(name='tct') tc.body = [Keyword(), Keyword(), Keyword()] tc.body[0].body = [Keyword(), Keyword(), Message(), Message(), Message()] - tc.body[0].teardown.config(kwname='kt') + tc.body[0].teardown.config(name='kt') s2 = self.suite.suites.create() t1 = s2.tests.create() t2 = s2.tests.create() @@ -374,16 +373,16 @@ def setUp(self): def test_no_pruning(self): SuiteBuilder(JsBuildingContext(prune_input=False)).build(self.suite) - assert_equal(self.suite.setup.kwname, 's') - assert_equal(self.suite.teardown.kwname, 't') - assert_equal(self.suite.suites[0].setup.kwname, 's1') - assert_equal(self.suite.suites[0].teardown.kwname, None) - assert_equal(self.suite.suites[0].tests[0].setup.kwname, 'tcs') - assert_equal(self.suite.suites[0].tests[0].teardown.kwname, 'tct') + assert_equal(self.suite.setup.name, 's') + assert_equal(self.suite.teardown.name, 't') + assert_equal(self.suite.suites[0].setup.name, 's1') + assert_equal(self.suite.suites[0].teardown.name, None) + assert_equal(self.suite.suites[0].tests[0].setup.name, 'tcs') + assert_equal(self.suite.suites[0].tests[0].teardown.name, 'tct') assert_equal(len(self.suite.suites[0].tests[0].body), 3) assert_equal(len(self.suite.suites[0].tests[0].body[0].body), 5) assert_equal(len(self.suite.suites[0].tests[0].body[0].messages), 3) - assert_equal(self.suite.suites[0].tests[0].body[0].teardown.kwname, 'kt') + assert_equal(self.suite.suites[0].tests[0].body[0].teardown.name, 'kt') assert_equal(len(self.suite.suites[1].tests[0].body), 1) assert_equal(len(self.suite.suites[1].tests[1].body), 2) diff --git a/utest/reporting/test_reporting.py b/utest/reporting/test_reporting.py index 376cab8e54e..6cb130cd14c 100644 --- a/utest/reporting/test_reporting.py +++ b/utest/reporting/test_reporting.py @@ -82,9 +82,9 @@ def _write_results(self, **settings): def _get_execution_result(self): suite = TestSuite(name=self.EXPECTED_SUITE_NAME) tc = suite.tests.create(name=self.EXPECTED_TEST_NAME, status='PASS') - tc.body.create_keyword(kwname=self.EXPECTED_KEYWORD_NAME, status='PASS') + tc.body.create_keyword(name=self.EXPECTED_KEYWORD_NAME, status='PASS') tc = suite.tests.create(name=self.EXPECTED_FAILING_TEST) - kw = tc.body.create_keyword(kwname=self.EXPECTED_KEYWORD_NAME) + kw = tc.body.create_keyword(name=self.EXPECTED_KEYWORD_NAME) kw.body.create_message(message=self.EXPECTED_DEBUG_MESSAGE, level='DEBUG', timestamp='2020-12-12 12:12:12.000') errors = ExecutionErrors() diff --git a/utest/result/golden.xml b/utest/result/golden.xml index 4fb366003e3..24456e901a2 100644 --- a/utest/result/golden.xml +++ b/utest/result/golden.xml @@ -6,7 +6,7 @@
- + Test 1 Logs the given message with the given level. Test 1 @@ -15,7 +15,7 @@ ${not really in source} tag not in source - + Log on ${TEST NAME} TRACE Logs the given message with the given level. @@ -28,7 +28,7 @@ not in source not in source - + ${x} Logs the given message with the given level. not in source @@ -40,7 +40,7 @@
- + not going here Fails the test with the given message and optionally alters its tags. @@ -48,7 +48,7 @@ - + Not in source. diff --git a/utest/result/goldenTwice.xml b/utest/result/goldenTwice.xml index a0c15eb2705..7b76ca136d7 100644 --- a/utest/result/goldenTwice.xml +++ b/utest/result/goldenTwice.xml @@ -7,7 +7,7 @@ - + Test 1 Logs the given message with the given level. Test 1 @@ -16,7 +16,7 @@ ${not really in source} tag not in source - + Log on ${TEST NAME} TRACE Logs the given message with the given level. @@ -29,7 +29,7 @@ not in source not in source - + ${x} Logs the given message with the given level. not in source @@ -41,7 +41,7 @@ - + not going here Fails the test with the given message and optionally alters its tags. @@ -49,7 +49,7 @@ - + Not in source. @@ -71,7 +71,7 @@ - + Test 1 Logs the given message with the given level. Test 1 @@ -80,7 +80,7 @@ ${not really in source} tag not in source - + Log on ${TEST NAME} TRACE Logs the given message with the given level. @@ -93,7 +93,7 @@ not in source not in source - + ${x} Logs the given message with the given level. not in source @@ -105,7 +105,7 @@ - + not going here Fails the test with the given message and optionally alters its tags. @@ -113,7 +113,7 @@ - + Not in source. diff --git a/utest/result/test_configurer.py b/utest/result/test_configurer.py index d69647e26d6..9f1748cd68e 100644 --- a/utest/result/test_configurer.py +++ b/utest/result/test_configurer.py @@ -138,8 +138,8 @@ def test_remove_passed_removes_from_passed_test(self): def test_remove_passed_removes_setup_and_teardown_from_passed_suite(self): suite = TestSuite() suite.tests.create(status='PASS') - suite.setup.config(kwname='S', status='PASS').body.create_keyword() - suite.teardown.config(kwname='T', status='PASS').body.create_message(message='message') + suite.setup.config(name='S', status='PASS').body.create_keyword() + suite.teardown.config(name='T', status='PASS').body.create_message(message='message') self._remove_passed(suite) for keyword in suite.setup, suite.teardown: self._should_contain_no_messages_or_keywords(keyword) @@ -176,7 +176,7 @@ def _test_with_warning(self, suite): def test_remove_passed_does_not_remove_setup_and_teardown_from_failed_suite(self): suite = TestSuite() - suite.setup.config(kwname='SETUP').body.create_message(message='some') + suite.setup.config(name='SETUP').body.create_message(message='some') suite.teardown.config(type='TEARDOWN').body.create_keyword() suite.tests.create(status='FAIL') self._remove_passed(suite) @@ -196,7 +196,7 @@ def suite_with_for_loop(self): loop = test.body.create_for(status='PASS') for i in range(100): loop.body.create_iteration({'${i}': i}, status='PASS')\ - .body.create_keyword(kwname='k%d' % i, status='PASS')\ + .body.create_keyword(name='k%d' % i, status='PASS')\ .body.create_message(message='something') return suite, loop @@ -235,8 +235,8 @@ def test_remove_based_on_multiple_condition(self): def _suite_with_setup_and_teardown_and_test_with_keywords(self): suite = TestSuite() - suite.setup.config(kwname='S', status='PASS').body.create_message('setup message') - suite.teardown.config(kwname='T', status='PASS').body.create_message(message='message') + suite.setup.config(name='S', status='PASS').body.create_message('setup message') + suite.teardown.config(name='T', status='PASS').body.create_message(message='message') test = suite.tests.create() test.body.create_keyword().body.create_keyword() test.body.create_keyword().body.create_message('kw with message') diff --git a/utest/result/test_keywordremover.py b/utest/result/test_keywordremover.py index 021e9300536..32392be9766 100644 --- a/utest/result/test_keywordremover.py +++ b/utest/result/test_keywordremover.py @@ -33,7 +33,7 @@ def test_keywords_and_messages(self): def _assert_removed(self, failing=0, passing=0, messages=0, expected=0): suite = TestSuite() kw = suite.tests.create().body.create_keyword( - libname='BuiltIn', kwname='Wait Until Keyword Succeeds' + owner='BuiltIn', name='Wait Until Keyword Succeeds' ) for i in range(failing): kw.body.create_keyword(status='FAIL') diff --git a/utest/result/test_resultbuilder.py b/utest/result/test_resultbuilder.py index 17e9c31c0b8..791ea4a0265 100644 --- a/utest/result/test_resultbuilder.py +++ b/utest/result/test_resultbuilder.py @@ -46,7 +46,7 @@ def test_testcase_is_built(self): def test_keyword_is_built(self): keyword = self.test.body[0] - assert_equal(keyword.name, 'BuiltIn.Log') + assert_equal(keyword.full_name, 'BuiltIn.Log') assert_equal(keyword.doc, 'Logs the given message with the given level.') assert_equal(keyword.args, ('Test 1',)) assert_equal(keyword.assign, ()) @@ -85,7 +85,7 @@ def test_for_is_built(self): assert_equal(for_.body[0].assign, {'${x}': 'not in source'}) assert_equal(len(for_.body[0].body), 1) kw = for_.body[0].body[0] - assert_equal(kw.name, 'BuiltIn.Log') + assert_equal(kw.full_name, 'BuiltIn.Log') assert_equal(kw.args, ('${x}',)) assert_equal(len(kw.body), 1) assert_equal(kw.body[0].message, 'not in source') @@ -97,13 +97,13 @@ def test_if_is_built(self): assert_equal(if_.status, if_.NOT_RUN) assert_equal(len(if_.body), 1) kw = if_.body[0] - assert_equal(kw.name, 'BuiltIn.Fail') + assert_equal(kw.full_name, 'BuiltIn.Fail') assert_equal(kw.status, kw.NOT_RUN) assert_equal(else_.condition, None) assert_equal(else_.status, else_.PASS) assert_equal(len(else_.body), 1) kw = else_.body[0] - assert_equal(kw.name, 'BuiltIn.No Operation') + assert_equal(kw.full_name, 'BuiltIn.No Operation') assert_equal(kw.status, kw.PASS) def test_suite_setup_is_built(self): diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 5ca865d714d..7274524ce6d 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -213,8 +213,8 @@ def test_suite_elapsed_time(self): suite.tests.create(elapsed_time=1) suite.suites.create(elapsed_time=2) assert_equal(suite.elapsed_time, timedelta(seconds=3)) - suite.setup.config(kwname='S', elapsed_time=0.1) - suite.teardown.config(kwname='T', elapsed_time=0.2) + suite.setup.config(name='S', elapsed_time=0.1) + suite.teardown.config(name='T', elapsed_time=0.2) assert_equal(suite.elapsed_time, timedelta(seconds=3.3)) suite.config(start_time=datetime(2023, 9, 7, 20, 33, 44), end_time=datetime(2023, 9, 7, 20, 33, 45),) @@ -227,8 +227,8 @@ def test_test_elapsed_time(self): test.body.create_keyword(elapsed_time=1) test.body.create_if(elapsed_time=2) assert_equal(test.elapsed_time, timedelta(seconds=3)) - test.setup.config(kwname='S', elapsed_time=0.1) - test.teardown.config(kwname='T', elapsed_time=0.2) + test.setup.config(name='S', elapsed_time=0.1) + test.teardown.config(name='T', elapsed_time=0.2) assert_equal(test.elapsed_time, timedelta(seconds=3.3)) test.config(start_time=datetime(2023, 9, 7, 20, 33, 44), end_time=datetime(2023, 9, 7, 20, 33, 45),) @@ -241,7 +241,7 @@ def test_keyword_elapsed_time(self): kw.body.create_keyword(elapsed_time=1) kw.body.create_if(elapsed_time=2) assert_equal(kw.elapsed_time, timedelta(seconds=3)) - kw.teardown.config(kwname='T', elapsed_time=0.2) + kw.teardown.config(name='T', elapsed_time=0.2) assert_equal(kw.elapsed_time, timedelta(seconds=3.2)) kw.config(start_time=datetime(2023, 9, 7, 20, 33, 44), end_time=datetime(2023, 9, 7, 20, 33, 45),) @@ -309,14 +309,32 @@ class TestModel(unittest.TestCase): def test_keyword_name(self): kw = Keyword('keyword') assert_equal(kw.name, 'keyword') - kw = Keyword('keyword', 'lib') - assert_equal(kw.name, 'lib.keyword') - kw.kwname = 'Kekkonen' - kw.libname = 'Urho' - assert_equal(kw.name, 'Urho.Kekkonen') - - def test_keyword_name_cannot_be_set_directly(self): - assert_raises(AttributeError, setattr, Keyword(), 'name', 'value') + assert_equal(kw.owner, None) + assert_equal(kw.full_name, 'keyword') + assert_equal(kw.source_name, None) + kw = Keyword('keyword', 'library', 'key${x}') + assert_equal(kw.name, 'keyword') + assert_equal(kw.owner, 'library') + assert_equal(kw.full_name, 'library.keyword') + assert_equal(kw.source_name, 'key${x}') + + def test_full_name_cannot_be_set_directly(self): + assert_raises(AttributeError, setattr, Keyword(), 'full_name', 'value') + + def test_deprecated_names(self): + # These aren't loudly deprecated yet. + kw = Keyword('k', 'l', 's') + assert_equal(kw.kwname, 'k') + assert_equal(kw.libname, 'l') + assert_equal(kw.sourcename, 's') + kw.kwname, kw.libname, kw.sourcename = 'K', 'L', 'S' + assert_equal(kw.kwname, 'K') + assert_equal(kw.libname, 'L') + assert_equal(kw.sourcename, 'S') + assert_equal(kw.name, 'K') + assert_equal(kw.owner, 'L') + assert_equal(kw.source_name, 'S') + assert_equal(kw.full_name, 'L.K') def test_status_propertys_with_test(self): self._verify_status_propertys(TestCase()) diff --git a/utest/result/test_visitor.py b/utest/result/test_visitor.py index e94162fe79a..c0104e09d31 100644 --- a/utest/result/test_visitor.py +++ b/utest/result/test_visitor.py @@ -43,12 +43,12 @@ def test_visit_setups_and_teardowns(self): def test_visit_keyword_teardown(self): suite = ResultSuite() - suite.setup.config(kwname='SS') - suite.teardown.config(kwname='ST') + suite.setup.config(name='SS') + suite.teardown.config(name='ST') test = suite.tests.create() - test.setup.config(kwname='TS') - test.teardown.config(kwname='TT') - test.body.create_keyword().teardown.config(kwname='KT') + test.setup.config(name='TS') + test.teardown.config(name='TT') + test.body.create_keyword().teardown.config(name='KT') visitor = VisitSetupsAndTeardowns() suite.visit(visitor) assert_equal(visitor.visited, ['SS', 'TS', 'KT', 'TT', 'ST']) @@ -203,9 +203,9 @@ def end_body_item(self, item): def test_visit_return_continue_and_break(self): suite = ResultSuite() - suite.tests.create().body.create_return().body.create_keyword(kwname='R') + suite.tests.create().body.create_return().body.create_keyword(name='R') suite.tests.create().body.create_continue().body.create_message(message='C') - suite.tests.create().body.create_break().body.create_keyword(kwname='B') + suite.tests.create().body.create_break().body.create_keyword(name='B') class Visitor(SuiteVisitor): visited_return = visited_continue = visited_break = False @@ -310,12 +310,12 @@ def end_test(self, test): def start_keyword(self, keyword): if self.test_started and not self.kw_added: - keyword.parent.body.create_keyword(kwname='Added by start_keyword') + keyword.parent.body.create_keyword(name='Added by start_keyword') self.kw_added = True def end_keyword(self, keyword): if keyword.name == 'Added by start_keyword': - keyword.parent.body.create_keyword(kwname='Added by end_keyword') + keyword.parent.body.create_keyword(name='Added by end_keyword') if __name__ == '__main__': From 6e450c9f18e707ceaa37a94925e337d83295c921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 17:37:09 +0300 Subject: [PATCH 0740/1592] f-strings. Also some '.' at the end of sentences. --- ...verriding_default_settings_with_none.robot | 4 +- .../standard_libraries/builtin/length.robot | 14 +- .../standard_libraries/builtin/sleep.robot | 12 +- ...verriding_default_settings_with_none.robot | 6 +- src/robot/libraries/BuiltIn.py | 179 +++++++++--------- 5 files changed, 105 insertions(+), 110 deletions(-) diff --git a/atest/robot/core/overriding_default_settings_with_none.robot b/atest/robot/core/overriding_default_settings_with_none.robot index 1c15a93ba03..9e3dae2bb86 100644 --- a/atest/robot/core/overriding_default_settings_with_none.robot +++ b/atest/robot/core/overriding_default_settings_with_none.robot @@ -26,11 +26,11 @@ Overriding Test Template Overriding Test Timeout ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.body[0].msgs[0]} Slept 300 milliseconds + Check Log Message ${tc.body[0].msgs[0]} Slept 123 milliseconds. Overriding Test Timeout from Command Line ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.body[0].msgs[0]} Slept 300 milliseconds + Check Log Message ${tc.body[0].msgs[0]} Slept 123 milliseconds. Overriding Default Tags ${tc}= Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/length.robot b/atest/robot/standard_libraries/builtin/length.robot index ce90b08a30c..4c111a8fd9b 100644 --- a/atest/robot/standard_libraries/builtin/length.robot +++ b/atest/robot/standard_libraries/builtin/length.robot @@ -5,16 +5,16 @@ Resource builtin_resource.robot *** Test Cases *** Get Length ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Length is 0 - Check Log Message ${tc.kws[1].kws[0].msgs[0]} Length is 1 - Check Log Message ${tc.kws[2].kws[0].msgs[0]} Length is 2 - Check Log Message ${tc.kws[3].kws[0].msgs[0]} Length is 3 - Check Log Message ${tc.kws[4].kws[0].msgs[0]} Length is 11 - Check Log Message ${tc.kws[5].kws[0].msgs[0]} Length is 0 + Check Log Message ${tc.kws[0].kws[0].msgs[0]} Length is 0. + Check Log Message ${tc.kws[1].kws[0].msgs[0]} Length is 1. + Check Log Message ${tc.kws[2].kws[0].msgs[0]} Length is 2. + Check Log Message ${tc.kws[3].kws[0].msgs[0]} Length is 3. + Check Log Message ${tc.kws[4].kws[0].msgs[0]} Length is 11. + Check Log Message ${tc.kws[5].kws[0].msgs[0]} Length is 0. Length Should Be ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[-1].msgs[0]} Length is 2 + Check Log Message ${tc.kws[-1].msgs[0]} Length is 2. Check Log Message ${tc.kws[-1].msgs[1]} Length of '*' should be 3 but is 2. FAIL pattern=yep Check Log Message ${tc.kws[-1].msgs[2]} Traceback* DEBUG pattern=yep Length Should Be ${tc.kws[-1].msgs} 3 diff --git a/atest/robot/standard_libraries/builtin/sleep.robot b/atest/robot/standard_libraries/builtin/sleep.robot index 0cffda8b948..9ff1a8fad74 100644 --- a/atest/robot/standard_libraries/builtin/sleep.robot +++ b/atest/robot/standard_libraries/builtin/sleep.robot @@ -5,18 +5,18 @@ Resource atest_resource.robot *** Test Cases *** Sleep ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[1].msgs[0]} Slept 1 second 111 milliseconds - Check Log Message ${tc.kws[3].msgs[0]} Slept 1 second 234 milliseconds - Check Log Message ${tc.kws[5].msgs[0]} Slept 1 second 112 milliseconds + Check Log Message ${tc.kws[1].msgs[0]} Slept 1 second 111 milliseconds. + Check Log Message ${tc.kws[3].msgs[0]} Slept 1 second 234 milliseconds. + Check Log Message ${tc.kws[5].msgs[0]} Slept 1 second 112 milliseconds. Sleep With Negative Time ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[1].msgs[0]} Slept 0 seconds - Check Log Message ${tc.kws[2].msgs[0]} Slept 0 seconds + Check Log Message ${tc.kws[1].msgs[0]} Slept 0 seconds. + Check Log Message ${tc.kws[2].msgs[0]} Slept 0 seconds. Sleep With Reason ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Slept 42 milliseconds + Check Log Message ${tc.kws[0].msgs[0]} Slept 42 milliseconds. Check Log Message ${tc.kws[0].msgs[1]} No good reason Invalid Time Does Not Cause Uncatchable Error diff --git a/atest/testdata/core/overriding_default_settings_with_none.robot b/atest/testdata/core/overriding_default_settings_with_none.robot index 9ae5bd566f6..b0099ef4032 100644 --- a/atest/testdata/core/overriding_default_settings_with_none.robot +++ b/atest/testdata/core/overriding_default_settings_with_none.robot @@ -2,7 +2,7 @@ Test Setup Log Default Setup Test Teardown Log Default Teardown Test Template Log -Test Timeout 200 ms +Test Timeout 100 ms Default Tags d1 d2 *** Test Cases *** @@ -34,12 +34,12 @@ Overriding Test Template Overriding Test Timeout [Timeout] NONE [Template] NONE - Sleep 300ms + Sleep 123ms Overriding Test Timeout from Command Line [Timeout] ${CONFIG} [Template] NONE - Sleep 300ms + Sleep 123ms Overriding Default Tags [Tags] NONE diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 8b7439a219d..de80edf8f7a 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -153,8 +153,8 @@ def _convert_to_integer(self, orig, base=None): return int(item, self._convert_to_integer(base)) return int(item) except: - raise RuntimeError("'%s' cannot be converted to an integer: %s" - % (orig, get_error_message())) + raise RuntimeError(f"'{orig}' cannot be converted to an integer: " + f"{get_error_message()}") def _get_base(self, item, base): if not is_string(item): @@ -300,8 +300,8 @@ def _convert_to_number_without_precision(self, item): try: return float(self._convert_to_integer(item)) except RuntimeError: - raise RuntimeError("'%s' cannot be converted to a floating " - "point number: %s" % (item, error)) + raise RuntimeError(f"'{item}' cannot be converted to a floating " + f"point number: {error}") def convert_to_string(self, item): """Converts the given item to a Unicode string. @@ -380,12 +380,12 @@ def convert_to_bytes(self, input, input_type='text'): """ try: try: - ordinals = getattr(self, '_get_ordinals_from_%s' % input_type) + ordinals = getattr(self, f'_get_ordinals_from_{input_type}') except AttributeError: - raise RuntimeError("Invalid input type '%s'." % input_type) + raise RuntimeError(f"Invalid input type '{input_type}'.") return bytes(bytearray(o for o in ordinals(input))) except: - raise RuntimeError("Creating bytes failed: %s" % get_error_message()) + raise RuntimeError("Creating bytes failed: " + get_error_message()) def _get_ordinals_from_text(self, input): for char in input: @@ -395,8 +395,7 @@ def _get_ordinals_from_text(self, input): def _test_ordinal(self, ordinal, original, type): if 0 <= ordinal <= 255: return ordinal - raise RuntimeError("%s '%s' cannot be represented as a byte." - % (type, original)) + raise RuntimeError(f"{type} '{original}' cannot be represented as a byte.") def _get_ordinals_from_int(self, input): if is_string(input): @@ -422,7 +421,7 @@ def _input_to_tokens(self, input, length): return input input = ''.join(input.split()) if len(input) % length != 0: - raise RuntimeError('Expected input to be multiple of %d.' % length) + raise RuntimeError(f'Expected input to be multiple of {length}.') return (input[i:i+length] for i in range(0, len(input), length)) def create_list(self, *items): @@ -488,8 +487,8 @@ def _split_dict_items(self, items): def _format_separate_dict_items(self, separate): separate = self._variables.replace_list(separate) if len(separate) % 2 != 0: - raise DataError('Expected even number of keys and values, got %d.' - % len(separate)) + raise DataError(f'Expected even number of keys and values, ' + f'got {len(separate)}.') return [separate[i:i+2] for i in range(0, len(separate), 2)] @@ -549,7 +548,7 @@ def should_not_be_true(self, condition, msg=None): and how ``msg`` can be used to override the default error message. """ if self._is_true(condition): - raise AssertionError(msg or "'%s' should not be true." % condition) + raise AssertionError(msg or f"'{condition}' should not be true.") def should_be_true(self, condition, msg=None): """Fails if the given condition is not true. @@ -580,7 +579,7 @@ def should_be_true(self, condition, msg=None): | Should Be True | $status == 'PASS' | # Expected string must be quoted | """ if not self._is_true(condition): - raise AssertionError(msg or "'%s' should be true." % condition) + raise AssertionError(msg or f"'{condition}' should be true.") def should_be_equal(self, first, second, msg=None, values=True, ignore_case=False, formatter='str', strip_spaces=False, @@ -655,14 +654,14 @@ def _raise_multi_diff(self, first, second, msg, formatter): second_lines = second.splitlines(True) if len(first_lines) < 3 or len(second_lines) < 3: return - self.log("%s\n\n!=\n\n%s" % (first.rstrip(), second.rstrip())) + self.log(f"{first.rstrip()}\n\n!=\n\n{second.rstrip()}") diffs = list(difflib.unified_diff(first_lines, second_lines, fromfile='first', tofile='second', lineterm='')) diffs[3:] = [item[0] + formatter(item[1:]).rstrip() for item in diffs[3:]] prefix = 'Multiline strings are different:' if msg: - prefix = '%s: %s' % (msg, prefix) + prefix = f'{msg}: {prefix}' raise AssertionError('\n'.join([prefix] + diffs)) def _include_values(self, values): @@ -1123,8 +1122,8 @@ def should_contain_any(self, container, *items, **configuration): strip_spaces = configuration.pop('strip_spaces', False) collapse_spaces = is_truthy(configuration.pop('collapse_spaces', False)) if configuration: - raise RuntimeError("Unsupported configuration parameter%s: %s." - % (s(configuration), seq2str(sorted(configuration)))) + raise RuntimeError(f"Unsupported configuration parameter{s(configuration)}: " + f"{seq2str(sorted(configuration))}.") if not items: raise RuntimeError('One or more items required.') orig_container = container @@ -1181,8 +1180,8 @@ def should_not_contain_any(self, container, *items, **configuration): strip_spaces = configuration.pop('strip_spaces', False) collapse_spaces = is_truthy(configuration.pop('collapse_spaces', False)) if configuration: - raise RuntimeError("Unsupported configuration parameter%s: %s." - % (s(configuration), seq2str(sorted(configuration)))) + raise RuntimeError(f"Unsupported configuration parameter{s(configuration)}: " + f"{seq2str(sorted(configuration))}.") if not items: raise RuntimeError('One or more items required.') orig_container = container @@ -1266,8 +1265,8 @@ def should_contain_x_times(self, container, item, count, msg=None, container = [self._collapse_spaces(x) for x in container] x = self.get_count(container, item) if not msg: - msg = "%r contains '%s' %d time%s, not %d time%s." \ - % (orig_container, item, x, s(x), count, s(count)) + msg = (f"{orig_container!r} contains '{item}' {x} time{s(x)}, " + f"not {count} time{s(count)}.") self.should_be_equal_as_integers(x, count, msg, values=False) def get_count(self, container, item): @@ -1284,10 +1283,10 @@ def get_count(self, container, item): try: container = list(container) except: - raise RuntimeError("Converting '%s' to list failed: %s" - % (container, get_error_message())) + raise RuntimeError(f"Converting '{container}' to list failed: " + f"{get_error_message()}") count = container.count(item) - self.log('Item found from container %d time%s.' % (count, s(count))) + self.log(f'Item found from container {count} time{s(count)}.') return count def should_not_match(self, string, pattern, msg=None, values=True, @@ -1407,7 +1406,7 @@ def get_length(self, item): Empty`. """ length = self._get_length(item) - self.log('Length is %d' % length) + self.log(f'Length is {length}.') return length def _get_length(self, item): @@ -1431,7 +1430,7 @@ def _get_length(self, item): except RERAISED_EXCEPTIONS: raise except: - raise RuntimeError("Could not get length of '%s'." % item) + raise RuntimeError(f"Could not get length of '{item}'.") def length_should_be(self, item, length, msg=None): """Verifies that the length of the given item is correct. @@ -1442,8 +1441,8 @@ def length_should_be(self, item, length, msg=None): length = self._convert_to_integer(length) actual = self.get_length(item) if actual != length: - raise AssertionError(msg or "Length of '%s' should be %d but is %d." - % (item, length, actual)) + raise AssertionError(msg or f"Length of '{item}' should be {length} " + f"but is {actual}.") def should_be_empty(self, item, msg=None): """Verifies that the given item is empty. @@ -1452,7 +1451,7 @@ def should_be_empty(self, item, msg=None): default error message can be overridden with the ``msg`` argument. """ if self.get_length(item) > 0: - raise AssertionError(msg or "'%s' should be empty." % (item,)) + raise AssertionError(msg or f"'{item}' should be empty.") def should_not_be_empty(self, item, msg=None): """Verifies that the given item is not empty. @@ -1461,18 +1460,18 @@ def should_not_be_empty(self, item, msg=None): default error message can be overridden with the ``msg`` argument. """ if self.get_length(item) == 0: - raise AssertionError(msg or "'%s' should not be empty." % (item,)) + raise AssertionError(msg or f"'{item}' should not be empty.") def _get_string_msg(self, item1, item2, custom_message, include_values, delimiter, quote_item1=True, quote_item2=True): if custom_message and not self._include_values(include_values): return custom_message - item1 = "'%s'" % safe_str(item1) if quote_item1 else safe_str(item1) - item2 = "'%s'" % safe_str(item2) if quote_item2 else safe_str(item2) - default_message = '%s %s %s' % (item1, delimiter, item2) + item1 = f"'{safe_str(item1)}'" if quote_item1 else safe_str(item1) + item2 = f"'{safe_str(item2)}'" if quote_item2 else safe_str(item2) + default_message = f'{item1} {delimiter} {item2}' if not custom_message: return default_message - return '%s: %s' % (custom_message, default_message) + return f'{custom_message}: {default_message}' class _Variables(_BuiltInBase): @@ -1569,7 +1568,7 @@ def variable_should_exist(self, name, msg=None): self._variables.replace_scalar(name) except VariableError: raise AssertionError(self._variables.replace_string(msg) - if msg else "Variable '%s' does not exist." % name) + if msg else f"Variable '{name}' does not exist.") @run_keyword_variant(resolve=0) def variable_should_not_exist(self, name, msg=None): @@ -1591,14 +1590,14 @@ def variable_should_not_exist(self, name, msg=None): pass else: raise AssertionError(self._variables.replace_string(msg) - if msg else "Variable '%s' exists." % name) + if msg else f"Variable '{name}' exists.") def replace_variables(self, text): """Replaces variables in the given text with their current values. 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: @@ -1814,7 +1813,7 @@ def _get_var_name(self, original, require_assign=True): match.resolve_base(self._variables) valid = match.is_assign() if require_assign else match.is_variable() if not valid: - raise DataError("Invalid variable name '%s'." % name) + raise DataError(f"Invalid variable name '{name}'.") return str(match) def _resolve_var_name(self, name): @@ -1823,7 +1822,7 @@ def _resolve_var_name(self, name): if len(name) < 2 or name[0] not in '$@&': raise ValueError if name[1] != '{': - name = '%s{%s}' % (name[0], name[1:]) + name = f'{name[0]}{{{name[1:]}}}' match = search_variable(name, identifiers='$@&', ignore_errors=True) match.resolve_base(self._variables) if not match.is_assign(): @@ -1839,9 +1838,9 @@ def _get_var_value(self, name, values): # handling non-string values somehow. For details see # https://github.com/robotframework/robotframework/issues/1919 if len(values) != 1 or is_list_variable(values[0]): - raise DataError("Setting list value to scalar variable '%s' " - "is not supported anymore. Create list " - "variable '@%s' instead." % (name, name[1:])) + raise DataError(f"Setting list value to scalar variable '{name}' " + f"is not supported anymore. Create list variable " + f"'@{name[1:]}' instead.") return self._variables.replace_scalar(values[0]) return VariableTableValue(values, name).resolve(self._variables) @@ -2048,7 +2047,7 @@ def _split_branch(self, args, control_word, required, required_error): index = list(args).index(control_word) branch = self._variables.replace_list(args[index+1:], required) if len(branch) < required: - raise DataError('%s requires %s.' % (control_word, required_error)) + raise DataError(f'{control_word} requires {required_error}.') return args[:index], branch @run_keyword_variant(resolve=1, dry_run=True) @@ -2105,7 +2104,7 @@ def run_keyword_and_warn_on_failure(self, name, *args): """ status, message = self.run_keyword_and_ignore_error(name, *args) if status == 'FAIL': - logger.warn("Executing keyword '%s' failed:\n%s" % (name, message)) + logger.warn(f"Executing keyword '{name}' failed:\n{message}") return status, message @run_keyword_variant(resolve=0, dry_run=True) @@ -2206,11 +2205,9 @@ def run_keyword_and_expect_error(self, expected_error, name, *args): raise error = err.message else: - raise AssertionError("Expected error '%s' did not occur." - % expected_error) + raise AssertionError(f"Expected error '{expected_error}' did not occur.") if not self._error_is_expected(error, expected_error): - raise AssertionError("Expected error '%s' but got '%s'." - % (expected_error, error)) + raise AssertionError(f"Expected error '{expected_error}' but got '{error}'.") return error def _error_is_expected(self, error, expected_error): @@ -2218,7 +2215,7 @@ def _error_is_expected(self, error, expected_error): matchers = {'GLOB': glob, 'EQUALS': lambda s, p: s == p, 'STARTS': lambda s, p: s.startswith(p), - 'REGEXP': lambda s, p: re.match(p + r'\Z', s) is not None} + 'REGEXP': lambda s, p: re.fullmatch(p, s) is not None} prefixes = tuple(prefix + ':' for prefix in matchers) if not expected_error.startswith(prefixes): return glob(error, expected_error) @@ -2287,21 +2284,20 @@ def _get_repeat_timeout(self, timestr): def _keywords_repeated_by_count(self, count, name, args): if count <= 0: - self.log("Keyword '%s' repeated zero times." % name) + self.log(f"Keyword '{name}' repeated zero times.") for i in range(count): - self.log("Repeating keyword, round %d/%d." % (i + 1, count)) + self.log(f"Repeating keyword, round {i+1}/{count}.") yield name, args def _keywords_repeated_by_timeout(self, timeout, name, args): if timeout <= 0: - self.log("Keyword '%s' repeated zero times." % name) - repeat_round = 0 + self.log(f"Keyword '{name}' repeated zero times.") + round = 0 maxtime = time.time() + timeout while time.time() < maxtime: - repeat_round += 1 - self.log("Repeating keyword, round %d, %s remaining." - % (repeat_round, - secs_to_timestr(maxtime - time.time(), compact=True))) + round += 1 + remaining = secs_to_timestr(maxtime - time.time(), compact=True) + self.log(f"Repeating keyword, round {round}, {remaining} remaining.") yield name, args @run_keyword_variant(resolve=2, dry_run=True) @@ -2354,11 +2350,11 @@ def wait_until_keyword_succeeds(self, retry, retry_interval, name, *args): except ValueError: timeout = timestr_to_secs(retry) maxtime = time.time() + timeout - message = 'for %s' % secs_to_timestr(timeout) + message = f'for {secs_to_timestr(timeout)}' else: if count <= 0: - raise ValueError('Retry count %d is not positive.' % count) - message = '%d time%s' % (count, s(count)) + raise ValueError(f'Retry count {count} is not positive.') + message = f'{count} time{s(count)}' if is_string(retry_interval) and normalize(retry_interval).startswith('strict:'): retry_interval = retry_interval.split(':', 1)[1].strip() strict_interval = True @@ -2374,16 +2370,18 @@ def wait_until_keyword_succeeds(self, retry, retry_interval, name, *args): raise count -= 1 if time.time() > maxtime > 0 or count == 0: - raise AssertionError("Keyword '%s' failed after retrying %s. " - "The last error was: %s" % (name, message, err)) + raise AssertionError(f"Keyword '{name}' failed after retrying " + f"{message}. The last error was: {err}") finally: if strict_interval: - keyword_runtime = time.time() - start_time - sleep_time = retry_interval - keyword_runtime + execution_time = time.time() - start_time + sleep_time = retry_interval - execution_time if sleep_time < 0: - logger.warn("Keyword execution time %s is longer than retry " - "interval %s." % (secs_to_timestr(keyword_runtime), - secs_to_timestr(retry_interval))) + logger.warn( + f"Keyword execution time {secs_to_timestr(execution_time)} " + f"is longer than retry interval " + f"{secs_to_timestr(retry_interval)}." + ) self._sleep_in_parts(sleep_time) @run_keyword_variant(resolve=1) @@ -2520,10 +2518,9 @@ def run_keyword_if_any_tests_failed(self, name, *args): if suite.statistics.failed > 0: return self.run_keyword(name, *args) - def _get_suite_in_teardown(self, kwname): + def _get_suite_in_teardown(self, kw): if not self._context.in_suite_teardown: - raise RuntimeError("Keyword '%s' can only be used in suite teardown." - % kwname) + raise RuntimeError(f"Keyword '{kw}' can only be used in suite teardown.") return self._context.suite @@ -2872,7 +2869,7 @@ def pass_execution(self, message, *tags): Passing execution in the middle of a test, setup or teardown should be used with care. In the worst case it leads to tests that skip all the parts that could actually uncover problems in the tested application. - In cases where execution cannot continue do to external factors, + In cases where execution cannot continue due to external factors, it is often safer to fail the test case and make it non-critical. """ message = message.strip() @@ -2880,7 +2877,7 @@ def pass_execution(self, message, *tags): raise RuntimeError('Message cannot be empty.') self._set_and_remove_tags(tags) log_message, level = self._get_logged_test_message_and_level(message) - self.log('Execution passed with message:\n%s' % log_message, level) + self.log(f'Execution passed with message:\n{log_message}', level) raise PassExecution(message) @run_keyword_variant(resolve=1) @@ -2931,7 +2928,7 @@ def sleep(self, time_, reason=None): if seconds < 0: seconds = 0 self._sleep_in_parts(seconds) - self.log('Slept %s' % secs_to_timestr(seconds)) + self.log(f'Slept {secs_to_timestr(seconds)}.') if reason: self.log(reason) @@ -3050,8 +3047,8 @@ def _get_formatter(self, formatter): 'len': len, 'type': lambda x: type(x).__name__}[formatter.lower()] except KeyError: - raise ValueError("Invalid formatter '%s'. Available " - "'str', 'repr', 'ascii', 'len', and 'type'." % formatter) + raise ValueError(f"Invalid formatter '{formatter}'. Available " + f"'str', 'repr', 'ascii', 'len', and 'type'.") @run_keyword_variant(resolve=0) def log_many(self, *messages): @@ -3078,7 +3075,7 @@ def _yield_logged_messages(self, messages): yield item elif match.is_dict_variable(): for name, value in value.items(): - yield '%s=%s' % (name, value) + yield f'{name}={value}' else: yield value @@ -3142,7 +3139,7 @@ def set_log_level(self, level): except DataError as err: raise RuntimeError(str(err)) self._namespace.variables.set_global('${LOG_LEVEL}', level.upper()) - self.log('Log level changed from %s to %s.' % (old, level.upper())) + self.log(f'Log level changed from {old} to {level.upper()}.') return old def reload_library(self, name_or_instance): @@ -3156,8 +3153,7 @@ def reload_library(self, name_or_instance): calls this keyword as a method. """ library = self._namespace.reload_library(name_or_instance) - self.log('Reloaded library %s with %s keywords.' % (library.name, - len(library))) + self.log(f'Reloaded library {library.name} with {len(library)} keywords.') @run_keyword_variant(resolve=0) def import_library(self, name, *args): @@ -3523,7 +3519,7 @@ def set_test_message(self, message, append=False): 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('Set test message to:\n%s' % message, level) + self.log(f'Set test message to:\n{message}', level) def _get_new_text(self, old, new, append, handle_html=False): if not is_string(new): @@ -3534,10 +3530,10 @@ def _get_new_text(self, old, new, append, handle_html=False): if new.startswith('*HTML*'): new = new[6:].lstrip() if not old.startswith('*HTML*'): - old = '*HTML* %s' % html_escape(old) + old = f'*HTML* {html_escape(old)}' elif old.startswith('*HTML*'): new = html_escape(new) - return '%s %s' % (old, new) + return f'{old} {new}' def _get_logged_test_message_and_level(self, message): if message.startswith('*HTML*'): @@ -3561,12 +3557,12 @@ def set_test_documentation(self, doc, append=False): "used in suite setup or teardown.") test.doc = self._get_new_text(test.doc, doc, append) self._variables.set_test('${TEST_DOCUMENTATION}', test.doc) - self.log('Set test documentation to:\n%s' % test.doc) + self.log(f'Set test documentation to:\n{test.doc}') def set_suite_documentation(self, doc, append=False, top=False): """Sets documentation for the current test suite. - By default the possible existing documentation is overwritten, but + By default, the possible existing documentation is overwritten, but this can be changed using the optional ``append`` argument similarly as with `Set Test Message` keyword. @@ -3581,12 +3577,12 @@ def set_suite_documentation(self, doc, append=False, top=False): suite = self._get_context(top).suite suite.doc = self._get_new_text(suite.doc, doc, append) self._variables.set_suite('${SUITE_DOCUMENTATION}', suite.doc, top) - self.log('Set suite documentation to:\n%s' % suite.doc) + self.log(f'Set suite documentation to:\n{suite.doc}') def set_suite_metadata(self, name, value, append=False, top=False): """Sets metadata for the current test suite. - By default possible existing metadata values are overwritten, but + By default, possible existing metadata values are overwritten, but this can be changed using the optional ``append`` argument similarly as with `Set Test Message` keyword. @@ -3604,7 +3600,7 @@ def set_suite_metadata(self, name, value, append=False, top=False): original = metadata.get(name, '') metadata[name] = self._get_new_text(original, value, append) self._variables.set_suite('${SUITE_METADATA}', metadata.copy(), top) - self.log("Set suite metadata '%s' to value '%s'." % (name, metadata[name])) + self.log(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. @@ -3629,7 +3625,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('Set tag%s %s.' % (s(tags), seq2str(tags))) + self.log(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. @@ -3657,7 +3653,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('Removed tag%s %s.' % (s(tags), seq2str(tags))) + self.log(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. @@ -3672,8 +3668,7 @@ def get_library_instance(self, name=None, all=False): | seleniumlib = BuiltIn().get_library_instance('SeleniumLibrary') | title = seleniumlib.get_title() | if not title.startswith(expected): - | raise AssertionError("Title '%s' did not start with '%s'" - | % (title, expected)) + | raise AssertionError(f"Title '{title}' did not start with '{expected}'.") It is also possible to use this keyword in the test data and pass the returned library instance to another keyword. If a From e2b276a9627c7b9001a986c1f4f2d2448939852b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 17:45:33 +0300 Subject: [PATCH 0741/1592] Fix building kw teardown to js model. Also tiny performance enhancement for handling empty strings. --- src/robot/reporting/jsbuildingcontext.py | 4 +++- src/robot/reporting/jsmodelbuilders.py | 14 +++++++------ src/robot/reporting/stringcache.py | 7 ++++--- utest/reporting/test_jsmodelbuilders.py | 26 ++++++++++++++++++------ 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/robot/reporting/jsbuildingcontext.py b/src/robot/reporting/jsbuildingcontext.py index 1865bd0cfde..08dbcb09f22 100644 --- a/src/robot/reporting/jsbuildingcontext.py +++ b/src/robot/reporting/jsbuildingcontext.py @@ -48,7 +48,9 @@ def _get_log_dir(self, log_path): return None def string(self, string, escape=True, attr=False): - if escape and string: + if not string: + return self._strings.empty + if escape: if not isinstance(string, str): string = safe_str(string) string = (html_escape if not attr else attribute_escape)(string) diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index f0152974dd0..777337a4a75 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -153,11 +153,11 @@ def build_body_item(self, item, split=False): with self._context.prune_input(item.body): if isinstance (item, Keyword): self._context.check_expansion(item) - items = item.body.flatten() + body = item.body.flatten() if item.has_teardown: - items.append(item.teardown) + body.append(item.teardown) return self._build(item, item.name, item.owner, item.timeout, item.doc, item.args, - item.assign, item.tags, split=split) + item.assign, item.tags, body, split=split) if isinstance(item, Return): return self._build(item, args=item.values, split=split) if isinstance(item, Error): @@ -165,17 +165,19 @@ def build_body_item(self, item, split=False): return self._build(item, item._name, split=split) def _build(self, item, name='', owner='', timeout='', doc='', args=(), assign=(), - tags=(), items=None, split =False): + tags=(), body=None, split =False): + if body is None: + body = item.body.flatten() return (KEYWORD_TYPES[item.type], self._string(name, attr=True), self._string(owner, attr=True), self._string(timeout), - self._html(item.doc), + self._html(doc), self._string(', '.join(args)), self._string(', '.join(assign)), self._string(', '.join(tags)), self._get_status(item), - self._build_body(items if items is not None else item.body.flatten(), split)) + self._build_body(body, split)) class MessageBuilder(_Builder): diff --git a/src/robot/reporting/stringcache.py b/src/robot/reporting/stringcache.py index 0a0d6bcbcc5..8d7d5553875 100644 --- a/src/robot/reporting/stringcache.py +++ b/src/robot/reporting/stringcache.py @@ -15,22 +15,23 @@ from robot.utils import compress_text, html_format + # TODO: can this be removed? class StringIndex(int): pass class StringCache: + empty = StringIndex(0) _compress_threshold = 80 _use_compressed_threshold = 1.1 - _zero_index = StringIndex(0) def __init__(self): - self._cache = {('', False): self._zero_index} + self._cache = {('', False): self.empty} def add(self, text, html=False): if not text: - return self._zero_index + return self.empty key = (text, html) if key not in self._cache: self._cache[key] = StringIndex(len(self._cache)) diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index d3d13453e9d..d9157dff23c 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -43,8 +43,10 @@ def test_default_suite(self): def test_suite_with_values(self): suite = TestSuite('Name', 'Doc', {'m1': 'v1', 'M2': 'V2'}, None, False, 'Message', '2011-12-04 19:00:00.000', '2011-12-04 19:00:42.001') + s = self._verify_keyword(suite.setup.config(name='S'), type=1, name='S') + t = self._verify_keyword(suite.teardown.config(name='T'), type=2, name='T') self._verify_suite(suite, 'Name', 'Doc', ('m1', '

v1

', 'M2', '

V2

'), - message='Message', start=0, elapsed=42001) + message='Message', start=0, elapsed=42001, keywords=(s, t)) def test_relative_source(self): self._verify_suite(TestSuite(source='non-existing'), @@ -65,12 +67,11 @@ def test_default_test(self): def test_test_with_values(self): test = TestCase('Name', '*Doc*', ['t1', 't2'], '1 minute', 42, 'PASS', 'Msg', '2011-12-04 19:22:22.222', '2011-12-04 19:22:22.333') - test.setup.config(name='setup') - test.teardown.config(name='td') - k1 = self._verify_keyword(test.setup, type=1, name='setup') - k2 = self._verify_keyword(test.teardown, type=2, name='td') + k = self._verify_keyword(test.body.create_keyword('K'), name='K') + s = self._verify_keyword(test.setup.config(name='S'), type=1, name='S') + t = self._verify_keyword(test.teardown.config(name='T'), type=2, name='T') self._verify_test(test, 'Name', 'Doc', ('t1', 't2'), - '1 minute', 1, 'Msg', 0, 111, (k1, k2)) + '1 minute', 1, 'Msg', 0, 111, (s, k, t)) def test_name_escaping(self): kw = Keyword('quote:"', 'and *url* https://url.com', doc='*"Doc"*',) @@ -92,6 +93,19 @@ def test_keyword_with_values(self): 'arg1, arg2', '${v1}, ${v2}', 'tag1, tag2', '1 second', 0, 0, 42, 'message') + def test_keyword_with_body(self): + root = Keyword('Root') + exp1 = self._verify_keyword(root.body.create_keyword('C1'), name='C1') + exp2 = self._verify_keyword(root.body.create_keyword('C2'), name='C2') + self._verify_keyword(root, name='Root', body=(exp1, exp2)) + + def test_keyword_with_teardown(self): + root = Keyword('Root') + t = self._verify_keyword(root.teardown.config(name='T'), type=2, name='T') + self._verify_keyword(root, name='Root', body=(t,)) + k = self._verify_keyword(root.body.create_keyword('K'), name='K') + self._verify_keyword(root, name='Root', body=(k, t)) + def test_default_message(self): self._verify_message(Message()) self._verify_min_message_level('INFO') From a27bff26c0620d3e7dffc7e8d1f01ca31211487a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 20:02:42 +0300 Subject: [PATCH 0742/1592] Handlers: `longname`, 'shortdoc' -> `full_name`, `short_doc` Internally used keyword handler objects as well as objects used by Libdoc had `longname` and `shortdoc` attributes that don't follow our naming conventions. With keyword result objects we just introduced `full_name` for the same purpose as `longname` (#4884), and this commit renames `longname` to `full_name` also with handlers. At the same time `shortdoc` is renamed to `short_doc`. --- src/robot/libdocpkg/htmlutils.py | 11 ++-- src/robot/libdocpkg/jsonbuilder.py | 2 +- src/robot/libdocpkg/model.py | 24 ++++----- src/robot/libdocpkg/xmlbuilder.py | 2 +- src/robot/libdocpkg/xmlwriter.py | 2 +- src/robot/running/context.py | 4 +- src/robot/running/handlers.py | 12 ++--- src/robot/running/handlerstore.py | 2 +- src/robot/running/librarykeywordrunner.py | 14 ++--- src/robot/running/namespace.py | 18 +++---- src/robot/running/usererrorhandler.py | 14 ++--- src/robot/running/userkeyword.py | 16 +++--- src/robot/running/userkeywordrunner.py | 12 ++--- utest/libdoc/test_libdoc.py | 62 +++++++++++------------ utest/running/test_handlers.py | 4 +- utest/running/test_testlibrary.py | 2 +- utest/running/test_userhandlers.py | 9 ++-- utest/running/test_userlibrary.py | 8 +-- 18 files changed, 103 insertions(+), 115 deletions(-) diff --git a/src/robot/libdocpkg/htmlutils.py b/src/robot/libdocpkg/htmlutils.py index 36e29c77cf5..c171093e650 100644 --- a/src/robot/libdocpkg/htmlutils.py +++ b/src/robot/libdocpkg/htmlutils.py @@ -14,10 +14,7 @@ # limitations under the License. import re -try: - from urllib import quote -except ImportError: - from urllib.parse import quote +from urllib.parse import quote from robot.errors import DataError from robot.utils import html_escape, html_format, NormalizedDict @@ -97,10 +94,10 @@ def _get_formatter(self, doc_format): 'HTML': lambda doc: doc, 'REST': self._format_rest}[doc_format] except KeyError: - raise DataError("Invalid documentation format '%s'." % doc_format) + raise DataError(f"Invalid documentation format '{doc_format}'.") def _format_text(self, doc): - return '

%s

' % html_escape(doc) + return f'

{html_escape(doc)}

' def _format_rest(self, doc): try: @@ -133,7 +130,7 @@ class HtmlToText: ''': "'" } - def get_shortdoc_from_html(self, doc): + def get_short_doc_from_html(self, doc): match = re.search(r'(.*?)', doc, re.DOTALL) if match: doc = match.group(1) diff --git a/src/robot/libdocpkg/jsonbuilder.py b/src/robot/libdocpkg/jsonbuilder.py index ce2bc227f94..825aa6ba2fd 100644 --- a/src/robot/libdocpkg/jsonbuilder.py +++ b/src/robot/libdocpkg/jsonbuilder.py @@ -57,7 +57,7 @@ def _parse_spec_json(self, path): def _create_keyword(self, data): kw = KeywordDoc(name=data.get('name'), doc=data['doc'], - shortdoc=data['shortdoc'], + short_doc=data['shortdoc'], tags=data['tags'], private=data.get('private', False), deprecated=data.get('deprecated', False), diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index c992eeb7103..8d4c0482b30 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -97,9 +97,9 @@ def convert_docs_to_html(self): formatter = DocFormatter(self.keywords, self.type_docs, self.doc, self.doc_format) self._doc = formatter.html(self.doc, intro=True) for item in self.inits + self.keywords: - # If 'shortdoc' is not set, it is generated automatically based on 'doc' + # If 'short_doc' is not set, it is generated automatically based on 'doc' # when accessed. Generate and set it to avoid HTML format affecting it. - item.shortdoc = item.shortdoc + item.short_doc = item.short_doc item.doc = formatter.html(item.doc) for type_doc in self.type_docs: # Standard docs are always in ROBOT format ... @@ -141,12 +141,12 @@ def to_json(self, indent=None, include_private=True, theme=None): class KeywordDoc(Sortable): """Documentation for a single keyword or an initializer.""" - def __init__(self, name='', args=None, doc='', shortdoc='', tags=(), private=False, + def __init__(self, name='', args=None, doc='', short_doc='', tags=(), private=False, deprecated=False, source=None, lineno=-1, parent=None): self.name = name self.args = args or ArgumentSpec() self.doc = doc - self._shortdoc = shortdoc + self._short_doc = short_doc self.tags = Tags(tags) self.private = private self.deprecated = deprecated @@ -157,19 +157,19 @@ def __init__(self, name='', args=None, doc='', shortdoc='', tags=(), private=Fal self.type_docs = {arg.name: {} for arg in self.args} @property - def shortdoc(self): - return self._shortdoc or self._doc_to_shortdoc() + def short_doc(self): + return self._short_doc or self._doc_to_short_doc() - def _doc_to_shortdoc(self): + def _doc_to_short_doc(self): if self.parent and self.parent.doc_format == 'HTML': - doc = HtmlToText().get_shortdoc_from_html(self.doc) + doc = HtmlToText().get_short_doc_from_html(self.doc) else: doc = self.doc return ' '.join(getshortdoc(doc).splitlines()) - @shortdoc.setter - def shortdoc(self, shortdoc): - self._shortdoc = shortdoc + @short_doc.setter + def short_doc(self, short_doc): + self._short_doc = short_doc @property def _sort_key(self): @@ -180,7 +180,7 @@ def to_dictionary(self): 'name': self.name, 'args': [self._arg_to_dict(arg) for arg in self.args], 'doc': self.doc, - 'shortdoc': self.shortdoc, + 'shortdoc': self.short_doc, 'tags': list(self.tags), 'source': str(self.source) if self.source else None, 'lineno': self.lineno diff --git a/src/robot/libdocpkg/xmlbuilder.py b/src/robot/libdocpkg/xmlbuilder.py index 1c40bdcd658..562afb8b262 100644 --- a/src/robot/libdocpkg/xmlbuilder.py +++ b/src/robot/libdocpkg/xmlbuilder.py @@ -63,7 +63,7 @@ def _create_keywords(self, spec, path, lib_source): def _create_keyword(self, elem, lib_source): kw = KeywordDoc(name=elem.get('name', ''), doc=elem.find('doc').text or '', - shortdoc=elem.find('shortdoc').text or '', + short_doc=elem.find('shortdoc').text or '', tags=[t.text for t in elem.findall('tags/tag')], private=elem.get('private', 'false') == 'true', deprecated=elem.get('deprecated', 'false') == 'true', diff --git a/src/robot/libdocpkg/xmlwriter.py b/src/robot/libdocpkg/xmlwriter.py index db8ae164833..1f2377c01a7 100644 --- a/src/robot/libdocpkg/xmlwriter.py +++ b/src/robot/libdocpkg/xmlwriter.py @@ -55,7 +55,7 @@ def _write_keywords(self, list_name, kw_type, keywords, lib_source, writer): writer.start(kw_type, attrs) self._write_arguments(kw, writer) writer.element('doc', kw.doc) - writer.element('shortdoc', kw.shortdoc) + writer.element('shortdoc', kw.short_doc) if kw_type == 'kw' and kw.tags: self._write_tags(kw.tags, writer) writer.end(kw_type) diff --git a/src/robot/running/context.py b/src/robot/running/context.py index f2d33884c63..7119000c45c 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -146,7 +146,7 @@ def user_keyword(self, handler): def warn_on_invalid_private_call(self, handler): parent = self.user_keywords[-1] if self.user_keywords else None if not parent or parent.source != handler.source: - self.warn(f"Keyword '{handler.longname}' is private and should only " + self.warn(f"Keyword '{handler.full_name}' is private and should only " f"be called by keywords in the same file.") @contextmanager @@ -183,7 +183,7 @@ def allow_loop_control(self): for step in reversed(self.steps): if step.type == 'ITERATION': return True - if step.type == 'KEYWORD' and step.libname != 'BuiltIn': + if step.type == 'KEYWORD' and step.owner != 'BuiltIn': return False return False diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py index 5ec67e529b6..11210467c45 100644 --- a/src/robot/running/handlers.py +++ b/src/robot/running/handlers.py @@ -13,8 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from copy import copy import inspect +from copy import copy from robot.utils import (getdoc, getshortdoc, is_list_like, normpath, printable_name, split_tags_from_doc, type_name) @@ -91,15 +91,15 @@ def doc(self): return self._doc @property - def longname(self): + def full_name(self): return f'{self.library.name}.{self.name}' @property - def shortdoc(self): + def short_doc(self): return getshortdoc(self.doc) @property - def libname(self): + def owner(self): return self.library.name @property @@ -137,7 +137,7 @@ def __init__(self, library, handler_name, handler_method): super().__init__(library, handler_name, handler_method, getdoc(handler_method)) def _parse_arguments(self, handler_method): - return PythonArgumentParser().parse(handler_method, self.longname) + return PythonArgumentParser().parse(handler_method, self.full_name) @property def source(self): @@ -175,7 +175,7 @@ def __init__(self, library, handler_name, dynamic_method, doc='', argspec=None, self._source_info = None def _parse_arguments(self, handler_method): - spec = DynamicArgumentParser().parse(self._argspec, self.longname) + spec = DynamicArgumentParser().parse(self._argspec, self.full_name) if not self._supports_kwargs: name = self._run_keyword_method_name if spec.var_named: diff --git a/src/robot/running/handlerstore.py b/src/robot/running/handlerstore.py index 36eab2df16a..842de5591d5 100644 --- a/src/robot/running/handlerstore.py +++ b/src/robot/running/handlerstore.py @@ -35,7 +35,7 @@ def add(self, handler, embedded=False): else: error = DataError('Keyword with same name defined multiple times.') self._normal[handler.name] = UserErrorHandler(error, handler.name, - handler.libname) + handler.owner) raise error def __iter__(self): diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index 11187ba3830..7f2452dac12 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -39,12 +39,8 @@ def library(self): return self._handler.library @property - def libname(self): - return self._handler.library.name - - @property - def longname(self): - return '%s.%s' % (self.library.name, self.name) + def full_name(self): + return f'{self.library.name}.{self.name}' def run(self, kw, context, run=True): assignment = VariableAssignment(kw.assign) @@ -59,8 +55,8 @@ def run(self, kw, context, run=True): def _get_result(self, kw, assignment): handler = self._handler return KeywordResult(name=self.name, - owner=handler.libname, - doc=handler.shortdoc, + owner=handler.owner, + doc=handler.short_doc, args=kw.args, assign=tuple(assignment), tags=handler.tags, @@ -129,7 +125,7 @@ def _executed_in_dry_run(self, handler): 'BuiltIn.Set Library Search Order', 'BuiltIn.Set Tags', 'BuiltIn.Remove Tags') - return handler.longname in keywords_to_execute + return handler.full_name in keywords_to_execute class EmbeddedArgumentsRunner(LibraryKeywordRunner): diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 98881c42ee4..8bfb4fefb89 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -203,15 +203,15 @@ def start_user_keyword(self): def end_user_keyword(self): self.variables.end_keyword() - def get_library_instance(self, libname): - return self._kw_store.get_library(libname).get_instance() + def get_library_instance(self, name): + return self._kw_store.get_library(name).get_instance() def get_library_instances(self): return dict((name, lib.get_instance()) for name, lib in self._kw_store.libraries.items()) - def reload_library(self, libname_or_instance): - library = self._kw_store.get_library(libname_or_instance) + def reload_library(self, name_or_instance): + library = self._kw_store.get_library(name_or_instance) library.reload() return library @@ -328,7 +328,7 @@ def _get_runner_from_suite_file(self, name): if caller and runner.source != caller.source: if self._exists_in_resource_file(name, caller.source): message = ( - f"Keyword '{caller.longname}' called keyword '{name}' that exists " + f"Keyword '{caller.full_name}' called keyword '{name}' that exists " f"both in the same resource file as the caller and in the suite " f"file using that resource. The keyword in the suite file is used " f"now, but this will change in Robot Framework 7.0." @@ -409,8 +409,8 @@ def _prioritize_same_file_or_public(self, handlers): return matches or handlers def _filter_based_on_search_order(self, handlers): - for libname in self.search_order: - matches = [hand for hand in handlers if eq(libname, hand.libname)] + for name in self.search_order: + matches = [hand for hand in handlers if eq(name, hand.owner)] if matches: return matches return handlers @@ -441,7 +441,7 @@ def _custom_and_standard_keyword_conflict_warning(self, custom, standard): f"'{custom.library.orig_name}'{custom_with_name} and a standard library " f"'{standard.library.orig_name}'{standard_with_name}. The custom keyword " f"is used. To select explicitly, and to get rid of this warning, use " - f"either '{custom.longname}' or '{standard.longname}'.", level='WARN' + f"either '{custom.full_name}' or '{standard.full_name}'.", level='WARN' ) def _get_explicit_runner(self, name): @@ -475,7 +475,7 @@ def _raise_multiple_keywords_found(self, handlers, name, implicit=True): error = f"Multiple keywords with name '{name}' found" if implicit: error += ". Give the full name of the keyword you want to use" - names = sorted(hand.longname for hand in handlers) + names = sorted(hand.full_name for hand in handlers) raise KeywordError('\n '.join([error+':'] + names)) diff --git a/src/robot/running/usererrorhandler.py b/src/robot/running/usererrorhandler.py index babb963c779..eb6c4426001 100644 --- a/src/robot/running/usererrorhandler.py +++ b/src/robot/running/usererrorhandler.py @@ -30,17 +30,17 @@ class UserErrorHandler: """ supports_embedded_arguments = False - def __init__(self, error, name, libname=None, source=None, lineno=None): + def __init__(self, error, name, owner=None, source=None, lineno=None): """ :param robot.errors.DataError error: Occurred error. :param str name: Name of the affected keyword. - :param str libname: Name of the affected library or resource. + :param str owner: Name of the affected library or resource. :param str source: Path to the source file. :param int lineno: Line number of the failing keyword. """ self.error = error self.name = name - self.libname = libname + self.owner = owner self.source = source self.lineno = lineno self.arguments = ArgumentSpec() @@ -48,15 +48,15 @@ def __init__(self, error, name, libname=None, source=None, lineno=None): self.tags = Tags() @property - def longname(self): - return f'{self.libname}.{self.name}' if self.libname else self.name + def full_name(self): + return f'{self.owner}.{self.name}' if self.owner else self.name @property def doc(self): return f'*Creating keyword failed:* {self.error}' @property - def shortdoc(self): + def short_doc(self): return self.doc.splitlines()[0] def create_runner(self, name, languages=None): @@ -64,7 +64,7 @@ def create_runner(self, name, languages=None): def run(self, kw, context, run=True): result = KeywordResult(name=self.name, - owner=self.libname, + owner=self.owner, args=kw.args, assign=tuple(VariableAssignment(kw.assign)), type=kw.type) diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index 2c98653389f..729ce286ec7 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -67,26 +67,26 @@ def handlers_for(self, name): class UserKeywordHandler: supports_embedded_args = False - def __init__(self, keyword, libname): + def __init__(self, keyword, owner): self.name = keyword.name - self.libname = libname + self.owner = owner self.doc = keyword.doc self.source = keyword.source self.lineno = keyword.lineno self.tags = keyword.tags self.arguments = UserKeywordArgumentParser().parse(tuple(keyword.args), - self.longname) + self.full_name) self.timeout = keyword.timeout self.body = keyword.body self.return_value = tuple(keyword.return_) self.teardown = keyword.teardown if keyword.has_teardown else None @property - def longname(self): - return '%s.%s' % (self.libname, self.name) if self.libname else self.name + def full_name(self): + return f'{self.owner}.{self.name}' if self.owner else self.name @property - def shortdoc(self): + def short_doc(self): return getshortdoc(self.doc) @property @@ -100,8 +100,8 @@ def create_runner(self, name, languages=None): class EmbeddedArgumentsHandler(UserKeywordHandler): supports_embedded_args = True - def __init__(self, keyword, libname, embedded): - super().__init__(keyword, libname) + def __init__(self, keyword, owner, embedded): + super().__init__(keyword, owner) self.embedded = embedded def matches(self, name): diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 775e1624483..cafa474d4e8 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -36,13 +36,9 @@ def __init__(self, handler, name=None): self.pre_run_messages = () @property - def longname(self): - libname = self._handler.libname - return f'{libname}.{self.name}' if libname else self.name - - @property - def libname(self): - return self._handler.libname + def full_name(self): + owner = self._handler.owner + return f'{owner}.{self.name}' if owner else self.name @property def tags(self): @@ -75,7 +71,7 @@ def _get_result(self, kw, assignment, variables): doc, tags = split_tags_from_doc(doc) tags = variables.replace_list(handler.tags, ignore_errors=True) + tags return KeywordResult(name=self.name, - owner=handler.libname, + owner=handler.owner, doc=getshortdoc(doc), args=kw.args, assign=tuple(assignment), diff --git a/utest/libdoc/test_libdoc.py b/utest/libdoc/test_libdoc.py index 6aa54265e77..f78b4666350 100644 --- a/utest/libdoc/test_libdoc.py +++ b/utest/libdoc/test_libdoc.py @@ -12,7 +12,7 @@ from robot.libdocpkg.model import LibraryDoc, KeywordDoc from robot.libdocpkg.htmlutils import HtmlToText -get_shortdoc = HtmlToText().get_shortdoc_from_html +get_short_doc = HtmlToText().get_short_doc_from_html get_text = HtmlToText().html_to_plain_text CURDIR = Path(__file__).resolve().parent @@ -30,15 +30,15 @@ TYPEDDICT_SUPPORTS_REQUIRED_KEYS = True -def verify_shortdoc_output(doc_input, expected): - current = get_shortdoc(doc_input) +def verify_short_doc_output(doc_input, expected): + current = get_short_doc(doc_input) assert_equal(current, expected) -def verify_keyword_shortdoc(doc_format, doc_input, expected): +def verify_keyword_short_doc(doc_format, doc_input, expected): libdoc = LibraryDoc(doc_format=doc_format) libdoc.keywords = [KeywordDoc(doc=doc_input)] - assert_equal(libdoc.keywords[0].shortdoc, expected) + assert_equal(libdoc.keywords[0].short_doc, expected) def run_libdoc_and_validate_json(filename): @@ -49,37 +49,37 @@ def run_libdoc_and_validate_json(filename): class TestHtmlToDoc(unittest.TestCase): - def test_shortdoc_firstline(self): + def test_short_doc_first_line(self): doc = """

This is the first line

This is the second one

""" exp = "This is the first line" - verify_shortdoc_output(doc, exp) + verify_short_doc_output(doc, exp) - def test_shortdoc_replace_format(self): + def test_short_doc_replace_format(self): doc = "

This is bold or italic or italicbold and code.

" exp = "This is *bold* or _italic_ or _*italicbold*_ and code." - verify_shortdoc_output(doc, exp) + verify_short_doc_output(doc, exp) - def test_shortdoc_replace_format_multiline(self): + def test_short_doc_replace_format_multiline(self): doc = """

This is bold or italic or italic bold and code.

""" exp = """This is *bold* or _italic_ or _*italic bold*_ and ``code``.""" - verify_shortdoc_output(doc, exp) + verify_short_doc_output(doc, exp) - def test_shortdoc_unexcape_html(self): + def test_short_doc_unexcape_html(self): doc = """

This & "<b>is</b>" <i>the</i> </p>'first' line

""" exp = """This & "*is*" the

'first' line""" - verify_shortdoc_output(doc, exp) + verify_short_doc_output(doc, exp) class TestKeywordShortDoc(unittest.TestCase): - def test_shortdoc_with_multiline_plain_text(self): + def test_short_doc_with_multiline_plain_text(self): doc = """Writes the message to the console. If the ``newline`` argument is ``True``, a newline character is @@ -89,12 +89,12 @@ def test_shortdoc_with_multiline_plain_text(self): Using the standard error stream is possibly by giving the ``stream`` argument value ``'stderr'``.""" exp = "Writes the message to the console." - verify_keyword_shortdoc('TEXT', doc, exp) + verify_keyword_short_doc('TEXT', doc, exp) - def test_shortdoc_with_empty_plain_text(self): - verify_keyword_shortdoc('TEXT', '', '') + def test_short_doc_with_empty_plain_text(self): + verify_keyword_short_doc('TEXT', '', '') - def test_shortdoc_with_multiline_robot_format(self): + def test_short_doc_with_multiline_robot_format(self): doc = """Writes the *message* to _the_ ``console``. @@ -106,12 +106,12 @@ def test_shortdoc_with_multiline_robot_format(self): Using the standard error stream is possibly by giving the ``stream`` argument value ``'stderr'``.""" exp = "Writes the *message* to _the_ ``console``." - verify_keyword_shortdoc('ROBOT', doc, exp) + verify_keyword_short_doc('ROBOT', doc, exp) - def test_shortdoc_with_empty_robot_format(self): - verify_keyword_shortdoc('ROBOT', '', '') + def test_short_doc_with_empty_robot_format(self): + verify_keyword_short_doc('ROBOT', '', '') - def test_shortdoc_with_multiline_HTML_format(self): + def test_short_doc_with_multiline_HTML_format(self): doc = """

Writes
the message to the console.

If the newline argument is True, a newline character is @@ -120,9 +120,9 @@ def test_shortdoc_with_multiline_HTML_format(self): Using the standard error stream is possibly by giving the stream argument value ``'stderr'``.""" exp = "*Writes* _the_ *message* to _the_ ``console``." - verify_keyword_shortdoc('HTML', doc, exp) + verify_keyword_short_doc('HTML', doc, exp) - def test_shortdoc_with_nonclosing_p_HTML_format(self): + def test_short_doc_with_nonclosing_p_HTML_format(self): doc = """

Writes
the message to the console.

If the newline argument is True, a newline character is @@ -131,12 +131,12 @@ def test_shortdoc_with_nonclosing_p_HTML_format(self): Using the standard error stream is possibly by giving the stream argument value ``'stderr'``.""" exp = "*Writes* _the_ *message* to _the_ ``console``." - verify_keyword_shortdoc('HTML', doc, exp) + verify_keyword_short_doc('HTML', doc, exp) - def test_shortdoc_with_empty_HTML_format(self): - verify_keyword_shortdoc('HTML', '', '') + def test_short_doc_with_empty_HTML_format(self): + verify_keyword_short_doc('HTML', '', '') - def test_shortdoc_with_multiline_reST_format(self): + def test_short_doc_with_multiline_reST_format(self): doc = """Writes the **message** to *the* console. @@ -147,10 +147,10 @@ def test_shortdoc_with_multiline_reST_format(self): Using the standard error stream is possibly by giving the ``stream`` argument value ``'stderr'``.""" exp = "Writes the **message** to *the* console." - verify_keyword_shortdoc('REST', doc, exp) + verify_keyword_short_doc('REST', doc, exp) - def test_shortdoc_with_empty_reST_format(self): - verify_keyword_shortdoc('REST', '', '') + def test_short_doc_with_empty_reST_format(self): + verify_keyword_short_doc('REST', '', '') class TestLibdocJsonWriter(unittest.TestCase): diff --git a/utest/running/test_handlers.py b/utest/running/test_handlers.py index fd4b06cd6c6..ccc01b1278e 100644 --- a/utest/running/test_handlers.py +++ b/utest/running/test_handlers.py @@ -48,13 +48,13 @@ def test_name(self): for method in _get_handler_methods(NameLibrary()): handler = _PythonHandler(LibraryMock('mylib'), method.__name__, method) assert_equal(handler.name, method.__doc__) - assert_equal(handler.longname, 'mylib.'+method.__doc__) + assert_equal(handler.full_name, 'mylib.'+method.__doc__) def test_docs(self): for method in _get_handler_methods(DocLibrary()): handler = _PythonHandler(LibraryMock(), method.__name__, method) assert_equal(handler.doc, method.expected_doc) - assert_equal(handler.shortdoc, method.expected_shortdoc) + assert_equal(handler.short_doc, method.expected_shortdoc) def test_arguments(self): for method in _get_handler_methods(ArgInfoLibrary()): diff --git a/utest/running/test_testlibrary.py b/utest/running/test_testlibrary.py index d4dc7210116..47117ebec9c 100644 --- a/utest/running/test_testlibrary.py +++ b/utest/running/test_testlibrary.py @@ -131,7 +131,7 @@ def _verify_lib(self, lib, libname, keywords): assert_equal(libname, lib.name) for name, _ in keywords: handler = lib.handlers[name] - assert_equal(normalize(handler.longname), normalize(f"{libname}.{name}")) + assert_equal(normalize(handler.full_name), normalize(f"{libname}.{name}")) class TestLibraryInit(unittest.TestCase): diff --git a/utest/running/test_userhandlers.py b/utest/running/test_userhandlers.py index bc864c3e4fc..25abf0bc89c 100644 --- a/utest/running/test_userhandlers.py +++ b/utest/running/test_userhandlers.py @@ -5,8 +5,7 @@ from robot.model import Body from robot.running.userkeyword import EmbeddedArgumentsHandler from robot.running.arguments import EmbeddedArguments, UserKeywordArgumentParser -from robot.utils.asserts import (assert_equal, assert_true, assert_raises, - assert_raises_with_msg) +from robot.utils.asserts import assert_equal, assert_true, assert_raises_with_msg class Fake: @@ -77,11 +76,11 @@ def test_create_runner_with_one_embedded_arg(self): runner = self.tmp1.create_runner('User selects book from list') assert_equal(runner.embedded_args, ('book',)) assert_equal(runner.name, 'User selects book from list') - assert_equal(runner.longname, 'resource.User selects book from list') + assert_equal(runner.full_name, 'resource.User selects book from list') runner = self.tmp1.create_runner('User selects radio from list') assert_equal(runner.embedded_args, ('radio',)) assert_equal(runner.name, 'User selects radio from list') - assert_equal(runner.longname, 'resource.User selects radio from list') + assert_equal(runner.full_name, 'resource.User selects radio from list') def test_create_runner_with_many_embedded_args(self): runner = self.tmp2.create_runner('User * book from "list"') @@ -109,7 +108,7 @@ def test_creating_runners_is_case_insensitive(self): runner = self.tmp1.create_runner('User SELECts book frOm liST') assert_equal(runner.embedded_args, ('book',)) assert_equal(runner.name, 'User SELECts book frOm liST') - assert_equal(runner.longname, 'resource.User SELECts book frOm liST') + assert_equal(runner.full_name, 'resource.User SELECts book frOm liST') class TestGetArgSpec(unittest.TestCase): diff --git a/utest/running/test_userlibrary.py b/utest/running/test_userlibrary.py index 17c6a2a8bed..e3210b088f4 100644 --- a/utest/running/test_userlibrary.py +++ b/utest/running/test_userlibrary.py @@ -3,16 +3,16 @@ from robot.running import userkeyword from robot.running.model import ResourceFile, UserKeyword -from robot.running.userkeyword import EmbeddedArguments, UserLibrary +from robot.running.userkeyword import UserLibrary from robot.utils.asserts import (assert_equal, assert_none, assert_raises_with_msg, assert_true) class UserHandlerStub: - def __init__(self, kwdata, library): + def __init__(self, kwdata, owner): self.name = kwdata.name - self.libname = library + self.owner = owner self.lineno = 42 if kwdata.name == 'FAIL': raise Exception('Expected failure') @@ -23,7 +23,7 @@ def create(self, name): class EmbeddedArgsHandlerStub: - def __init__(self, kwdata, library, embedded): + def __init__(self, kwdata, owner, embedded): if '${' not in kwdata.name: raise TypeError self.name = kwdata.name From a99f111588bc00c62070ece1e2f33a78707dfaf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 21:29:50 +0300 Subject: [PATCH 0743/1592] Add timedelta support to secs_to_timestr. --- src/robot/utils/robottime.py | 19 ++++++++++--------- utest/utils/test_robottime.py | 1 + 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index d789d6a48c7..c3d6e5ab2a5 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -128,7 +128,7 @@ def _normalize_timestr(timestr): return timestr -def secs_to_timestr(secs, compact=False): +def secs_to_timestr(secs: 'int|float|timedelta', compact=False) -> str: """Converts time in seconds to a string representation. Returned string is in format like @@ -136,12 +136,14 @@ def secs_to_timestr(secs, compact=False): - Time parts having zero value are not included (e.g. '3 minutes 4 seconds' instead of '0 days 0 hours 3 minutes 4 seconds') - - Hour part has a maximun of 23 and minutes and seconds both have 59 + - Hour part has a maximum of 23 and minutes and seconds both have 59 (e.g. '1 minute 40 seconds' instead of '100 seconds') If compact has value 'True', short suffixes are used. (e.g. 1d 2h 3min 4s 5ms) """ + if isinstance(secs, timedelta): + secs = secs.total_seconds() return _SecsToTimestrHelper(secs, compact).get_value() @@ -150,13 +152,12 @@ class _SecsToTimestrHelper: def __init__(self, float_secs, compact): self._compact = compact self._ret = [] - self._sign, millis, secs, mins, hours, days \ - = self._secs_to_components(float_secs) - self._add_item(days, 'd', 'day') - self._add_item(hours, 'h', 'hour') - self._add_item(mins, 'min', 'minute') - self._add_item(secs, 's', 'second') - self._add_item(millis, 'ms', 'millisecond') + self._sign, ms, sec, min, hour, day = self._secs_to_components(float_secs) + self._add_item(day, 'd', 'day') + self._add_item(hour, 'h', 'hour') + self._add_item(min, 'min', 'minute') + self._add_item(sec, 's', 'second') + self._add_item(ms, 'ms', 'millisecond') def get_value(self): if len(self._ret) > 0: diff --git a/utest/utils/test_robottime.py b/utest/utils/test_robottime.py index a77fd7ecf54..dbf8e55e38c 100644 --- a/utest/utils/test_robottime.py +++ b/utest/utils/test_robottime.py @@ -205,6 +205,7 @@ def test_secs_to_timestr(self): '- 1 day 23 hours 46 minutes 7 seconds 667 milliseconds')]: assert_equal(secs_to_timestr(inp, compact=True), compact, inp) assert_equal(secs_to_timestr(inp), verbose, inp) + assert_equal(secs_to_timestr(timedelta(seconds=inp)), verbose, inp) def test_format_time(self): timetuple = (2005, 11, 2, 14, 23, 12, 123) From 32646013b59335237227c1fbddb2bb62a8e47452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 21:36:27 +0300 Subject: [PATCH 0744/1592] Cleanup. - f-strings - `self._attr` -> `self.attr` - ... --- src/robot/output/console/dotted.py | 85 ++++++++++---------- src/robot/output/console/verbose.py | 118 ++++++++++++++-------------- utest/output/test_console.py | 2 +- utest/output/test_logger.py | 2 +- 4 files changed, 103 insertions(+), 104 deletions(-) diff --git a/src/robot/output/console/dotted.py b/src/robot/output/console/dotted.py index 9ea4ac8b577..753765364ee 100644 --- a/src/robot/output/console/dotted.py +++ b/src/robot/output/console/dotted.py @@ -16,7 +16,8 @@ import sys from robot.model import SuiteVisitor -from robot.utils import plural_or_not, secs_to_timestr +from robot.result import TestCase, TestSuite +from robot.utils import plural_or_not as s, secs_to_timestr from .highlighting import HighlightingStream @@ -24,67 +25,65 @@ class DottedOutput: def __init__(self, width=78, colors='AUTO', stdout=None, stderr=None): - self._width = width - self._stdout = HighlightingStream(stdout or sys.__stdout__, colors) - self._stderr = HighlightingStream(stderr or sys.__stderr__, colors) - self._markers_on_row = 0 + self.width = width + self.stdout = HighlightingStream(stdout or sys.__stdout__, colors) + self.stderr = HighlightingStream(stderr or sys.__stderr__, colors) + self.markers_on_row = 0 - def start_suite(self, suite): + def start_suite(self, suite: TestSuite): if not suite.parent: - self._stdout.write("Running suite '%s' with %d %s%s.\n" - % (suite.name, suite.test_count, - 'test' if not suite.rpa else 'task', - plural_or_not(suite.test_count))) - self._stdout.write('=' * self._width + '\n') - - def end_test(self, test): - if self._markers_on_row == self._width: - self._stdout.write('\n') - self._markers_on_row = 0 - self._markers_on_row += 1 + count = suite.test_count + ts = ('test' if not suite.rpa else 'task') + s(count) + self.stdout.write(f"Running suite '{suite.name}' with {count} {ts}.\n") + self.stdout.write('=' * self.width + '\n') + + def end_test(self, test: TestCase): + if self.markers_on_row == self.width: + self.stdout.write('\n') + self.markers_on_row = 0 + self.markers_on_row += 1 if test.passed: - self._stdout.write('.') + self.stdout.write('.') elif test.skipped: - self._stdout.highlight('s', 'SKIP') + self.stdout.highlight('s', 'SKIP') elif test.tags.robot('exit'): - self._stdout.write('x') + self.stdout.write('x') else: - self._stdout.highlight('F', 'FAIL') + self.stdout.highlight('F', 'FAIL') - def end_suite(self, suite): + def end_suite(self, suite: TestSuite): if not suite.parent: - self._stdout.write('\n') - StatusReporter(self._stdout, self._width).report(suite) - self._stdout.write('\n') + self.stdout.write('\n') + StatusReporter(self.stdout, self.width).report(suite) + self.stdout.write('\n') def message(self, msg): if msg.level in ('WARN', 'ERROR'): - self._stderr.error(msg.message, msg.level) + self.stderr.error(msg.message, msg.level) def output_file(self, name, path): - self._stdout.write('%-8s %s\n' % (name+':', path)) + self.stdout.write(f"{name+':':8} {path}\n") class StatusReporter(SuiteVisitor): def __init__(self, stream, width): - self._stream = stream - self._width = width + self.stream = stream + self.width = width - def report(self, suite): + def report(self, suite: TestSuite): suite.visit(self) stats = suite.statistics - self._stream.write("%s\nRun suite '%s' with %d %s%s in %s.\n\n" - % ('=' * self._width, suite.name, stats.total, - 'test' if not suite.rpa else 'task', - plural_or_not(stats.total), - secs_to_timestr(suite.elapsedtime/1000.0))) - self._stream.highlight(suite.status + ('PED' if suite.status == 'SKIP' else 'ED'), suite.status) - self._stream.write('\n%s\n' % stats.message) - - def visit_test(self, test): + ts = ('test' if not suite.rpa else 'task') + s(stats.total) + elapsed = secs_to_timestr(suite.elapsed_time) + self.stream.write(f"{'=' * self.width}\nRun suite '{suite.name}' with " + f"{stats.total} {ts} in {elapsed}.\n\n") + ed = 'ED' if suite.status != 'SKIP' else 'PED' + self.stream.highlight(suite.status + ed, suite.status) + self.stream.write(f'\n{stats.message}\n') + + def visit_test(self, test: TestCase): if test.failed and not test.tags.robot('exit'): - self._stream.write('-' * self._width + '\n') - self._stream.highlight('FAIL') - self._stream.write(': %s\n%s\n' % (test.longname, - test.message.strip())) + self.stream.write('-' * self.width + '\n') + self.stream.highlight('FAIL') + self.stream.write(f': {test.longname}\n{test.message.strip()}\n') diff --git a/src/robot/output/console/verbose.py b/src/robot/output/console/verbose.py index 39bd9b3e38f..048efdc1be1 100644 --- a/src/robot/output/console/verbose.py +++ b/src/robot/output/console/verbose.py @@ -16,8 +16,8 @@ import sys from robot.errors import DataError -from robot.utils import (get_console_length, getshortdoc, isatty, - pad_console_length) +from robot.result import Keyword, TestCase, TestSuite +from robot.utils import get_console_length, getshortdoc, isatty, pad_console_length from .highlighting import HighlightingStream @@ -26,48 +26,48 @@ class VerboseOutput: def __init__(self, width=78, colors='AUTO', markers='AUTO', stdout=None, stderr=None): - self._writer = VerboseWriter(width, colors, markers, stdout, stderr) - self._started = False - self._started_keywords = 0 - self._running_test = False - - def start_suite(self, suite): - if not self._started: - self._writer.suite_separator() - self._started = True - self._writer.info(suite.longname, suite.doc, start_suite=True) - self._writer.suite_separator() - - def end_suite(self, suite): - self._writer.info(suite.longname, suite.doc) - self._writer.status(suite.status) - self._writer.message(suite.full_message) - self._writer.suite_separator() - - def start_test(self, test): - self._writer.info(test.name, test.doc) - self._running_test = True - - def end_test(self, test): - self._writer.status(test.status, clear=True) - self._writer.message(test.message) - self._writer.test_separator() - self._running_test = False - - def start_keyword(self, kw): - self._started_keywords += 1 - - def end_keyword(self, kw): - self._started_keywords -= 1 - if self._running_test and not self._started_keywords: - self._writer.keyword_marker(kw.status) + self.writer = VerboseWriter(width, colors, markers, stdout, stderr) + self.started = False + self.started_keywords = 0 + self.running_test = False + + def start_suite(self, suite: TestSuite): + if not self.started: + self.writer.suite_separator() + self.started = True + self.writer.info(suite.longname, suite.doc, start_suite=True) + self.writer.suite_separator() + + def end_suite(self, suite: TestSuite): + self.writer.info(suite.longname, suite.doc) + self.writer.status(suite.status) + self.writer.message(suite.full_message) + self.writer.suite_separator() + + def start_test(self, test: TestCase): + self.writer.info(test.name, test.doc) + self.running_test = True + + def end_test(self, test: TestCase): + self.writer.status(test.status, clear=True) + self.writer.message(test.message) + self.writer.test_separator() + self.running_test = False + + def start_keyword(self, kw: Keyword): + self.started_keywords += 1 + + def end_keyword(self, kw: Keyword): + self.started_keywords -= 1 + if self.running_test and not self.started_keywords: + self.writer.keyword_marker(kw.status) def message(self, msg): if msg.level in ('WARN', 'ERROR'): - self._writer.error(msg.message, msg.level, clear=self._running_test) + self.writer.error(msg.message, msg.level, clear=self.running_test) def output_file(self, name, path): - self._writer.output(name, path) + self.writer.output(name, path) class VerboseWriter: @@ -75,10 +75,10 @@ class VerboseWriter: def __init__(self, width=78, colors='AUTO', markers='AUTO', stdout=None, stderr=None): - self._width = width - self._stdout = HighlightingStream(stdout or sys.__stdout__, colors) - self._stderr = HighlightingStream(stderr or sys.__stderr__, colors) - self._keyword_marker = KeywordMarker(self._stdout, markers) + self.width = width + self.stdout = HighlightingStream(stdout or sys.__stdout__, colors) + self.stderr = HighlightingStream(stderr or sys.__stderr__, colors) + self._keyword_marker = KeywordMarker(self.stdout, markers) self._last_info = None def info(self, name, doc, start_suite=False): @@ -88,18 +88,18 @@ def info(self, name, doc, start_suite=False): self._keyword_marker.reset_count() def _write_info(self): - self._stdout.write(self._last_info) + self.stdout.write(self._last_info) def _get_info_width_and_separator(self, start_suite): if start_suite: - return self._width, '\n' - return self._width - self._status_length - 1, ' ' + return self.width, '\n' + return self.width - self._status_length - 1, ' ' def _get_info(self, name, doc, width): if get_console_length(name) > width: return pad_console_length(name, width) doc = getshortdoc(doc, linesep=' ') - info = '%s :: %s' % (name, doc) if doc else name + info = f'{name} :: {doc}' if doc else name return pad_console_length(info, width) def suite_separator(self): @@ -109,14 +109,14 @@ def test_separator(self): self._fill('-') def _fill(self, char): - self._stdout.write('%s\n' % (char * self._width)) + self.stdout.write(f'{char * self.width}\n') def status(self, status, clear=False): if self._should_clear_markers(clear): self._clear_status() - self._stdout.write('| ', flush=False) - self._stdout.highlight(status, flush=False) - self._stdout.write(' |\n') + self.stdout.write('| ', flush=False) + self.stdout.highlight(status, flush=False) + self.stdout.write(' |\n') def _should_clear_markers(self, clear): return clear and self._keyword_marker.marking_enabled @@ -126,12 +126,12 @@ def _clear_status(self): self._write_info() def _clear_info(self): - self._stdout.write('\r%s\r' % (' ' * self._width)) + self.stdout.write(f"\r{' ' * self.width}\r") self._keyword_marker.reset_count() def message(self, message): if message: - self._stdout.write(message.strip() + '\n') + self.stdout.write(message.strip() + '\n') def keyword_marker(self, status): if self._keyword_marker.marker_count == self._status_length: @@ -142,18 +142,18 @@ def keyword_marker(self, status): def error(self, message, level, clear=False): if self._should_clear_markers(clear): self._clear_info() - self._stderr.error(message, level) + self.stderr.error(message, level) if self._should_clear_markers(clear): self._write_info() def output(self, name, path): - self._stdout.write('%-8s %s\n' % (name+':', path)) + self.stdout.write(f"{name+':':8} {path}\n") class KeywordMarker: def __init__(self, highlighter, markers): - self._highlighter = highlighter + self.highlighter = highlighter self.marking_enabled = self._marking_enabled(markers, highlighter) self.marker_count = 0 @@ -164,13 +164,13 @@ def _marking_enabled(self, markers, highlighter): try: return options[markers.upper()] except KeyError: - raise DataError("Invalid console marker value '%s'. Available " - "'AUTO', 'ON' and 'OFF'." % markers) + raise DataError(f"Invalid console marker value '{markers}'. " + f"Available 'AUTO', 'ON' and 'OFF'.") def mark(self, status): if self.marking_enabled: marker, status = ('.', 'PASS') if status != 'FAIL' else ('F', 'FAIL') - self._highlighter.highlight(marker, status) + self.highlighter.highlight(marker, status) self.marker_count += 1 def reset_count(self): diff --git a/utest/output/test_console.py b/utest/output/test_console.py index 838276d8ac1..f3f3f5facbc 100644 --- a/utest/output/test_console.py +++ b/utest/output/test_console.py @@ -40,7 +40,7 @@ def test_more_markers_than_fit_into_status_area(self): def test_clear_markers_when_test_status_is_written(self): self._write_marker(count=5) self.console.end_test(Stub()) - self._verify('| PASS |\n%s\n' % ('-'*self.console._writer._width)) + self._verify('| PASS |\n%s\n' % ('-'*self.console.writer.width)) def test_clear_markers_when_there_are_warnings(self): self._write_marker(count=5) diff --git a/utest/output/test_logger.py b/utest/output/test_logger.py index 72a914ab8e1..30398d16e63 100644 --- a/utest/output/test_logger.py +++ b/utest/output/test_logger.py @@ -167,7 +167,7 @@ def test_registering_console_logger_disables_automatic_console_logger(self): logger = Logger() logger.register_console_logger(width=42) self._number_of_registered_loggers_should_be(1, logger) - assert_equal(logger._console_logger.start_suite.__self__._writer._width, 42) + assert_equal(logger._console_logger.start_suite.__self__.writer.width, 42) def test_unregister_logger(self): logger1, logger2, logger3 = LoggerMock(), LoggerMock(), LoggerMock() From 26187023f0a9b8346736976768b4953ab8cf5785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 21:45:41 +0300 Subject: [PATCH 0745/1592] Remove cruft accidentally left over. --- src/robot/result/model.py | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 8c3b1f0b293..dc31372ec94 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -50,7 +50,7 @@ from .configurer import SuiteConfigurer from .messagefilter import MessageFilter -from .modeldeprecation import deprecated, DeprecatedAttributesMixin +from .modeldeprecation import DeprecatedAttributesMixin from .keywordremover import KeywordRemover from .suiteteardownfailed import SuiteTeardownFailed, SuiteTeardownFailureHandler @@ -566,11 +566,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - # FIXME @deprecated - def doc(self) -> str: - return '' - @Body.register class Continue(model.Continue, StatusMixin, DeprecatedAttributesMixin): @@ -601,11 +596,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - # FIXME @deprecated - def doc(self) -> str: - return '' - @Body.register class Break(model.Break, StatusMixin, DeprecatedAttributesMixin): @@ -636,11 +626,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - # FIXME @deprecated - def doc(self) -> str: - return '' - @Body.register class Error(model.Error, StatusMixin, DeprecatedAttributesMixin): @@ -670,11 +655,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - # FIXME @deprecated - def doc(self) -> 'str': - return '' - @property def _name(self): return self.values[0] @@ -765,7 +745,7 @@ def full_name(self) -> 'str|None': # TODO: Deprecate 'kwname', 'libname' and 'sourcename' loudly in RF 8. @property def kwname(self) -> 'str|None': - """Deprecated since Robot Framework 7.0. Use :attr:``name` instead.""" + """Deprecated since Robot Framework 7.0. Use :attr:`name` instead.""" return self.name @kwname.setter @@ -774,7 +754,7 @@ def kwname(self, name: 'str|None'): @property def libname(self) -> 'str|None': - """Deprecated since Robot Framework 7.0. Use :attr:``owner` instead.""" + """Deprecated since Robot Framework 7.0. Use :attr:`owner` instead.""" return self.owner @libname.setter @@ -783,7 +763,7 @@ def libname(self, name: 'str|None'): @property def sourcename(self) -> str: - """Deprecated since Robot Framework 7.0. Use :attr:``source_name` instead.""" + """Deprecated since Robot Framework 7.0. Use :attr:`source_name` instead.""" return self.source_name @sourcename.setter From 56c198cb3d8b68d7648f848e2e0bacdd2cb034a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 5 Oct 2023 22:11:46 +0300 Subject: [PATCH 0746/1592] Add `full_name` to suite/test objects. Old `longname` is still preserved, but it is considered deprecated and will be eventually removed. Fixes #4885. --- src/robot/conf/gatherfailed.py | 4 ++-- src/robot/model/filter.py | 4 ++-- src/robot/model/namepatterns.py | 4 ++-- src/robot/model/stats.py | 2 +- src/robot/model/testcase.py | 11 ++++++++--- src/robot/model/testsuite.py | 18 +++++++++++++----- src/robot/model/visitor.py | 2 +- src/robot/output/console/dotted.py | 2 +- src/robot/output/console/verbose.py | 4 ++-- src/robot/output/debugfile.py | 4 ++-- src/robot/output/listenerarguments.py | 14 ++++++++------ src/robot/reporting/xunitwriter.py | 2 +- src/robot/running/context.py | 2 +- src/robot/running/namespace.py | 4 ++-- src/robot/running/suiterunner.py | 4 ++-- src/robot/testdoc.py | 6 +++--- 16 files changed, 51 insertions(+), 36 deletions(-) diff --git a/src/robot/conf/gatherfailed.py b/src/robot/conf/gatherfailed.py index 24cf30d7a67..1ffd8aa906b 100644 --- a/src/robot/conf/gatherfailed.py +++ b/src/robot/conf/gatherfailed.py @@ -26,7 +26,7 @@ def __init__(self): def visit_test(self, test): if test.failed: - self.tests.append(glob_escape(test.longname)) + self.tests.append(glob_escape(test.full_name)) def visit_keyword(self, kw): pass @@ -39,7 +39,7 @@ def __init__(self): def start_suite(self, suite): if any(test.failed for test in suite.tests): - self.suites.append(glob_escape(suite.longname)) + self.suites.append(glob_escape(suite.full_name)) def visit_test(self, test): pass diff --git a/src/robot/model/filter.py b/src/robot/model/filter.py index 3578acbe422..6f7b83181b4 100644 --- a/src/robot/model/filter.py +++ b/src/robot/model/filter.py @@ -88,7 +88,7 @@ def start_suite(self, suite: 'TestSuite'): return bool(suite.suites) def _filter_based_on_suite_name(self, suite: 'TestSuite') -> bool: - if self.include_suites.match(suite.name, suite.longname): + if self.include_suites.match(suite.name, suite.full_name): suite.visit(Filter(include_tests=self.include_tests, include_tags=self.include_tags, exclude_tags=self.exclude_tags)) @@ -103,7 +103,7 @@ def _test_included(self, test: 'TestCase') -> bool: return False if include is not None and include.match(test.tags): return True - if tests is not None and tests.match(test.name, test.longname): + if tests is not None and tests.match(test.name, test.full_name): return True return include is None and tests is None diff --git a/src/robot/model/namepatterns.py b/src/robot/model/namepatterns.py index 2c1cf297d79..f059f92bb80 100644 --- a/src/robot/model/namepatterns.py +++ b/src/robot/model/namepatterns.py @@ -23,9 +23,9 @@ class NamePatterns(Iterable[str]): def __init__(self, patterns: Sequence[str] = (), ignore: Sequence[str] = '_'): self.matcher = MultiMatcher(patterns, ignore) - def match(self, name: str, longname: 'str|None' = None) -> bool: + def match(self, name: str, full_name: 'str|None' = None) -> bool: match = self.matcher.match - return bool(match(name) or longname and match(longname)) + return bool(match(name) or full_name and match(full_name)) def __bool__(self) -> bool: return bool(self.matcher) diff --git a/src/robot/model/stats.py b/src/robot/model/stats.py index f7b0aac427a..04ad694b198 100644 --- a/src/robot/model/stats.py +++ b/src/robot/model/stats.py @@ -103,7 +103,7 @@ class SuiteStat(Stat): type = 'suite' def __init__(self, suite): - super().__init__(suite.longname) + super().__init__(suite.full_name) self.id = suite.id self.elapsed = suite.elapsed_time self._name = suite.name diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index 8b91dead6d8..8950b375318 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -157,11 +157,16 @@ def id(self) -> str: return f'{self.parent.id}-t{index + 1}' @property - def longname(self) -> str: - """Test name prefixed with the long name of the parent suite.""" + def full_name(self) -> str: + """Test name prefixed with the full name of the parent suite.""" if not self.parent: return self.name - return f'{self.parent.longname}.{self.name}' + return f'{self.parent.full_name}.{self.name}' + + @property + def longname(self) -> str: + """Deprecated since Robot Framework 7.0. Use :attr:`full_name` instead.""" + return self.full_name @property def source(self) -> 'Path|None': diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index bee93933632..488048a296c 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -191,11 +191,19 @@ def adjust_source(self, relative_to: 'Path|str|None' = None, suite.adjust_source(relative_to, root) @property - def longname(self) -> str: - """Suite name prefixed with the long name of the parent suite.""" + def full_name(self) -> str: + """Suite name prefixed with the full name of the possible parent suite. + + Just :attr:`name` of the suite if it has no :attr:`parent`. + """ if not self.parent: return self.name - return f'{self.parent.longname}.{self.name}' + return f'{self.parent.full_name}.{self.name}' + + @property + def longname(self) -> str: + """Deprecated since Robot Framework 7.0. Use :attr:`full_name` instead.""" + return self.full_name @setter def metadata(self, metadata: 'Mapping[str, str]|None') -> Metadata: @@ -216,12 +224,12 @@ def validate_execution_mode(self) -> 'bool|None': suite.validate_execution_mode() if rpa is None: rpa = suite.rpa - name = suite.longname + name = suite.full_name elif rpa is not suite.rpa: mode1, mode2 = ('tasks', 'tests') if rpa else ('tests', 'tasks') raise DataError( f"Conflicting execution modes: Suite '{name}' has {mode1} but " - f"suite '{suite.longname}' has {mode2}. Resolve the conflict " + f"suite '{suite.full_name}' has {mode2}. Resolve the conflict " f"or use '--rpa' or '--norpa' options to set the execution " f"mode explicitly." ) diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 4c0119a92de..79418d05490 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -92,7 +92,7 @@ class FailurePrinter(SuiteVisitor): def start_suite(self, suite: TestSuite): - print(f"{suite.longname}: {suite.statistics.failed} failed") + print(f"{suite.full_name}: {suite.statistics.failed} failed") def visit_test(self, test: TestCase): if test.failed: diff --git a/src/robot/output/console/dotted.py b/src/robot/output/console/dotted.py index 753765364ee..11e3ef5515c 100644 --- a/src/robot/output/console/dotted.py +++ b/src/robot/output/console/dotted.py @@ -86,4 +86,4 @@ def visit_test(self, test: TestCase): if test.failed and not test.tags.robot('exit'): self.stream.write('-' * self.width + '\n') self.stream.highlight('FAIL') - self.stream.write(f': {test.longname}\n{test.message.strip()}\n') + self.stream.write(f': {test.full_name}\n{test.message.strip()}\n') diff --git a/src/robot/output/console/verbose.py b/src/robot/output/console/verbose.py index 048efdc1be1..984a7b74bf8 100644 --- a/src/robot/output/console/verbose.py +++ b/src/robot/output/console/verbose.py @@ -35,11 +35,11 @@ def start_suite(self, suite: TestSuite): if not self.started: self.writer.suite_separator() self.started = True - self.writer.info(suite.longname, suite.doc, start_suite=True) + self.writer.info(suite.full_name, suite.doc, start_suite=True) self.writer.suite_separator() def end_suite(self, suite: TestSuite): - self.writer.info(suite.longname, suite.doc) + self.writer.info(suite.full_name, suite.doc) self.writer.status(suite.status) self.writer.message(suite.full_message) self.writer.suite_separator() diff --git a/src/robot/output/debugfile.py b/src/robot/output/debugfile.py index 40597b4a262..559c5c77c69 100644 --- a/src/robot/output/debugfile.py +++ b/src/robot/output/debugfile.py @@ -46,12 +46,12 @@ def __init__(self, outfile): def start_suite(self, suite): self._separator('SUITE') - self._start('SUITE', suite.longname, suite.start_time) + self._start('SUITE', suite.full_name, suite.start_time) self._separator('SUITE') def end_suite(self, suite): self._separator('SUITE') - self._end('SUITE', suite.longname, suite.end_time, suite.elapsed_time) + self._end('SUITE', suite.full_name, suite.end_time, suite.elapsed_time) self._separator('SUITE') if self._indent == 0: LOGGER.output_file('Debug', self._outfile.name) diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 0ee68a8b2e6..12bb3750217 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -101,17 +101,18 @@ def _get_version3_arguments(self, item): class StartSuiteArguments(_ListenerArgumentsFromItem): - _attribute_names = ('id', 'longname', 'doc', 'metadata', 'starttime') + _attribute_names = ('id', 'doc', 'metadata', 'starttime') def _get_extra_attributes(self, suite): - return {'tests': [t.name for t in suite.tests], + return {'longname': suite.full_name, + 'tests': [t.name for t in suite.tests], 'suites': [s.name for s in suite.suites], 'totaltests': suite.test_count, 'source': str(suite.source or '')} class EndSuiteArguments(StartSuiteArguments): - _attribute_names = ('id', 'longname', 'doc', 'metadata', 'starttime', + _attribute_names = ('id', 'doc', 'metadata', 'starttime', 'endtime', 'elapsedtime', 'status', 'message') def _get_extra_attributes(self, suite): @@ -121,16 +122,17 @@ def _get_extra_attributes(self, suite): class StartTestArguments(_ListenerArgumentsFromItem): - _attribute_names = ('id', 'longname', 'doc', 'tags', 'lineno', 'starttime') + _attribute_names = ('id', 'doc', 'tags', 'lineno', 'starttime') def _get_extra_attributes(self, test): - return {'source': str(test.source or ''), + return {'longname': test.full_name, + 'source': str(test.source or ''), 'template': test.template or '', 'originalname': test.data.name} class EndTestArguments(StartTestArguments): - _attribute_names = ('id', 'longname', 'doc', 'tags', 'lineno', 'starttime', + _attribute_names = ('id', 'doc', 'tags', 'lineno', 'starttime', 'endtime', 'elapsedtime', 'status', 'message') diff --git a/src/robot/reporting/xunitwriter.py b/src/robot/reporting/xunitwriter.py index a8a48369995..903c74dfca3 100644 --- a/src/robot/reporting/xunitwriter.py +++ b/src/robot/reporting/xunitwriter.py @@ -63,7 +63,7 @@ def end_suite(self, suite: TestSuite): def visit_test(self, test: TestCase): self._writer.start('testcase', - {'classname': test.parent.longname, + {'classname': test.parent.full_name, 'name': test.name, 'time': format(test.elapsed_time.total_seconds(), '.3f')}) if test.failed: diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 7119000c45c..581c10382f7 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -197,7 +197,7 @@ def end_suite(self, suite): EXECUTION_CONTEXTS.end_suite() def set_suite_variables(self, suite): - self.variables['${SUITE_NAME}'] = suite.longname + self.variables['${SUITE_NAME}'] = suite.full_name self.variables['${SUITE_SOURCE}'] = str(suite.source or '') self.variables['${SUITE_DOCUMENTATION}'] = suite.doc self.variables['${SUITE_METADATA}'] = suite.metadata.copy() diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 8bfb4fefb89..82a6c675132 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -40,13 +40,13 @@ class Namespace: _variables_import_by_path_ends = _library_import_by_path_ends + ('.yaml', '.yml') + ('.json',) def __init__(self, variables, suite, resource, languages): - LOGGER.info(f"Initializing namespace for suite '{suite.longname}'.") + LOGGER.info(f"Initializing namespace for suite '{suite.full_name}'.") self.variables = variables self.languages = languages self._imports = resource.imports self._kw_store = KeywordStore(resource, languages) self._imported_variable_files = ImportCache() - self._suite_name = suite.longname + self._suite_name = suite.full_name self._running_test = False @property diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index 51de7b13c9c..ec4c50ddad8 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -48,7 +48,7 @@ def _context(self): def start_suite(self, suite): if suite.name in self._executed[-1] and suite.parent.source: self._output.warn(f"Multiple suites with name '{suite.name}' executed in " - f"suite '{suite.parent.longname}'.") + f"suite '{suite.parent.full_name}'.") self._executed[-1][suite.name] = True self._executed.append(NormalizedDict(ignore='_')) self._output.library_listeners.new_suite_scope() @@ -130,7 +130,7 @@ def visit_test(self, test): if test.name in self._executed[-1]: self._output.warn( test_or_task(f"Multiple {{test}}s with name '{test.name}' executed in " - f"suite '{test.parent.longname}'.", settings.rpa)) + f"suite '{test.parent.full_name}'.", settings.rpa)) self._executed[-1][test.name] = True result = self._suite.tests.create(self._resolve_setting(test.name), self._resolve_setting(test.doc), diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index 97a0014c6fa..5a4dce5aac8 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -171,11 +171,11 @@ def _convert_suite(self, suite): 'relativeSource': self._get_relative_source(suite.source), 'id': suite.id, 'name': self._escape(suite.name), - 'fullName': self._escape(suite.longname), + 'fullName': self._escape(suite.full_name), 'doc': self._html(suite.doc), 'metadata': [(self._escape(name), self._html(value)) for name, value in suite.metadata.items()], - 'numberOfTests': suite.test_count , + 'numberOfTests': suite.test_count, 'suites': self._convert_suites(suite), 'tests': self._convert_tests(suite), 'keywords': list(self._convert_keywords((suite.setup, suite.teardown))) @@ -205,7 +205,7 @@ def _convert_test(self, test): test.body.append(test.teardown) return { 'name': self._escape(test.name), - 'fullName': self._escape(test.longname), + 'fullName': self._escape(test.full_name), 'id': test.id, 'doc': self._html(test.doc), 'tags': [self._escape(t) for t in test.tags], From 86fb0174acb8bbb58821924860afba0b9b040931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 6 Oct 2023 11:52:20 +0300 Subject: [PATCH 0747/1592] Atest infra enhancements. 1. Make sure all body items have `kws` and `msgs`. It's a bit questionable should we use them at all, but as long as we do, they should be used consistently. 2. Remove `keyword_count` and `message_count` attributes and their shorter versions `kw_count` and `msg_count`. They aren't really needed because `Length Should Be` and `Should Be Empty` can be used instead. Update data accordingly. The former change broke some tests related to TRY. The reason was that TRY didn't have `kws` earlier and as the result TRY structures weren't properly tested by `All Keywords Should Have Passed`. TRY structures had some expected failures, handled by EXCEPTs, so that keyword needed enhancements as well. --- atest/resources/TestCheckerLibrary.py | 58 ++++++------------- atest/resources/atest_resource.robot | 23 ++++---- atest/robot/cli/runner/log_level.robot | 44 +++++++------- .../running/for/break_and_continue.robot | 16 ++--- .../builtin/log_variables.robot | 8 +-- .../builtin/repeat_keyword.robot | 12 ++-- .../builtin/run_keyword_if_unless.robot | 4 +- .../running/for/break_and_continue.robot | 10 ++-- 8 files changed, 76 insertions(+), 99 deletions(-) diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index 67121c7eea1..d62f661b020 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -5,12 +5,13 @@ from robot import utils from robot.api import logger -from robot.utils.asserts import assert_equal -from robot.result import (ExecutionResultBuilder, Result, ResultVisitor, - For, ForIteration, While, WhileIteration, Return, Break, Continue, - If, IfBranch, Try, TryBranch, Keyword, TestCase, TestSuite) -from robot.result.model import Body, Iterations from robot.libraries.BuiltIn import BuiltIn +from robot.result import (Break, Continue, Error, ExecutionResultBuilder, For, + ForIteration, If, IfBranch, Keyword, Result, ResultVisitor, + Return, TestCase, TestSuite, Try, TryBranch, While, + WhileIteration) +from robot.result.model import Body, Iterations +from robot.utils.asserts import assert_equal class NoSlotsKeyword(Keyword): @@ -45,6 +46,10 @@ class NoSlotsContinue(Continue): pass +class NoSlotsError(Error): + pass + + class NoSlotsBody(Body): keyword_class = NoSlotsKeyword for_class = NoSlotsFor @@ -54,6 +59,7 @@ class NoSlotsBody(Body): return_class = NoSlotsReturn break_class = NoSlotsBreak continue_class = NoSlotsContinue + error_class = NoSlotsError class NoSlotsIfBranch(IfBranch): @@ -77,8 +83,8 @@ class NoSlotsIterations(Iterations): NoSlotsKeyword.body_class = NoSlotsReturn.body_class = NoSlotsBreak.body_class \ - = NoSlotsContinue.body_class = NoSlotsBody -NoSlotsFor.iterations_class = NoSlotsWhile.iterations_class =NoSlotsIterations + = NoSlotsContinue.body_class = NoSlotsError.body_class = NoSlotsBody +NoSlotsFor.iterations_class = NoSlotsWhile.iterations_class = NoSlotsIterations NoSlotsFor.iteration_class = NoSlotsForIteration NoSlotsWhile.iteration_class = NoSlotsWhileIteration NoSlotsIf.branch_class = NoSlotsIfBranch @@ -340,43 +346,17 @@ def start_test(self, test): test.exp_status = 'PASS' test.exp_message = '' test.kws = list(test.body) - test.keyword_count = test.kw_count = len(test.kws) - def start_keyword(self, kw): - self._add_kws_and_msgs(kw) - - def _add_kws_and_msgs(self, item): - # TODO: Consider not setting these special attributes: - # - Using normal `body` instead of special `kws` in tests would be better. - # - `msgs` isn't that much shorter than normal `messages`. - # - Counts likely not needed often enough. There are other ways to get them. - # - No need to construct "NoSlots" variants for all model objects. + def start_body_item(self, item): + # TODO: Consider not setting these attributes to avoid "NoSlots" variants. + # - Using normal `body` and `messages` would in general be cleaner. + # - If `kws` is preserved, it should only contain keywords, not controls. + # - `msgs` isn't that much shorter than `messages`. item.kws = item.body.filter(messages=False) item.msgs = item.body.filter(messages=True) - item.keyword_count = item.kw_count = len(item.kws) - item.message_count = item.msg_count = len(item.msgs) - - def start_for(self, for_): - self._add_kws_and_msgs(for_) - - def start_for_iteration(self, iteration): - self._add_kws_and_msgs(iteration) - - def start_if(self, if_): - self._add_kws_and_msgs(if_) - - def start_if_branch(self, branch): - self._add_kws_and_msgs(branch) - - def start_while(self, while_): - self._add_kws_and_msgs(while_) - - def start_while_iteration(self, iteration): - self._add_kws_and_msgs(iteration) - def visit_error(self, error): + def visit_message(self, message): pass def visit_errors(self, errors): errors.msgs = errors.messages - errors.message_count = errors.msg_count = len(errors.messages) diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index 5e0439251cf..380cf69cf2a 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -131,22 +131,21 @@ Check TRY Data Should Be Equal ${try.status} ${status} Test And All Keywords Should Have Passed - [Arguments] ${name}=${TESTNAME} ${allow not run}=False + [Arguments] ${name}=${TESTNAME} ${allow not run}=False ${allowed failure}= ${tc} = Check Test Case ${name} - All Keywords Should Have Passed ${tc} ${allow not run} + All Keywords Should Have Passed ${tc} ${allow not run} ${allowed failure} All Keywords Should Have Passed - [Arguments] ${tc_or_kw} ${allow not run}=False - IF hasattr($tc_or_kw, 'kws') - FOR ${index} ${kw} IN ENUMERATE @{tc_or_kw.kws} - IF ${allow not run} and (${index} > 0 or $kw.type in ['IF', 'ELSE', 'EXCEPT', 'BREAK']) - Should Be True $kw.status in ['PASS', 'NOT RUN'] - ELSE - Log ${kw.type} - Should Be Equal ${kw.status} PASS - END - All Keywords Should Have Passed ${kw} ${allow not run} + [Arguments] ${tc_or_kw} ${allow not run}=False ${allowed failure}= + FOR ${index} ${item} IN ENUMERATE @{tc_or_kw.body.filter(messages=False)} + IF $item.failed and not ($item.message == $allowed_failure) + Fail ${item.type} failed: ${item.message} + ELSE IF $item.not_run and not $allow_not_run + Fail ${item.type} was not run. + ELSE IF $item.skipped + Fail ${item.type} was skipped. END + All Keywords Should Have Passed ${item} ${allow not run} ${allowed failure} END Get Output File diff --git a/atest/robot/cli/runner/log_level.robot b/atest/robot/cli/runner/log_level.robot index 8f11cb8a8a7..b6033bcc6d7 100644 --- a/atest/robot/cli/runner/log_level.robot +++ b/atest/robot/cli/runner/log_level.robot @@ -8,11 +8,11 @@ ${LOG NAME} logfile.html *** Test Cases *** No Log Level Given - [Documentation] Default level of INFO should be used - Run Tests ${EMPTY} ${TESTDATA} - Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! INFO - Should Be Equal As Integers ${SUITE.tests[0].kws[0].kws[1].message_count} 0 - Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL + [Documentation] Default level of INFO should be used + Run Tests ${EMPTY} ${TESTDATA} + Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! INFO + Should Be Empty ${SUITE.tests[0].kws[0].kws[1].messages} + Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL Trace Level Run Tests --loglevel TRACE ${TESTDATA} @@ -34,38 +34,38 @@ Trace Level With Default Debug Min level should be 'TRACE' and default 'DEBUG' Info Level - Run Tests -L InFo ${TESTDATA} - Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! INFO - Should Be Equal As Integers ${SUITE.tests[0].kws[0].kws[1].message_count} 0 - Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL + Run Tests -L InFo ${TESTDATA} + Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! INFO + Should Be Empty ${SUITE.tests[0].kws[0].kws[1].messages} + Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL Warn Level - Run Tests --loglevel WARN --variable LEVEL1:WARN --variable LEVEL2:INFO ${TESTDATA} - Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! WARN - Should Be Equal As Integers ${SUITE.tests[0].kws[0].kws[1].message_count} 0 - Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL + Run Tests --loglevel WARN --variable LEVEL1:WARN --variable LEVEL2:INFO ${TESTDATA} + Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! WARN + Should Be Empty ${SUITE.tests[0].kws[0].kws[1].messages} + Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL Warnings Should Be Written To Syslog Should Be Equal ${PREV TEST NAME} Warn Level Check Log Message ${ERRORS.msgs[0]} Hello says "Suite Setup"! WARN Check Log Message ${ERRORS.msgs[1]} Hello says "Pass"! WARN Check Log Message ${ERRORS.msgs[2]} Hello says "Fail"! WARN - Should Be True ${ERRORS.msg_count} == 3 + Length Should Be ${ERRORS.msgs} 3 Syslog Should Contain | WARN \ | Hello says "Suite Setup"! Syslog Should Contain | WARN \ | Hello says "Pass"! Syslog Should Contain | WARN \ | Hello says "Fail"! Error Level - Run Tests --loglevel ERROR --variable LEVEL1:ERROR --variable LEVEL2:WARN ${TESTDATA} - Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! ERROR - Should Be Equal As Integers ${SUITE.tests[0].kws[0].kws[1].message_count} 0 - Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL + Run Tests --loglevel ERROR --variable LEVEL1:ERROR --variable LEVEL2:WARN ${TESTDATA} + Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! ERROR + Should Be Empty ${SUITE.tests[0].kws[0].kws[1].messages} + Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL None Level - Run Tests --loglevel NONE --log ${LOG NAME} --variable LEVEL1:ERROR --variable LEVEL2:WARN ${TESTDATA} - Should Be Equal As Integers ${SUITE.tests[0].kws[0].kws[0].message_count} 0 - Should Be Equal As Integers ${SUITE.tests[0].kws[0].kws[1].message_count} 0 - Should Be Equal As Integers ${SUITE.tests[1].kws[1].message_count} 0 + Run Tests --loglevel NONE --log ${LOG NAME} --variable LEVEL1:ERROR --variable LEVEL2:WARN ${TESTDATA} + Should Be Empty ${SUITE.tests[0].kws[0].kws[0].messages} + Should Be Empty ${SUITE.tests[0].kws[0].kws[1].messages} + Should Be Empty ${SUITE.tests[1].kws[1].messages} Min level should be 'NONE' and default 'NONE' *** Keywords *** diff --git a/atest/robot/running/for/break_and_continue.robot b/atest/robot/running/for/break_and_continue.robot index 262fdc5bf8d..5c6ac36c869 100644 --- a/atest/robot/running/for/break_and_continue.robot +++ b/atest/robot/running/for/break_and_continue.robot @@ -8,15 +8,13 @@ With CONTINUE allow not run=True With CONTINUE inside IF - [Template] None - ${tc}= Check test case ${TEST NAME} - Should be FOR loop ${tc.body[0]} 5 FAIL IN RANGE + allow not run=True allowed failure=Oh no, got 4 With CONTINUE inside TRY allow not run=True With CONTINUE inside EXCEPT and TRY-ELSE - allow not run=True + allow not run=True allowed failure=4 == 4 With BREAK allow not run=True @@ -28,7 +26,7 @@ With BREAK inside TRY allow not run=True With BREAK inside EXCEPT - allow not run=True + allow not run=True allowed failure=This is excepted! With BREAK inside TRY-ELSE allow not run=True @@ -37,15 +35,13 @@ With CONTINUE in UK allow not run=True With CONTINUE inside IF in UK - [Template] None - ${tc}= Check test case ${TEST NAME} - Should be FOR loop ${tc.body[0].body[0]} 5 FAIL IN RANGE + allow not run=True allowed failure=Oh no, got 4 With CONTINUE inside TRY in UK allow not run=True With CONTINUE inside EXCEPT and TRY-ELSE in UK - allow not run=True + allow not run=True allowed failure=4 == 4 With BREAK in UK allow not run=True @@ -57,7 +53,7 @@ With BREAK inside TRY in UK allow not run=True With BREAK inside EXCEPT in UK - allow not run=True + allow not run=True allowed failure=This is excepted! With BREAK inside TRY-ELSE in UK allow not run=True diff --git a/atest/robot/standard_libraries/builtin/log_variables.robot b/atest/robot/standard_libraries/builtin/log_variables.robot index 5da8636f347..63007b6c012 100644 --- a/atest/robot/standard_libraries/builtin/log_variables.robot +++ b/atest/robot/standard_libraries/builtin/log_variables.robot @@ -42,7 +42,7 @@ Log Variables In Suite Setup Check Variable Message \${SUITE_SOURCE} = * pattern=yes Check Variable Message \${TEMPDIR} = * pattern=yes Check Variable Message \${True} = * pattern=yes - Should Be Equal As Integers ${kw.message_count} 35 Wrong total message count + Length Should Be ${kw.messages} 35 Log Variables In Test ${test} = Check Test Case Log Variables @@ -85,7 +85,7 @@ Log Variables In Test Check Variable Message \${TEST_NAME} = Log Variables Check Variable Message \@{TEST_TAGS} = [ ] Check Variable Message \${True} = * pattern=yes - Should Be Equal As Integers ${kw.message_count} 39 Wrong total message count + Length Should Be ${kw.messages} 39 Log Variables After Setting New Variables ${test} = Check Test Case Log Variables @@ -131,7 +131,7 @@ Log Variables After Setting New Variables Check Variable Message \@{TEST_TAGS} = [ ] DEBUG Check Variable Message \${True} = * DEBUG pattern=yes Check Variable Message \${var} = Hello DEBUG - Should Be Equal As Integers ${kw.message_count} 42 Wrong total message count + Length Should Be ${kw.messages} 42 Log Variables In User Keyword ${test} = Check Test Case Log Variables @@ -175,7 +175,7 @@ Log Variables In User Keyword Check Variable Message \@{TEST_TAGS} = [ ] Check Variable Message \${True} = * pattern=yes Check Variable Message \${ukvar} = Value of an uk variable - Should Be Equal As Integers ${kw.message_count} 40 Wrong total message count + Length Should Be ${kw.messages} 40 List and dict variables failing during iteration Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/repeat_keyword.robot b/atest/robot/standard_libraries/builtin/repeat_keyword.robot index 289eb94822e..5c0e3ea1e9e 100644 --- a/atest/robot/standard_libraries/builtin/repeat_keyword.robot +++ b/atest/robot/standard_libraries/builtin/repeat_keyword.robot @@ -73,30 +73,30 @@ Repeat Keyword With Pass Execution After Continuable Failure *** Keywords *** Check Repeated Messages [Arguments] ${kw} ${count} ${msg}= ${name}= - Should Be Equal As Integers ${kw.kw_count} ${count} + Length Should Be ${kw.kws} ${count} FOR ${i} IN RANGE ${count} Check Log Message ${kw.msgs[${i}]} Repeating keyword, round ${i+1}/${count}. Check Log Message ${kw.kws[${i}].msgs[0]} ${msg} END IF ${count} != 0 - Should Be Equal As Integers ${kw.msg_count} ${count} + Length Should Be ${kw.msgs} ${count} ELSE Check Log Message ${kw.msgs[0]} Keyword '${name}' repeated zero times. END Check Repeated Messages With Time [Arguments] ${kw} ${msg}=${None} - Should Be True ${kw.kw_count} > 0 - FOR ${i} IN RANGE ${kw.kw_count} + Should Not Be Empty ${kw.kws} + FOR ${i} IN RANGE ${{len($kw.kws)}} Check Log Message ${kw.msgs[${i}]} ... Repeating keyword, round ${i+1}, *remaining. pattern=yes Check Log Message ${kw.kws[${i}].msgs[0]} ${msg} END - Should Be Equal As Integers ${kw.msg_count} ${kw.kw_count} + Should Be Equal ${{len($kw.msgs)}} ${{len($kw.kws)}} Check Repeated Keyword Name [Arguments] ${kw} ${count} ${name}=${None} - Should Be Equal As Integers ${kw.kw_count} ${count} + Length Should Be ${kw.kws} ${count} FOR ${i} IN RANGE ${count} Should Be Equal ${kw.kws[${i}].full_name} ${name} END diff --git a/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot b/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot index 9a4f0867aab..07bc9abe674 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot @@ -12,12 +12,12 @@ Run Keyword If With True Expression Run Keyword If With False Expression ${tc} = Check Test Case ${TEST NAME} - Should Be Equal As Integers ${tc.body[0].keyword_count} 0 + Should Be Empty ${tc.body[0].body} Run Keyword In User Keyword ${tc} = Check Test Case ${TEST NAME} Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} ${EXECUTED} - Should Be Equal As Integers ${tc.body[1].body[0].keyword_count} 0 + Should Be Empty ${tc.body[1].body[0].body} Run Keyword With ELSE ${tc} = Check Test Case ${TEST NAME} diff --git a/atest/testdata/running/for/break_and_continue.robot b/atest/testdata/running/for/break_and_continue.robot index f06de5ca84e..3029546806f 100644 --- a/atest/testdata/running/for/break_and_continue.robot +++ b/atest/testdata/running/for/break_and_continue.robot @@ -31,8 +31,9 @@ With CONTINUE inside TRY With CONTINUE inside EXCEPT and TRY-ELSE FOR ${i} IN RANGE 6 TRY - Should not be equal ${variable} ${4} - EXCEPT + Should not be equal ${i} ${4} + EXCEPT AS ${error} + Log ${error} CONTINUE ELSE CONTINUE @@ -156,8 +157,9 @@ With CONTINUE inside TRY in UK With CONTINUE inside EXCEPT and TRY-ELSE in UK FOR ${i} IN RANGE 6 TRY - Should not be equal ${variable} ${4} - EXCEPT + Should not be equal ${i} ${4} + EXCEPT AS ${error} + Log ${error} CONTINUE ELSE CONTINUE From 2756f4a86c33d5c9012449ef0c5ca18a304eecbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 6 Oct 2023 12:03:23 +0300 Subject: [PATCH 0748/1592] Avoid `[Return]` that is going to be deprecated. `[Return]` usages that actually test the setting were left, but elsewhere it was replaced with `RETURN`. Deprecation itself is covered by #4876. --- atest/resources/atest_resource.robot | 34 +++++++++---------- .../robot/cli/console/console_resource.robot | 2 +- atest/robot/cli/console/max_error_lines.robot | 2 +- atest/robot/cli/rebot/log_level.robot | 2 +- .../robot/cli/rebot/rebot_cli_resource.robot | 2 +- atest/robot/cli/runner/cli_resource.robot | 4 +-- atest/robot/output/js_model.robot | 2 +- .../listener_resource.robot | 2 +- atest/robot/running/flatten.robot | 1 + atest/robot/running/long_error_messages.robot | 2 +- .../standard_libraries/builtin/tags.robot | 4 +-- .../process/passing_arguments.robot | 4 +-- .../remote/remote_resource.robot | 4 +-- .../error_msg_and_details.robot | 2 +- .../robot/test_libraries/print_logging.robot | 2 +- .../variables/recursive_definition.robot | 2 +- .../cli/console/max_error_lines.robot | 2 +- atest/testdata/cli/dryrun/dryrun.robot | 4 +-- .../remove_keywords/all_combinations.robot | 4 +-- .../__init__.robot | 2 +- .../sub_suite_with_init_file/__INIT__.robot | 2 +- .../named_args/variables_in_names.robot | 2 +- .../named_args/with_user_keywords.robot | 18 +++++----- .../named_only_args/user_keyword.robot | 14 ++++---- .../resources/embedded_args_in_uk_1.robot | 2 +- .../keywords/trace_log_return_value.robot | 2 +- .../keywords/user_keyword_arguments.robot | 32 ++++++++--------- .../keywords/user_keyword_kwargs.robot | 6 ++-- atest/testdata/parsing/escaping.robot | 2 +- atest/testdata/parsing/utf8_data.robot | 2 +- atest/testdata/parsing/utf8_data.tsv | 2 +- .../running/continue_on_failure.robot | 4 +-- atest/testdata/running/flatten.robot | 7 ++-- .../running/long_error_messages.robot | 2 +- atest/testdata/running/timeouts.robot | 4 +-- .../builtin/run_keyword_with_errors.robot | 2 +- .../set_resource_search_order/resource2.robot | 2 +- .../builtin/wait_until_keyword_succeeds.robot | 2 +- .../process/env_config.robot | 2 +- .../process/output_encoding.robot | 2 +- .../process/process_resource.robot | 10 +++--- .../process/sending_signal.robot | 2 +- .../process/stdout_and_stderr.robot | 2 +- .../telnet/telnet_resource.robot | 2 +- .../telnet/terminal_emulation.robot | 2 +- .../xml/etree_namespaces.robot | 4 +-- .../standard_libraries/xml/xml_resource.robot | 6 ++-- .../variables/environment_variables.robot | 2 +- .../testdata/variables/variable_scopes.robot | 4 +-- 49 files changed, 115 insertions(+), 113 deletions(-) diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index 380cf69cf2a..0706162498d 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -41,14 +41,14 @@ Run Tests ${result} = Execute ${INTERPRETER.runner} ${options} ${sources} ${default options} Log Many RC: ${result.rc} STDERR:\n${result.stderr} STDOUT:\n${result.stdout} Process Output ${output} validate=${validate output} - [Return] ${result} + RETURN ${result} Run Tests Without Processing Output [Arguments] ${options}= ${sources}= ${default options}=${RUNNER DEFAULTS} [Documentation] *OUTDIR:* file://${OUTDIR} (regenerated for every run) ${result} = Execute ${INTERPRETER.runner} ${options} ${sources} ${default options} Log Many RC: ${result.rc} STDERR:\n${result.stderr} STDOUT:\n${result.stdout} - [Return] ${result} + RETURN ${result} Run Rebot [Arguments] ${options}= ${sources}= ${default options}=${COMMON DEFAULTS} ${output}=${OUTFILE} ${validate output}=None @@ -56,14 +56,14 @@ Run Rebot ${result} = Execute ${INTERPRETER.rebot} ${options} ${sources} ${default options} Log Many RC: ${result.rc} STDERR:\n${result.stderr} STDOUT:\n${result.stdout} Process Output ${output} validate=${validate output} - [Return] ${result} + RETURN ${result} Run Rebot Without Processing Output [Arguments] ${options}= ${sources}= ${default options}=${COMMON DEFAULTS} [Documentation] *OUTDIR:* file://${OUTDIR} (regenerated for every run) ${result} = Execute ${INTERPRETER.rebot} ${options} ${sources} ${default options} Log Many RC: ${result.rc} STDERR:\n${result.stderr} STDOUT:\n${result.stdout} - [Return] ${result} + RETURN ${result} Execute [Arguments] ${executor} ${options} ${sources} ${default options}= @@ -72,14 +72,14 @@ Execute ${result} = Run Process @{executor} @{arguments} ... stdout=${STDOUTFILE} stderr=${STDERRFILE} output_encoding=SYSTEM ... timeout=5min on_timeout=terminate - [Return] ${result} + RETURN ${result} Get Execution Arguments [Arguments] ${options} ${sources} ${default options} @{options} = Split command line --outputdir ${OUTDIR} ${default options} ${options} @{sources} = Split command line ${sources} @{sources} = Join Paths ${DATADIR} @{sources} - [Return] @{options} @{sources} + RETURN @{options} @{sources} Set Execution Environment Remove Directory ${OUTDIR} recursive @@ -98,20 +98,20 @@ Check Test Suite IF $status is not None ... Should Be Equal ${suite.status} ${status} Should Be Equal ${suite.full_message} ${message} - [Return] ${suite} + RETURN ${suite} Check Test Doc [Arguments] ${name} @{expected} ${tc} = Check Test Case ${name} ${expected} = Catenate @{expected} Should Be Equal ${tc.doc} ${expected} - [Return] ${tc} + RETURN ${tc} Check Test Tags [Arguments] ${name} @{expected} ${tc} = Check Test Case ${name} Should Contain Tags ${tc} @{expected} - [Return] ${tc} + RETURN ${tc} Check Keyword Data [Arguments] ${kw} ${name} ${assign}= ${args}= ${status}=PASS ${tags}= ${type}=KEYWORD @@ -153,7 +153,7 @@ Get Output File [Documentation] Output encoding avare helper ${encoding} = Set Variable If r'${path}' in [r'${STDERR FILE}', r'${STDOUT FILE}'] SYSTEM UTF-8 ${file} = Get File ${path} ${encoding} - [Return] ${file} + RETURN ${file} File Should Contain [Arguments] ${path} @{expected} ${count}=None @@ -263,15 +263,15 @@ Stdout Should Contain Regexp Get Syslog ${file} = Get Output File ${SYSLOG_FILE} - [Return] ${file} + RETURN ${file} Get Stderr ${file} = Get Output File ${STDERR_FILE} - [Return] ${file} + RETURN ${file} Get Stdout ${file} = Get Output File ${STDOUT_FILE} - [Return] ${file} + RETURN ${file} Syslog Should Contain Match [Arguments] @{expected} @@ -340,22 +340,22 @@ Previous test should have passed Get Stat Nodes [Arguments] ${type} ${output}=${OUTFILE} ${nodes} = Get Elements ${output} statistics/${type}/stat - [Return] ${nodes} + RETURN ${nodes} Get Tag Stat Nodes [Arguments] ${output}=${OUTFILE} ${nodes} = Get Stat Nodes tag ${output} - [Return] ${nodes} + RETURN ${nodes} Get Total Stat Nodes [Arguments] ${output}=${OUTFILE} ${nodes} = Get Stat Nodes total ${output} - [Return] ${nodes} + RETURN ${nodes} Get Suite Stat Nodes [Arguments] ${output}=${OUTFILE} ${nodes} = Get Stat Nodes suite ${output} - [Return] ${nodes} + RETURN ${nodes} Tag Statistics Should Be [Arguments] ${tag} ${pass} ${fail} diff --git a/atest/robot/cli/console/console_resource.robot b/atest/robot/cli/console/console_resource.robot index c2b190576f5..5003b40a338 100644 --- a/atest/robot/cli/console/console_resource.robot +++ b/atest/robot/cli/console/console_resource.robot @@ -13,7 +13,7 @@ ${MSG_110} 1 test, 1 passed, 0 failed *** Keywords *** Create Status Line [Arguments] ${name} ${padding} ${status} - [Return] ${name}${SPACE * ${padding}}| ${status} | + RETURN ${name}${SPACE * ${padding}}| ${status} | Stdout Should Be [Arguments] ${expected} &{replaced} diff --git a/atest/robot/cli/console/max_error_lines.robot b/atest/robot/cli/console/max_error_lines.robot index b0a32af81ae..cc5140066f7 100644 --- a/atest/robot/cli/console/max_error_lines.robot +++ b/atest/robot/cli/console/max_error_lines.robot @@ -39,7 +39,7 @@ Has Been Cut Should Match Non Empty Regexp ${test.message} ${eol_dots} Should Match Non Empty Regexp ${test.message} ${bol_dots} Error Message In Log Should Not Have Been Cut ${test.kws} - [Return] ${test} + RETURN ${test} Error Message In Log Should Not Have Been Cut [Arguments] ${kws} diff --git a/atest/robot/cli/rebot/log_level.robot b/atest/robot/cli/rebot/log_level.robot index 5677f86d996..89f7de709c5 100644 --- a/atest/robot/cli/rebot/log_level.robot +++ b/atest/robot/cli/rebot/log_level.robot @@ -40,7 +40,7 @@ Configure visible log level Rebot [Arguments] ${options}=${EMPTY} Run Rebot ${options} --log ${LOGNAME} ${INPUT FILE} - [Return] ${SUITE.tests[0]} + RETURN ${SUITE.tests[0]} Min level should be '${min}' and default '${default}' ${log}= Get file ${OUTDIR}/${LOG NAME} diff --git a/atest/robot/cli/rebot/rebot_cli_resource.robot b/atest/robot/cli/rebot/rebot_cli_resource.robot index 4701032a360..5dd39858680 100644 --- a/atest/robot/cli/rebot/rebot_cli_resource.robot +++ b/atest/robot/cli/rebot/rebot_cli_resource.robot @@ -21,4 +21,4 @@ Run rebot and return outputs ${result} = Run Rebot --outputdir ${CLI OUTDIR} ${options} ${INPUT FILE} default options= output= Should Be Equal ${result.rc} ${0} @{outputs} = List Directory ${CLI OUTDIR} - [Return] @{outputs} + RETURN @{outputs} diff --git a/atest/robot/cli/runner/cli_resource.robot b/atest/robot/cli/runner/cli_resource.robot index 06a5328c2bc..fa485a3ce69 100644 --- a/atest/robot/cli/runner/cli_resource.robot +++ b/atest/robot/cli/runner/cli_resource.robot @@ -26,14 +26,14 @@ Run Some Tests [Arguments] ${options}=-l none -r none ${result} = Run Tests -d ${CLI OUTDIR} ${options} ${TEST FILE} default options= output= Should Be Equal ${result.rc} ${0} - [Return] ${result} + RETURN ${result} Tests Should Pass Without Errors [Arguments] ${options} ${datasource} ${result} = Run Tests ${options} ${datasource} Should Be Equal ${SUITE.status} PASS Should Be Empty ${result.stderr} - [Return] ${result} + RETURN ${result} Run Should Fail [Arguments] ${options} ${error} ${regexp}=False diff --git a/atest/robot/output/js_model.robot b/atest/robot/output/js_model.robot index 9c63ece6380..bfdb0d6e3c2 100644 --- a/atest/robot/output/js_model.robot +++ b/atest/robot/output/js_model.robot @@ -44,4 +44,4 @@ Get JS model ${file} = Get File ${OUTDIR}/${type}.html ${strings} = Get Lines Matching Pattern ${file} window.output?"strings"?* ${settings} = Get Lines Matching Pattern ${file} window.settings =* - [Return] ${strings} ${settings} + RETURN ${strings} ${settings} diff --git a/atest/robot/output/listener_interface/listener_resource.robot b/atest/robot/output/listener_interface/listener_resource.robot index 6b94649e09a..23d4da59ca3 100644 --- a/atest/robot/output/listener_interface/listener_resource.robot +++ b/atest/robot/output/listener_interface/listener_resource.robot @@ -42,4 +42,4 @@ Get Listener File [Arguments] ${file} ${path} = Join Path %{TEMPDIR} ${file} ${content} = Get File ${path} - [Return] ${content} + RETURN ${content} diff --git a/atest/robot/running/flatten.robot b/atest/robot/running/flatten.robot index b87fd710147..028ab0276bd 100644 --- a/atest/robot/running/flatten.robot +++ b/atest/robot/running/flatten.robot @@ -17,6 +17,7 @@ Loops and stuff Check Log Message ${tc.body[0].messages[0]} inside for 0 Check Log Message ${tc.body[0].messages[5]} inside while 0 Check Log Message ${tc.body[0].messages[15]} inside if + Check Log Message ${tc.body[0].messages[16]} fail inside try FAIL Check Log Message ${tc.body[0].messages[18]} inside except Recursion diff --git a/atest/robot/running/long_error_messages.robot b/atest/robot/running/long_error_messages.robot index 3f1187be2f2..891d106ee07 100644 --- a/atest/robot/running/long_error_messages.robot +++ b/atest/robot/running/long_error_messages.robot @@ -43,7 +43,7 @@ Has Been Cut Should Match Non Empty Regexp ${test.message} ${eol_dots} Should Match Non Empty Regexp ${test.message} ${bol_dots} Error Message In Log Should Not Have Been Cut ${test.kws} - [Return] ${test} + RETURN ${test} Error Message In Log Should Not Have Been Cut [Arguments] ${kws} diff --git a/atest/robot/standard_libraries/builtin/tags.robot b/atest/robot/standard_libraries/builtin/tags.robot index b3b5792ae69..886fde4cf54 100644 --- a/atest/robot/standard_libraries/builtin/tags.robot +++ b/atest/robot/standard_libraries/builtin/tags.robot @@ -73,11 +73,11 @@ Tags Should Have Been Added @{tags} = Create List @{SUITE_TAGS} @{added} Sort List ${tags} ${tc} = Check Test Tags ${testname} @{tags} - [Return] ${tc} + RETURN ${tc} Tags Should Have Been Removed [Arguments] ${testname} @{removed} @{tags} = Copy List ${SUITE_TAGS} Remove Values From List ${tags} @{removed} ${tc} = Check Test Tags ${testname} @{tags} - [Return] ${tc} + RETURN ${tc} diff --git a/atest/robot/standard_libraries/process/passing_arguments.robot b/atest/robot/standard_libraries/process/passing_arguments.robot index 02d9c00c644..cb4096b590c 100644 --- a/atest/robot/standard_libraries/process/passing_arguments.robot +++ b/atest/robot/standard_libraries/process/passing_arguments.robot @@ -61,10 +61,10 @@ Python script should be run and arguments logged [Arguments] ${arguments} ${script}=script.py ${index}=0 ${script} = Normalize Path ${DATADIR}/standard_libraries/process/files/${script} ${tc} = Arguments should be logged python ${script} ${arguments} ${index} - [Return] ${tc} + RETURN ${tc} Arguments should be logged [Arguments] ${message} ${index}=0 ${tc} = Check Test Case ${TESTNAME} Check Log Message ${tc.kws[${index}].msgs[0]} Starting process:\n${message} - [Return] ${tc} + RETURN ${tc} diff --git a/atest/robot/standard_libraries/remote/remote_resource.robot b/atest/robot/standard_libraries/remote/remote_resource.robot index 171b3c3251a..90f55bcd8ad 100644 --- a/atest/robot/standard_libraries/remote/remote_resource.robot +++ b/atest/robot/standard_libraries/remote/remote_resource.robot @@ -13,7 +13,7 @@ Run Remote Tests Run Tests --variable PORT:${port} standard_libraries/remote/${tests} [Teardown] Run Keyword If '${stop server}' == 'yes' ... Stop Remote Server ${server} - [Return] ${port} + RETURN ${port} Start Remote Server [Arguments] ${server} ${port}=0 @@ -24,7 +24,7 @@ Start Remote Server ... alias=${server} stdout=${STDOUT FILE} stderr=STDOUT Wait Until Created ${PORT FILE} 30s ${port} = Get File ${PORT FILE} - [Return] ${port} + RETURN ${port} Stop Remote Server [Arguments] ${server} diff --git a/atest/robot/test_libraries/error_msg_and_details.robot b/atest/robot/test_libraries/error_msg_and_details.robot index 8a579e89a34..60bd663600e 100644 --- a/atest/robot/test_libraries/error_msg_and_details.robot +++ b/atest/robot/test_libraries/error_msg_and_details.robot @@ -97,7 +97,7 @@ Verify Test Case And Error In Log [Arguments] ${name} ${error} ${index}=0 ${msg}=0 ${tc} = Check Test Case ${name} Check Log Message ${tc.kws[${index}].msgs[${msg}]} ${error} FAIL - [Return] ${tc} + RETURN ${tc} Verify Test Case, Error In Log And No Details [Arguments] ${name} ${error} ${msg_index}=${0} diff --git a/atest/robot/test_libraries/print_logging.robot b/atest/robot/test_libraries/print_logging.robot index 4015e8ecb92..6211f5ba00f 100644 --- a/atest/robot/test_libraries/print_logging.robot +++ b/atest/robot/test_libraries/print_logging.robot @@ -79,4 +79,4 @@ FAIL is not valid log level Get Expected Bytes [Arguments] ${string} ${bytes} = Encode String To Bytes ${string} ${CONSOLE_ENCODING} - [Return] b'${bytes}' + RETURN b'${bytes}' diff --git a/atest/robot/variables/recursive_definition.robot b/atest/robot/variables/recursive_definition.robot index 8d8a4e31683..f5bcde6529b 100644 --- a/atest/robot/variables/recursive_definition.robot +++ b/atest/robot/variables/recursive_definition.robot @@ -59,4 +59,4 @@ Get recommendations [Arguments] @{recommendations} ${recommendations} = Catenate SEPARATOR=\n${SPACE*4} ... Did you mean: @{recommendations} - [Return] ${recommendations} + RETURN ${recommendations} diff --git a/atest/testdata/cli/console/max_error_lines.robot b/atest/testdata/cli/console/max_error_lines.robot index 5f739c1ab08..82ebc82a44e 100644 --- a/atest/testdata/cli/console/max_error_lines.robot +++ b/atest/testdata/cli/console/max_error_lines.robot @@ -41,4 +41,4 @@ Get Long Message ${msg} = Evaluate "END\\n".join(${lines}) ${total_chars} = Evaluate ${line_length} * ${line_count} ${msg} = Evaluate """${msg}"""[:-len("${total_chars}")] + " " * 4 + "${total_chars}" - [Return] ${msg} + RETURN ${msg} diff --git a/atest/testdata/cli/dryrun/dryrun.robot b/atest/testdata/cli/dryrun/dryrun.robot index 6eeabe722aa..b6c19c06655 100644 --- a/atest/testdata/cli/dryrun/dryrun.robot +++ b/atest/testdata/cli/dryrun/dryrun.robot @@ -164,10 +164,10 @@ Invalid Syntax UK Some Return Value [Arguments] ${a1} ${a2} - [Return] ${a1}-${a2} + RETURN ${a1}-${a2} Ooops return value - [Return] ${ooops} + RETURN ${ooops} UK with multiple failures Invalid Syntax UK diff --git a/atest/testdata/cli/remove_keywords/all_combinations.robot b/atest/testdata/cli/remove_keywords/all_combinations.robot index 404a1243e9b..0fdf8f6c245 100644 --- a/atest/testdata/cli/remove_keywords/all_combinations.robot +++ b/atest/testdata/cli/remove_keywords/all_combinations.robot @@ -123,7 +123,7 @@ My WUKS Remove By Name [Arguments] ${whatever}=default Log ${REMOVED BY NAME MESSAGE} - [Return] ${whatever} + RETURN ${whatever} Do not remove by name Remove By Name @@ -132,7 +132,7 @@ Do not remove by name This should be removed [Arguments] ${whatever}=default Log ${REMOVED BY PATTERN MESSAGE} - [Return] ${whatever} + RETURN ${whatever} This should be removed also Log ${REMOVED BY PATTERN MESSAGE} diff --git a/atest/testdata/core/test_suite_dir_with_init_file/__init__.robot b/atest/testdata/core/test_suite_dir_with_init_file/__init__.robot index 48775697431..ecf6036eaa0 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/__init__.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/__init__.robot @@ -29,5 +29,5 @@ My Teardown Create Message [Arguments] @{msg_parts} ${msg} = Catenate @{msg_parts} - [Return] ${msg} + RETURN ${msg} diff --git a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot index 0e765505939..473ba54e282 100644 --- a/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot +++ b/atest/testdata/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot @@ -19,4 +19,4 @@ My Teardown Create Message [Arguments] @{msg_parts} ${msg} Catenate @{msg_parts} - [Return] ${msg} + RETURN ${msg} diff --git a/atest/testdata/keywords/named_args/variables_in_names.robot b/atest/testdata/keywords/named_args/variables_in_names.robot index 021a6127239..aeb2c4d9300 100644 --- a/atest/testdata/keywords/named_args/variables_in_names.robot +++ b/atest/testdata/keywords/named_args/variables_in_names.robot @@ -65,4 +65,4 @@ Equal sign in variable name *** Keywords *** User Keyword [Arguments] ${first arg} ${a-b-c}=default - [Return] ${first arg}, ${a-b-c} + RETURN ${first arg}, ${a-b-c} diff --git a/atest/testdata/keywords/named_args/with_user_keywords.robot b/atest/testdata/keywords/named_args/with_user_keywords.robot index 082f30ece38..88acb609761 100644 --- a/atest/testdata/keywords/named_args/with_user_keywords.robot +++ b/atest/testdata/keywords/named_args/with_user_keywords.robot @@ -136,37 +136,37 @@ Execute illegal named combination Mandatory, Named and varargs [Arguments] ${a} ${b}=default @{varargs} ${res}= pretty ${a} ${b} @{varargs} - [Return] ${res} + RETURN ${res} Mandatory and Named [Arguments] ${a} ${b}=default ${res}= pretty ${a} ${b} - [Return] ${res} + RETURN ${res} One Kwarg [Arguments] ${kwarg}= - [Return] ${kwarg} + RETURN ${kwarg} Two Kwargs [Arguments] ${first}= ${second}= - [Return] ${first}, ${second} + RETURN ${first}, ${second} Four Kw Args [Arguments] ${a}=default ${b}=default ${c}=default ${d}=default - [Return] ${a}, ${b}, ${c}, ${d} + RETURN ${a}, ${b}, ${c}, ${d} Mandatory And Kwargs [Arguments] ${man1} ${man2} ${kwarg}=KWARG VALUE - [Return] ${man1}, ${man2}, ${kwarg} + RETURN ${man1}, ${man2}, ${kwarg} Escaped default value [Arguments] ${d1}=\${notvariable} ${d2}=\\\\ ${d3}=\n ${d4}=\t - [Return] ${d1} ${d2} ${d3} ${d4} + RETURN ${d1} ${d2} ${d3} ${d4} Named arguments with varargs [Arguments] ${a}=default ${b}=default @{varargs} - [Return] ${a} ${b} @{varargs} + RETURN ${a} ${b} @{varargs} Named arguments with nönäscii [Arguments] ${nönäscii}= - [Return] ${nönäscii} + RETURN ${nönäscii} diff --git a/atest/testdata/keywords/named_only_args/user_keyword.robot b/atest/testdata/keywords/named_only_args/user_keyword.robot index 0d61e93aef6..7673a41b0d3 100644 --- a/atest/testdata/keywords/named_only_args/user_keyword.robot +++ b/atest/testdata/keywords/named_only_args/user_keyword.robot @@ -100,29 +100,29 @@ With positional argument containing equal sign *** Keywords *** Kw Only Arg [Arguments] @{} ${kwo} - [Return] ${kwo} + RETURN ${kwo} Many Kw Only Args [Arguments] @{} ${first} ${second} ${third} ${result} = Evaluate $first + $second + $third - [Return] ${result} + RETURN ${result} Kw Only Arg With Default [Arguments] @{} ${kwo}=default ${another}=another - [Return] ${kwo}-${another} + RETURN ${kwo}-${another} Mandatory After Defaults [Arguments] @{} ${default1}=xxx ${mandatory} ${default2}=zzz - [Return] ${default1}-${mandatory}-${default2} + RETURN ${default1}-${mandatory}-${default2} Kw Only Arg With Variable In Default [Arguments] @{} ${ko1}=${1} ${ko2}=${VAR} ${ko3}=${ko1} - [Return] ${ko1}-${ko2}-${ko3} + RETURN ${ko1}-${ko2}-${ko3} Kw Only Arg With Varargs [Arguments] @{varargs} ${kwo} ${result} = Catenate SEPARATOR=- @{varargs} ${kwo} - [Return] ${result} + RETURN ${result} All Arg Types [Arguments] ${pos_req} ${pos_def}=pd @{varargs} @@ -131,4 +131,4 @@ All Arg Types ${result} = Catenate SEPARATOR=- ... ${pos_req} ${pos_def} @{varargs} ... ${kwo_req} ${kwo_def} @{kwargs} - [Return] ${result} + RETURN ${result} diff --git a/atest/testdata/keywords/resources/embedded_args_in_uk_1.robot b/atest/testdata/keywords/resources/embedded_args_in_uk_1.robot index a0c0de6de5f..fac1e19e417 100644 --- a/atest/testdata/keywords/resources/embedded_args_in_uk_1.robot +++ b/atest/testdata/keywords/resources/embedded_args_in_uk_1.robot @@ -1,6 +1,6 @@ *** Keywords *** ${name} Uses ${type} File - [Return] ${name}-${type} + RETURN ${name}-${type} ${a}-r1-${b} Log ${a}-r1-${b} diff --git a/atest/testdata/keywords/trace_log_return_value.robot b/atest/testdata/keywords/trace_log_return_value.robot index e5a68289faa..fe299fc5ca7 100644 --- a/atest/testdata/keywords/trace_log_return_value.robot +++ b/atest/testdata/keywords/trace_log_return_value.robot @@ -29,4 +29,4 @@ Return object with invalid repr *** Keywords *** Return Value From UK ${return} = Set Variable value - [Return] ${return} + RETURN ${return} diff --git a/atest/testdata/keywords/user_keyword_arguments.robot b/atest/testdata/keywords/user_keyword_arguments.robot index ef2987058d6..8e8f46ce7d0 100644 --- a/atest/testdata/keywords/user_keyword_arguments.robot +++ b/atest/testdata/keywords/user_keyword_arguments.robot @@ -230,45 +230,45 @@ Invalid Arguments Spec - Multiple errors *** Keywords *** A 0 - [Return] a_0 + RETURN a_0 A 0 B - [Return] a_0_b + RETURN a_0_b A 1 [Arguments] ${arg} - [Return] a_1: ${arg} + RETURN a_1: ${arg} A 3 [Arguments] ${arg1} ${arg2} ${arg3} - [Return] a_3: ${arg1} ${arg2} ${arg3} + RETURN a_3: ${arg1} ${arg2} ${arg3} A 0 1 [Arguments] ${arg}=default - [Return] a_0_1: ${arg} + RETURN a_0_1: ${arg} A 1 3 [Arguments] ${arg1} ${arg2}=default ${arg3}=default - [Return] a_1_3: ${arg1} ${arg2} ${arg3} + RETURN a_1_3: ${arg1} ${arg2} ${arg3} A 0 N [Arguments] @{args} ${ret} = Catenate @{args} - [Return] a_0_n: ${ret} + RETURN a_0_n: ${ret} A 1 N [Arguments] ${arg} @{args} ${ret} = Catenate @{args} - [Return] a_1_n: ${arg} ${ret} + RETURN a_1_n: ${arg} ${ret} A 1 2 N [Arguments] ${arg1} ${arg2}=default @{args} ${ret} = Catenate @{args} - [Return] a_1_2_n: ${arg1} ${arg2} ${ret} + RETURN a_1_2_n: ${arg1} ${arg2} ${ret} Default With Variable [Arguments] ${arg}=${VAR} - [Return] ${arg} + RETURN ${arg} Default With Non-Existing Variable [Arguments] ${arg}=${NON EXISTING} @@ -276,15 +276,15 @@ Default With Non-Existing Variable Default With None Variable [Arguments] ${arg}=${None} - [Return] ${arg} + RETURN ${arg} Default With Number Variable [Arguments] ${arg}=${1e3} - [Return] ${arg} + RETURN ${arg} Default With Extended Variable Syntax [Arguments] ${arg}=${VAR.upper()} - [Return] ${arg} + RETURN ${arg} Default With Variable Based On Earlier Argument [Arguments] ${a}=a ${b}=b ${c}=${a}+${b} ${d}=${c.upper()} ${e}=\${d}on\\t escape (\\${a}) @@ -300,7 +300,7 @@ Default With List Variable Append To List ${b} foo Should Be True $a == ['foo'] Should Be True $b == ['With', 'three', 'values', 'foo'] != $LIST - [Return] ${a} + RETURN ${a} Default With Invalid List Variable [Arguments] ${invalid}=@{VAR} @@ -315,7 +315,7 @@ Default With Dict Variable ${b.c} = Set Variable value Should Be True $a == {'new': 'value'} Should Be True $b == {'a': 'override', 'b': 2, 'c': 'value'} != $DICT - [Return] ${a} + RETURN ${a} Default With Invalid Dict Variable [Arguments] ${invalid}=&{VAR} @@ -323,7 +323,7 @@ Default With Invalid Dict Variable Argument With `=` In Name [Arguments] ${=} ${==}== ${===}=${=} - [Return] ${=}-${==}-${===} + RETURN ${=}-${==}-${===} Mutate Lists [Arguments] ${list1} @{list2} diff --git a/atest/testdata/keywords/user_keyword_kwargs.robot b/atest/testdata/keywords/user_keyword_kwargs.robot index affc14e363f..cb3fbd29945 100644 --- a/atest/testdata/keywords/user_keyword_kwargs.robot +++ b/atest/testdata/keywords/user_keyword_kwargs.robot @@ -147,7 +147,7 @@ Varags and kwargs Append To List ${items} ${key}: ${value} END ${result} = Catenate SEPARATOR=,${SPACE} @{items} - [Return] ${result} + RETURN ${result} Positional, varargs and kwargs [Arguments] ${arg} @{varargs} &{kwargs} @@ -161,13 +161,13 @@ Kwargs are ordered [Arguments] &{kwargs} ${values} = Catenate @{kwargs.values()} Should Be Equal ${values} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 - [Return] &{kwargs} + RETURN &{kwargs} Kwargs are dot-accessible [Arguments] &{kwargs} Should Be Equal ${kwargs.key} value Should Be Equal ${kwargs.second} ${2} - [Return] &{kwargs} + RETURN &{kwargs} Mutate Dictionaries [Arguments] ${dict1} &{dict2} diff --git a/atest/testdata/parsing/escaping.robot b/atest/testdata/parsing/escaping.robot index ef616008774..03ab49bcb40 100644 --- a/atest/testdata/parsing/escaping.robot +++ b/atest/testdata/parsing/escaping.robot @@ -178,7 +178,7 @@ Pipe User keyword [Arguments] ${a1} ${a2} Should Contain ${a1} ${a2} - [Return] ${a1}\${${a2}} + RETURN ${a1}\${${a2}} User keyword 2 [Arguments] ${a1} ${a2} diff --git a/atest/testdata/parsing/utf8_data.robot b/atest/testdata/parsing/utf8_data.robot index 90e6b30c331..2e56f17d9d5 100644 --- a/atest/testdata/parsing/utf8_data.robot +++ b/atest/testdata/parsing/utf8_data.robot @@ -35,4 +35,4 @@ UTF-8 Name Äöå §½€" | | Log | ${value} | | | Log | ${UNICODE} | | | Log | §½€ | -| Äöå §½€ | [Return] | äöå §½€ | +| Äöå §½€ | RETURN | äöå §½€ | diff --git a/atest/testdata/parsing/utf8_data.tsv b/atest/testdata/parsing/utf8_data.tsv index 376863da763..3ea17382414 100644 --- a/atest/testdata/parsing/utf8_data.tsv +++ b/atest/testdata/parsing/utf8_data.tsv @@ -35,6 +35,6 @@ Logging Keyword [Arguments] ${value} Log ${UNICODE} Log §½€ -Äöå §½€ [Return] äöå §½€ +Äöå §½€ RETURN äöå §½€ diff --git a/atest/testdata/running/continue_on_failure.robot b/atest/testdata/running/continue_on_failure.robot index 6599acdb514..897ef756db9 100644 --- a/atest/testdata/running/continue_on_failure.robot +++ b/atest/testdata/running/continue_on_failure.robot @@ -221,10 +221,10 @@ Continuable failure in user keyword returning value Run Keyword And Continue On Failure Fail Continuable failure ${ret} = Set Variable return value Should Be Equal ${ret} return value - [Return] ${ret} + RETURN ${ret} Continuable failure in nested user keyword returning value Run Keyword And Continue On Failure Fail Another continuable failure ${ret} = Continuable failure in user keyword returning value Should Be Equal ${ret} return value - [Return] ${ret} 2 + RETURN ${ret} 2 diff --git a/atest/testdata/running/flatten.robot b/atest/testdata/running/flatten.robot index 5b6b78540fb..5d606a9efda 100644 --- a/atest/testdata/running/flatten.robot +++ b/atest/testdata/running/flatten.robot @@ -27,8 +27,9 @@ Nested UK Nest Nest - [Return] foo Log from nested kw + RETURN foo + Log not logged Loops and stuff [Tags] robot:flatten @@ -47,10 +48,10 @@ Loops and stuff IF True Log inside if ELSE - Fail + Fail not run END TRY - Fail + Fail fail inside try EXCEPT Log inside except END diff --git a/atest/testdata/running/long_error_messages.robot b/atest/testdata/running/long_error_messages.robot index 5e1ab9ca09a..45316f3f0fa 100644 --- a/atest/testdata/running/long_error_messages.robot +++ b/atest/testdata/running/long_error_messages.robot @@ -60,4 +60,4 @@ Get Long Message ${msg} = Evaluate "END\\n".join($lines) ${total_chars} = Evaluate ${line_length} * ${line_count} ${msg} = Evaluate $msg[:-len("${total_chars}")] + " " * 4 + "${total_chars}" - [Return] ${msg} + RETURN ${msg} diff --git a/atest/testdata/running/timeouts.robot b/atest/testdata/running/timeouts.robot index b47dcf7e73c..ffa33a74d99 100644 --- a/atest/testdata/running/timeouts.robot +++ b/atest/testdata/running/timeouts.robot @@ -266,7 +266,7 @@ Timeouted Keyword Passes [Timeout] 5 seconds Log Testing logging in timeouted keyword Sleep Without Logging ${secs} - [Return] Slept ${secs}s + RETURN Slept ${secs}s Timeouted Keyword Fails Before Timeout [Timeout] 9000 @@ -275,7 +275,7 @@ Timeouted Keyword Fails Before Timeout Timeouted Keyword Timeouts [Timeout] 99 milliseconds Sleep Without Logging 2 - [Return] Nothing, really + RETURN Nothing, really Timeouted Keyword Timeouts Due To Total Time [Timeout] 0.3 seconds diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot b/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot index b34fabd254e..9ea324d64fc 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot @@ -262,7 +262,7 @@ Passing UK Log Hello world No Operation ${ret} = Evaluate 1+2 - [Return] ${ret} + RETURN ${ret} Failing Uk Passing Uk diff --git a/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource2.robot b/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource2.robot index 27967ab6529..f9ad4efd840 100644 --- a/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource2.robot +++ b/atest/testdata/standard_libraries/builtin/set_resource_search_order/resource2.robot @@ -1,3 +1,3 @@ *** Keywords *** Get Name - [Return] resource2 + RETURN resource2 diff --git a/atest/testdata/standard_libraries/builtin/wait_until_keyword_succeeds.robot b/atest/testdata/standard_libraries/builtin/wait_until_keyword_succeeds.robot index 1ebf347ea9f..e1e2229c59a 100644 --- a/atest/testdata/standard_libraries/builtin/wait_until_keyword_succeeds.robot +++ b/atest/testdata/standard_libraries/builtin/wait_until_keyword_succeeds.robot @@ -155,7 +155,7 @@ Strict and invalid retry interval *** Keywords *** User Keyword ${value} = Fail Until Retried Often Enough From User Keyword - [Return] ${value} + RETURN ${value} Wait Until Inside User Keyword Wait Until Keyword Succeeds 3.99 seconds 0.1 Fail Until Retried Often Enough diff --git a/atest/testdata/standard_libraries/process/env_config.robot b/atest/testdata/standard_libraries/process/env_config.robot index 87f3bb86142..0bd93973d99 100644 --- a/atest/testdata/standard_libraries/process/env_config.robot +++ b/atest/testdata/standard_libraries/process/env_config.robot @@ -48,4 +48,4 @@ Create environ ${path} = Get Environment Variable PATH default=. ${systemroot} = Get Environment Variable SYSTEMROOT default=. ${environ} = Create Dictionary @{environ} PATH=${path} SYSTEMROOT=${SYSTEMROOT} - [Return] ${environ} + RETURN ${environ} diff --git a/atest/testdata/standard_libraries/process/output_encoding.robot b/atest/testdata/standard_libraries/process/output_encoding.robot index 28638f0e8e7..f95afc5cc7c 100644 --- a/atest/testdata/standard_libraries/process/output_encoding.robot +++ b/atest/testdata/standard_libraries/process/output_encoding.robot @@ -48,4 +48,4 @@ Run Process With Output Encoding ${output_encoding} = Evaluate $output_encoding or $encoding ${result} = Run Process python ${ENCODING SCRIPT} encoding:${encoding} ... stdout=${stdout} stderr=${stderr} output_encoding=${output encoding} - [Return] ${result} + RETURN ${result} diff --git a/atest/testdata/standard_libraries/process/process_resource.robot b/atest/testdata/standard_libraries/process/process_resource.robot index 7f8a8116ecc..e2f93f1b4a5 100644 --- a/atest/testdata/standard_libraries/process/process_resource.robot +++ b/atest/testdata/standard_libraries/process/process_resource.robot @@ -22,7 +22,7 @@ Some process ... alias=${alias} stderr=${stderr} stdin=PIPE Wait Until Created ${STARTED} timeout=10s Process Should Be Running - [Return] ${handle} + RETURN ${handle} Stop some process [Arguments] ${handle}=${NONE} ${message}= @@ -30,7 +30,7 @@ Stop some process Return From Keyword If not $running ${process}= Get Process Object ${handle} ${stdout} ${_} = Call Method ${process} communicate ${message.encode('ASCII') + b'\n'} - [Return] ${stdout.decode('ASCII').rstrip()} + RETURN ${stdout.decode('ASCII').rstrip()} Result should equal [Arguments] ${result} ${stdout}= ${stderr}= ${rc}=0 @@ -57,7 +57,7 @@ Custom stream should contain ${path} = Normalize Path ${path} ${content} = Get File ${path} encoding=CONSOLE Should Be Equal ${content.rstrip()} ${expected} - [Return] ${path} + RETURN ${path} Script result should equal [Documentation] These are default results by ${SCRIPT} @@ -68,13 +68,13 @@ Start Python Process [Arguments] ${command} ${alias}=${NONE} ${stdout}=${NONE} ${stderr}=${NONE} ${stdin}=None ${shell}=False ${handle}= Start Process python -c ${command} ... alias=${alias} stdout=${stdout} stderr=${stderr} stdin=${stdin} shell=${shell} - [Return] ${handle} + RETURN ${handle} Run Python Process [Arguments] ${command} ${alias}=${NONE} ${stdout}=${NONE} ${stderr}=${NONE} ${result}= Run Process python -c ${command} ... alias=${alias} stdout=${stdout} stderr=${stderr} - [Return] ${result} + RETURN ${result} Safe Remove File [Documentation] Ignore errors caused by process being locked. diff --git a/atest/testdata/standard_libraries/process/sending_signal.robot b/atest/testdata/standard_libraries/process/sending_signal.robot index 52f6347617b..8b566aa6809 100644 --- a/atest/testdata/standard_libraries/process/sending_signal.robot +++ b/atest/testdata/standard_libraries/process/sending_signal.robot @@ -61,4 +61,4 @@ Start Countdown ${handle} = Start Process python ${COUNTDOWN} ${TEMPFILE} ... ${children} alias=${alias} shell=${shell} Wait Until Countdown Started - [Return] ${handle} + RETURN ${handle} diff --git a/atest/testdata/standard_libraries/process/stdout_and_stderr.robot b/atest/testdata/standard_libraries/process/stdout_and_stderr.robot index baf5ccff6cf..e046f7f85ab 100644 --- a/atest/testdata/standard_libraries/process/stdout_and_stderr.robot +++ b/atest/testdata/standard_libraries/process/stdout_and_stderr.robot @@ -131,7 +131,7 @@ Run Stdout Stderr Process ... sys.stderr.write('${stderr_content}') ${result} = Run Process python -c ${code} ... stdout=${stdout} stderr=${stderr} cwd=${cwd} - [Return] ${result} + RETURN ${result} Run And Test Once [Arguments] ${content} ${stdout}=${NONE} ${stderr}=${NONE} diff --git a/atest/testdata/standard_libraries/telnet/telnet_resource.robot b/atest/testdata/standard_libraries/telnet/telnet_resource.robot index e97b8030a18..bef801e0a3b 100644 --- a/atest/testdata/standard_libraries/telnet/telnet_resource.robot +++ b/atest/testdata/standard_libraries/telnet/telnet_resource.robot @@ -8,7 +8,7 @@ Login and set prompt ... alias=${alias} encoding=${encoding} terminal_emulation=${terminal_emulation} ... window_size=${window_size} terminal_type=${terminal_type} Login and wait - [Return] ${index} + RETURN ${index} Login and wait Login ${USERNAME} ${PASSWORD} diff --git a/atest/testdata/standard_libraries/telnet/terminal_emulation.robot b/atest/testdata/standard_libraries/telnet/terminal_emulation.robot index 60753bc19df..203b5b97721 100644 --- a/atest/testdata/standard_libraries/telnet/terminal_emulation.robot +++ b/atest/testdata/standard_libraries/telnet/terminal_emulation.robot @@ -132,7 +132,7 @@ Run with timeout 0.5 [Arguments] ${kw} @{args} [Timeout] 0.5 ${res}= Run keyword ${kw} @{args} - [Return] ${res} + RETURN ${res} Read until should match [Arguments] ${expected} ${match} diff --git a/atest/testdata/standard_libraries/xml/etree_namespaces.robot b/atest/testdata/standard_libraries/xml/etree_namespaces.robot index 3e9eecc810e..8910ff83df7 100644 --- a/atest/testdata/standard_libraries/xml/etree_namespaces.robot +++ b/atest/testdata/standard_libraries/xml/etree_namespaces.robot @@ -74,7 +74,7 @@ Get Expected Etree 1.3 Output ... ${INDENT} ... ${INDENT}back in default ... - [Return] @{expected} + RETURN @{expected} Get Expected Etree 1.2 Output @{expected} = Create List @@ -94,4 +94,4 @@ Get Expected Etree 1.2 Output ... ${INDENT} ... ${INDENT}back in default ... - [Return] @{expected} + RETURN @{expected} diff --git a/atest/testdata/standard_libraries/xml/xml_resource.robot b/atest/testdata/standard_libraries/xml/xml_resource.robot index 9ced3e1d9e0..09b415d5cd0 100644 --- a/atest/testdata/standard_libraries/xml/xml_resource.robot +++ b/atest/testdata/standard_libraries/xml/xml_resource.robot @@ -15,13 +15,13 @@ ${INDENT} = ${SPACE * 4} *** Keywords *** Get Etree Version ${et} = Evaluate robot.utils.ET modules=robot - [Return] ${et.VERSION} + RETURN ${et.VERSION} Run With Bytes [Arguments] ${kw} ${string} @{args} ${encoding}=UTF-8 &{kws} ${bytes} = Encode string to bytes ${string} ${encoding} ${result} = Run Keyword ${kw} ${bytes} @{args} &{kws} - [Return] ${result} + RETURN ${result} Parse XML To Test Variable [Arguments] ${input} ${var} &{config} @@ -57,7 +57,7 @@ Run Keyword Depending On Etree Version ... ${etree 1.3 keyword} ... ELSE ... ${etree 1.2 keyword} - [Return] @{result} + RETURN @{result} Test Attribute Namespace Parsing [Arguments] ${elem} diff --git a/atest/testdata/variables/environment_variables.robot b/atest/testdata/variables/environment_variables.robot index 3c515236cc3..b9f4e6f159e 100644 --- a/atest/testdata/variables/environment_variables.robot +++ b/atest/testdata/variables/environment_variables.robot @@ -100,4 +100,4 @@ UK With Environment Variables In Metadata [Arguments] ${mypath}=%{TEMPDIR} [Documentation] %{THIS_ENV_VAR_IS_SET} in a uk doc Should Contain ${mypath} ${/} - [Return] %{THIS_ENV_VAR_IS_SET} + RETURN %{THIS_ENV_VAR_IS_SET} diff --git a/atest/testdata/variables/variable_scopes.robot b/atest/testdata/variables/variable_scopes.robot index 486453a8a5f..88a73c4b9cc 100644 --- a/atest/testdata/variables/variable_scopes.robot +++ b/atest/testdata/variables/variable_scopes.robot @@ -45,14 +45,14 @@ Keyword should see passed values Variable should not exist ${test} ${arg}= Set variable kw ${arg}= Keyword should see passed values 2 ${arg} - [Return] ${arg} + RETURN ${arg} Keyword should see passed values 2 [Arguments] ${arg2} Should be equal ${arg2} kw Variable should not exist ${test} Variable should not exist ${arg} - [Return] kw2 + RETURN kw2 Keyword should see test scope variables Should be equal ${test} test From ca3b930163f00808d4789da5d1cbb861c5fe90b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Sat, 7 Oct 2023 09:41:11 +0300 Subject: [PATCH 0749/1592] Deprecate [Return] setting. Fixes #4876. Deprecation is only in parser. Programmatically setting or inspecting `UserKeyword.return_` is so rare that separately deprecating it isn't worth the effort. --- ...dotted_exitonfailure_empty_test_stderr.txt | 8 ++++-- atest/robot/core/empty_tc_and_uk.robot | 6 ++-- atest/robot/parsing/line_continuation.robot | 13 +++++---- .../robot/parsing/user_keyword_settings.robot | 10 ++++++- .../testdata/parsing/line_continuation.robot | 28 +++++++++++-------- .../parsing/user_keyword_settings.robot | 3 +- src/robot/parsing/lexer/settings.py | 3 ++ src/robot/result/model.py | 1 - src/robot/running/builder/transformers.py | 9 +++++- utest/parsing/test_lexer.py | 6 ++-- 10 files changed, 58 insertions(+), 29 deletions(-) diff --git a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt index c61830b9230..04fa3df2ffb 100644 --- a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt +++ b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt @@ -1,3 +1,5 @@ -[[] ERROR ] Error in file '*' on line 41: Creating keyword '' failed: User keyword name cannot be empty. -[[] ERROR ] Error in file '*' on line 45: Creating keyword 'Empty UK' failed: User keyword cannot be empty. -[[] ERROR ] Error in file '*' on line 47: Creating keyword 'Empty UK With Settings' failed: User keyword cannot be empty. +[[] WARN ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 59: The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. +[[] WARN ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 62: The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. +[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 41: Creating keyword '' failed: User keyword name cannot be empty. +[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 45: Creating keyword 'Empty UK' failed: User keyword cannot be empty. +[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 47: Creating keyword 'Empty UK With Settings' failed: User keyword cannot be empty. diff --git a/atest/robot/core/empty_tc_and_uk.robot b/atest/robot/core/empty_tc_and_uk.robot index fcfe5decbf3..240d8844401 100644 --- a/atest/robot/core/empty_tc_and_uk.robot +++ b/atest/robot/core/empty_tc_and_uk.robot @@ -13,12 +13,12 @@ Empty Test Case With Setup And Teardown Check Test Case ${TESTNAME} User Keyword Without Name - Error In File 0 core/empty_testcase_and_uk.robot 41 + Error In File 2 core/empty_testcase_and_uk.robot 41 ... Creating keyword '' failed: User keyword name cannot be empty. Empty User Keyword Check Test Case ${TESTNAME} - Error In File 1 core/empty_testcase_and_uk.robot 45 + Error In File 3 core/empty_testcase_and_uk.robot 45 ... Creating keyword 'Empty UK' failed: User keyword cannot be empty. User Keyword With Only Non-Empty [Return] Works @@ -28,7 +28,7 @@ User Keyword With Empty [Return] Does Not Work Check Test Case ${TESTNAME} Empty User Keyword With Other Settings Than [Return] - Error In File 2 core/empty_testcase_and_uk.robot 47 + Error In File 4 core/empty_testcase_and_uk.robot 47 ... Creating keyword 'Empty UK With Settings' failed: User keyword cannot be empty. Check Test Case ${TESTNAME} diff --git a/atest/robot/parsing/line_continuation.robot b/atest/robot/parsing/line_continuation.robot index 4227048d11c..5ced4ba04b5 100644 --- a/atest/robot/parsing/line_continuation.robot +++ b/atest/robot/parsing/line_continuation.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} parsing/line_continuation.robot +Suite Setup Run Tests ${EMPTY} parsing/line_continuation.robot Resource atest_resource.robot *** Test Cases *** @@ -55,13 +55,16 @@ Multiline test settings Check Log Message ${tc.setup.msgs[1]} ${EMPTY} Check Log Message ${tc.setup.msgs[2]} last -Multiline user keyword settings - Check Test Case ${TEST NAME} +Multiline user keyword settings and control structures + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc.kws[0]} Multiline user keyword settings and control structures + ... \${x} 1, 2 tags=keyword, tags + Check Log Message ${tc.kws[0].teardown.msgs[0]} Bye! -Multiline for Loop declaration +Multiline FOR Loop declaration Check Test Case ${TEST NAME} -Multiline in for loop body +Multiline in FOR loop body Check Test Case ${TEST NAME} Escaped empty cells before line continuation do not work diff --git a/atest/robot/parsing/user_keyword_settings.robot b/atest/robot/parsing/user_keyword_settings.robot index d0961ccb433..28f73a8db8e 100644 --- a/atest/robot/parsing/user_keyword_settings.robot +++ b/atest/robot/parsing/user_keyword_settings.robot @@ -68,15 +68,23 @@ Teardown with escaping Return Check Test Case ${TEST NAME} + Error in File 0 parsing/user_keyword_settings.robot 167 + ... The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. level=WARN Return using variables Check Test Case ${TEST NAME} + Error in File 1 parsing/user_keyword_settings.robot 171 + ... The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. level=WARN Return multiple Check Test Case ${TEST NAME} + Error in File 2 parsing/user_keyword_settings.robot 176 + ... The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. level=WARN Return with escaping Check Test Case ${TEST NAME} + Error in File 3 parsing/user_keyword_settings.robot 179 + ... The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. level=WARN Timeout Verify Timeout 2 minutes 3 seconds @@ -102,7 +110,7 @@ Small typo should provide recommendation Check Test Case ${TEST NAME} Invalid empty line continuation in arguments should throw an error - Error in File 0 parsing/user_keyword_settings.robot 214 + Error in File 4 parsing/user_keyword_settings.robot 213 ... Creating keyword 'Invalid empty line continuation in arguments should throw an error' failed: ... Invalid argument specification: Invalid argument syntax ''. diff --git a/atest/testdata/parsing/line_continuation.robot b/atest/testdata/parsing/line_continuation.robot index f308c5d2867..adc0da40bf0 100644 --- a/atest/testdata/parsing/line_continuation.robot +++ b/atest/testdata/parsing/line_continuation.robot @@ -99,16 +99,15 @@ Multiline test settings ... last No Operation -Multiline user keyword settings - ${x} = Multiline User Keyword Settings 1 2 - Should Be True ${x} == [str(i) if i != 8 else '' for i in range(1,10)] - ${x} = Multiline User Keyword Settings +Multiline user keyword settings and control structures + ${x} = Multiline User Keyword Settings And Control Structures 1 2 + Should Be True ${x} == [str(i) if i != 8 else '' for i in range(1, 10)] + ${x} = Multiline User Keyword Settings And Control Structures ... 1 2 3 4 5 r1 r2 r3 - Should Be True ${x[:5]} == [str(i) for i in range(1,6)] - Should Be True ${x[5:8]} == ['r1','r2','r3'] - Should Be True ${x[9:]} == ['7', '', '9'] + Should Be True ${x}[:5] == [str(i) for i in range(1, 6)] + Should Be True ${x}[5:] == ['r1', 'r2', 'r3', '6', '7', '', '9'] -Multiline for Loop declaration +Multiline FOR Loop declaration ${result} = Set Variable ${EMPTY} FOR ${item} IN a b ... c @@ -148,7 +147,7 @@ Multiline for Loop declaration END Should Be Equal ${result} ${EMPTY} -Multiline in for loop body +Multiline in FOR loop body ${result} = Set Variable ${EMPTY} FOR ${item} IN a b c ${item} = Set Variable @@ -202,22 +201,29 @@ Multiline in user keyword ... bbb Should Be Equal ${a}-${b} aaa-bbb -Multiline user keyword settings +Multiline user keyword settings and control structures [Arguments] ${a1} ${a2} ${a3}=3 ... ${a4}=4 ... ${a5}=5 @{rest} + [Tags] + ... keyword + ... + ... tags Should Be Equal ${a1} 1 Should Be Equal ${a2} 2 Should Be Equal ${a3} 3 Should Be Equal ${a4} 4 Should Be Equal ${a5} 5 - [Return] ${a1} ${a2} + RETURN ${a1} ${a2} ... ${a3} ${a4} ${a5} ... @{rest} ... 6 ... 7 ... ... 9 + [Teardown] Log + ... Bye! + ... level=INFO Invalid usage in UK ... diff --git a/atest/testdata/parsing/user_keyword_settings.robot b/atest/testdata/parsing/user_keyword_settings.robot index 18d35c0ebf3..6a963561566 100644 --- a/atest/testdata/parsing/user_keyword_settings.robot +++ b/atest/testdata/parsing/user_keyword_settings.robot @@ -194,9 +194,8 @@ Multiple settings [Arguments] ${name} [Documentation] Documentation for a user keyword [Timeout] 0.1 hours - No Operation [Teardown] Log Teardown ${name} - [Return] Hello ${name}!! + RETURN Hello ${name}!! Invalid [Invalid Setting] This is invalid diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 9d508c26879..23ce33438c1 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -121,6 +121,9 @@ def _lex_setting(self, statement: StatementTokens, name: str): self._lex_name_arguments_and_with_name(values) else: self._lex_arguments(values) + if name == 'Return': + statement[0].error = ("The '[Return]' setting is deprecated. " + "Use the 'RETURN' statement instead.") def _lex_name_and_arguments(self, tokens: StatementTokens): if tokens: diff --git a/src/robot/result/model.py b/src/robot/result/model.py index dc31372ec94..e8a3f516f31 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -32,7 +32,6 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#programmatic-modification-of-results - """ import warnings diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index a5e56a9363b..bbddc0c4020 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -302,6 +302,7 @@ def visit_Tags(self, node): self.kw.tags.add(tag) def visit_Return(self, node): + ErrorReporter(self.resource.source).visit(node) self.kw.return_ = node.values def visit_Timeout(self, node): @@ -399,7 +400,8 @@ def visit_Break(self, node): def visit_Error(self, node): self.model.body.create_error(lineno=node.lineno, - values=node.values, error=format_error(node.errors)) + values=node.values, + error=format_error(node.errors)) class IfBuilder(NodeVisitor): @@ -620,6 +622,11 @@ def visit_TestCase(self, node): def visit_Keyword(self, node): pass + def visit_Return(self, node): + # Empty 'visit_Keyword' above prevents calling this when visiting the whole + # model, but 'KeywordBuilder.visit_Return' visits the node it gets. + LOGGER.warn(self._format_message(node.get_token(Token.RETURN_SETTING))) + def visit_SectionHeader(self, node): token = node.get_token(*Token.HEADER_TOKENS) if not token.error: diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index a565cbbf48a..b377d6e2a3f 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -494,7 +494,8 @@ def test_keyword_settings(self): (T.TIMEOUT, '[Timeout]', 8, 4), (T.ARGUMENT, '${TIMEOUT}', 8, 23), (T.EOS, '', 8, 33), - (T.RETURN, '[Return]', 9, 4), + (T.RETURN, '[Return]', 9, 4, + "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead."), (T.ARGUMENT, 'Value', 9, 23), (T.EOS, '', 9, 28) ] @@ -656,7 +657,8 @@ def test_keyword_settings_too_many_times(self): (T.ERROR, '[Timeout]', 12, 4, "Setting 'Timeout' is allowed only once. Only the first value is used."), (T.EOS, '', 12, 13), - (T.RETURN, '[Return]', 13, 4), + (T.RETURN, '[Return]', 13, 4, + "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead."), (T.ARGUMENT, 'Used', 13, 23), (T.EOS, '', 13, 27), (T.ERROR, '[Return]', 14, 4, From b4f708da9cc8cb9a935e20b009ad5e13068eb8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Sat, 7 Oct 2023 16:06:27 +0300 Subject: [PATCH 0750/1592] Cleanup: Remove unnecessary 'With' prefix. --- .../running/for/break_and_continue.robot | 36 +++++----- .../running/for/break_and_continue.robot | 72 +++++++++---------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/atest/robot/running/for/break_and_continue.robot b/atest/robot/running/for/break_and_continue.robot index 5c6ac36c869..e79998042f0 100644 --- a/atest/robot/running/for/break_and_continue.robot +++ b/atest/robot/running/for/break_and_continue.robot @@ -4,56 +4,56 @@ Resource for.resource Test Template Test and all keywords should have passed *** Test Cases *** -With CONTINUE +CONTINUE allow not run=True -With CONTINUE inside IF +CONTINUE inside IF allow not run=True allowed failure=Oh no, got 4 -With CONTINUE inside TRY +CONTINUE inside TRY allow not run=True -With CONTINUE inside EXCEPT and TRY-ELSE +CONTINUE inside EXCEPT and TRY-ELSE allow not run=True allowed failure=4 == 4 -With BREAK +BREAK allow not run=True -With BREAK inside IF +BREAK inside IF allow not run=True -With BREAK inside TRY +BREAK inside TRY allow not run=True -With BREAK inside EXCEPT +BREAK inside EXCEPT allow not run=True allowed failure=This is excepted! -With BREAK inside TRY-ELSE +BREAK inside TRY-ELSE allow not run=True -With CONTINUE in UK +CONTINUE in UK allow not run=True -With CONTINUE inside IF in UK +CONTINUE inside IF in UK allow not run=True allowed failure=Oh no, got 4 -With CONTINUE inside TRY in UK +CONTINUE inside TRY in UK allow not run=True -With CONTINUE inside EXCEPT and TRY-ELSE in UK +CONTINUE inside EXCEPT and TRY-ELSE in UK allow not run=True allowed failure=4 == 4 -With BREAK in UK +BREAK in UK allow not run=True -With BREAK inside IF in UK +BREAK inside IF in UK allow not run=True -With BREAK inside TRY in UK +BREAK inside TRY in UK allow not run=True -With BREAK inside EXCEPT in UK +BREAK inside EXCEPT in UK allow not run=True allowed failure=This is excepted! -With BREAK inside TRY-ELSE in UK +BREAK inside TRY-ELSE in UK allow not run=True diff --git a/atest/testdata/running/for/break_and_continue.robot b/atest/testdata/running/for/break_and_continue.robot index 3029546806f..dc63e855a73 100644 --- a/atest/testdata/running/for/break_and_continue.robot +++ b/atest/testdata/running/for/break_and_continue.robot @@ -1,11 +1,11 @@ *** Test Cases *** -With CONTINUE +CONTINUE FOR ${i} IN 2 3 4 CONTINUE Fail should not be executed END -With CONTINUE inside IF +CONTINUE inside IF [Documentation] FAIL Oh no, got 4 FOR ${i} IN RANGE 6 IF $i == 4 @@ -16,7 +16,7 @@ With CONTINUE inside IF Fail should not be executed END -With CONTINUE inside TRY +CONTINUE inside TRY FOR ${i} IN RANGE 6 TRY CONTINUE @@ -28,7 +28,7 @@ With CONTINUE inside TRY END END -With CONTINUE inside EXCEPT and TRY-ELSE +CONTINUE inside EXCEPT and TRY-ELSE FOR ${i} IN RANGE 6 TRY Should not be equal ${i} ${4} @@ -41,14 +41,14 @@ With CONTINUE inside EXCEPT and TRY-ELSE Fail should not be executed END -With BREAK +BREAK FOR ${i} IN RANGE 1000 BREAK Fail should not be executed END Should be equal ${i} ${0} -With BREAK inside IF +BREAK inside IF FOR ${i} IN RANGE 6 IF $i == 3 BREAK @@ -56,7 +56,7 @@ With BREAK inside IF END END -With BREAK inside TRY +BREAK inside TRY FOR ${i} IN RANGE 6 TRY BREAK @@ -70,7 +70,7 @@ With BREAK inside TRY Should be equal ${i} ${0} END -With BREAK inside EXCEPT +BREAK inside EXCEPT FOR ${i} IN RANGE 6 TRY Fail This is excepted! @@ -83,7 +83,7 @@ With BREAK inside EXCEPT END Should be equal ${i} ${0} -With BREAK inside TRY-ELSE +BREAK inside TRY-ELSE FOR ${i} IN RANGE 6 TRY No operation @@ -96,42 +96,42 @@ With BREAK inside TRY-ELSE END Should be equal ${i} ${0} -With CONTINUE in UK - With CONTINUE in UK +CONTINUE in UK + CONTINUE in UK -With CONTINUE inside IF in UK +CONTINUE inside IF in UK [Documentation] FAIL Oh no, got 4 - With CONTINUE inside IF in UK + CONTINUE inside IF in UK -With CONTINUE inside TRY in UK - With CONTINUE inside TRY in UK +CONTINUE inside TRY in UK + CONTINUE inside TRY in UK -With CONTINUE inside EXCEPT and TRY-ELSE in UK - With CONTINUE inside EXCEPT and TRY-ELSE in UK +CONTINUE inside EXCEPT and TRY-ELSE in UK + CONTINUE inside EXCEPT and TRY-ELSE in UK -With BREAK in UK - With BREAK in UK +BREAK in UK + BREAK in UK -With BREAK inside IF in UK - With BREAK inside IF in UK +BREAK inside IF in UK + BREAK inside IF in UK -With BREAK inside TRY in UK - With BREAK inside TRY in UK +BREAK inside TRY in UK + BREAK inside TRY in UK -With BREAK inside EXCEPT in UK - With BREAK inside EXCEPT in UK +BREAK inside EXCEPT in UK + BREAK inside EXCEPT in UK -With BREAK inside TRY-ELSE in UK - With BREAK inside TRY-ELSE in UK +BREAK inside TRY-ELSE in UK + BREAK inside TRY-ELSE in UK *** Keywords *** -With CONTINUE in UK +CONTINUE in UK FOR ${i} IN 2 3 4 CONTINUE Fail should not be executed END -With CONTINUE inside IF in UK +CONTINUE inside IF in UK [Documentation] FAIL Oh no, got 4 FOR ${i} IN RANGE 6 IF $i == 4 @@ -142,7 +142,7 @@ With CONTINUE inside IF in UK Fail should not be executed END -With CONTINUE inside TRY in UK +CONTINUE inside TRY in UK FOR ${i} IN RANGE 6 TRY CONTINUE @@ -154,7 +154,7 @@ With CONTINUE inside TRY in UK END END -With CONTINUE inside EXCEPT and TRY-ELSE in UK +CONTINUE inside EXCEPT and TRY-ELSE in UK FOR ${i} IN RANGE 6 TRY Should not be equal ${i} ${4} @@ -167,14 +167,14 @@ With CONTINUE inside EXCEPT and TRY-ELSE in UK Fail should not be executed END -With BREAK in UK +BREAK in UK FOR ${i} IN RANGE 1000 BREAK Fail should not be executed END Should be equal ${i} ${0} -With BREAK inside IF in UK +BREAK inside IF in UK FOR ${i} IN RANGE 6 IF $i == 3 BREAK @@ -182,7 +182,7 @@ With BREAK inside IF in UK END END -With BREAK inside TRY in UK +BREAK inside TRY in UK FOR ${i} IN RANGE 6 TRY BREAK @@ -196,7 +196,7 @@ With BREAK inside TRY in UK Should be equal ${i} ${0} END -With BREAK inside EXCEPT in UK +BREAK inside EXCEPT in UK FOR ${i} IN RANGE 6 TRY Fail This is excepted! @@ -209,7 +209,7 @@ With BREAK inside EXCEPT in UK END Should be equal ${i} ${0} -With BREAK inside TRY-ELSE in UK +BREAK inside TRY-ELSE in UK FOR ${i} IN RANGE 6 TRY No operation From 5f56a4b02f39dcaae89257ad7324dba7aad1c3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Sat, 7 Oct 2023 16:25:00 +0300 Subject: [PATCH 0751/1592] [Return] -> RETURN --- .../running/return_from_keyword.robot | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/atest/testdata/running/return_from_keyword.robot b/atest/testdata/running/return_from_keyword.robot index f2db896adcb..d5116fe1237 100644 --- a/atest/testdata/running/return_from_keyword.robot +++ b/atest/testdata/running/return_from_keyword.robot @@ -98,60 +98,60 @@ Return From Keyword If does not evaluate bogus arguments if condition is untrue Without Return Value Return From Keyword Fail Should have returned before this - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} With Single Return Value Return From Keyword something to return Fail Should have returned before this - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} With Multiple Return Values Return From Keyword something ${True} ${100} \\ ${EMPTY} Fail Should have returned before this - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} With variable [Arguments] ${arg} Return From Keyword ${arg} Fail Should have returned before this - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} With list variable [Arguments] @{list} Return From Keyword 0 @{list} Fail Should have returned before this - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} Nested keywords with return Without Return Value ${ret}= With Single Return Value Should Be Equal ${ret} something to return - [Return] should be returned + RETURN should be returned With for loop FOR ${var} IN foo bar baz Return From Keyword return ${var} Fail Should have returned before this END - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} With teardown [Arguments] ${arg} Return From Keyword something else to return Fail Should have returned before this [Teardown] Set Test Variable ${test var} ${arg} - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} Returning directly from keyword teardown fails No Operation [Teardown] Return From Keyword - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} With continuable failure Run Keyword And Continue On Failure Fail continuable error Return From Keyword this should be returned Fail Should have returned before this - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} With continuable failure in for loop FOR ${var} IN foo bar baz @@ -160,7 +160,7 @@ With continuable failure in for loop Fail Should have returned before this END Fail Should have returned before this - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} With return in keyword inside teardown No Operation @@ -170,7 +170,7 @@ With Return From Keyword If Return From Keyword If ${False} not returning yet Return From Keyword If 1 > 0 something to return Fail Should have returned before this - [Return] Should not ${evaluate} + RETURN Should not ${evaluate} Return From Keyword If with non-existing variables in arguments Return From Keyword If 0 > 1 ${non existing 1} From 3165bfa2912e9f1973f27960f926b4bcd769e950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Sat, 7 Oct 2023 16:29:31 +0300 Subject: [PATCH 0752/1592] Don't set message with BREAK, CONTINUE and RETURN. Nowadays keywords and control structures have `message` (#4883). It should not be set with BREAK, CONTINUE and RETURN, though. --- atest/resources/atest_resource.robot | 2 ++ atest/robot/running/return.robot | 2 ++ src/robot/running/statusreporter.py | 7 ++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/atest/resources/atest_resource.robot b/atest/resources/atest_resource.robot index 0706162498d..bde7c7e3113 100644 --- a/atest/resources/atest_resource.robot +++ b/atest/resources/atest_resource.robot @@ -144,6 +144,8 @@ All Keywords Should Have Passed Fail ${item.type} was not run. ELSE IF $item.skipped Fail ${item.type} was skipped. + ELSE IF $item.passed and $item.message + Fail ${item.type} has unexpected message: ${item.message} END All Keywords Should Have Passed ${item} ${allow not run} ${allowed failure} END diff --git a/atest/robot/running/return.robot b/atest/robot/running/return.robot index 6937411cab8..452258bc2c4 100644 --- a/atest/robot/running/return.robot +++ b/atest/robot/running/return.robot @@ -7,7 +7,9 @@ Simple ${tc} = Check Test Case ${TESTNAME} Should Be Equal ${tc.body[0].body[1].type} RETURN Should Be Equal ${tc.body[0].body[1].status} PASS + Should Be Equal ${tc.body[0].body[1].message} ${EMPTY} Should Be Equal ${tc.body[0].body[2].status} NOT RUN + Should Be Equal ${tc.body[0].message} ${EMPTY} Return value Check Test Case ${TESTNAME} diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index dbfc73e6060..981ed193050 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -15,8 +15,8 @@ from datetime import datetime -from robot.errors import (ExecutionFailed, ExecutionStatus, DataError, - HandlerExecutionFailed) +from robot.errors import (BreakLoop, ContinueLoop, DataError, ExecutionFailed, + ExecutionStatus, HandlerExecutionFailed, ReturnFromKeyword) from robot.utils import ErrorDetails from .modelcombiner import ModelCombiner @@ -60,7 +60,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): result.status = self.pass_status else: result.status = failure.status - result.message = failure.message + if not isinstance(failure, (BreakLoop, ContinueLoop, ReturnFromKeyword)): + result.message = failure.message if self.initial_test_status == 'PASS': context.test.status = result.status result.elapsed_time = datetime.now() - result.start_time From 64af879ff83429afccf3ec2b8e7d0b5ffff95a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 9 Oct 2023 15:21:15 +0300 Subject: [PATCH 0753/1592] Don't report parsing errors with tests and keywords further. We only validate that body and name aren't empty, and errors being reported would mean that tools adding content to body needed to clear them. With tests we already validated that body and name aren't empty also during execution, so with them not reporting these problems as parsing errors didn't require other changes. With keywords same runtime validation needed to be added, but that's a good idea in general. This way programmatically created keywords cannot be empty either. Fixes #4880. --- .../dotted_exitonfailure_empty_test_stderr.txt | 11 ++++++----- atest/robot/cli/model_modifiers/ModelModifier.py | 11 +++++++++++ atest/robot/cli/model_modifiers/pre_run.robot | 9 +++++++++ atest/robot/core/empty_tc_and_uk.robot | 8 +++++--- atest/testdata/core/empty_testcase_and_uk.robot | 9 +++++---- .../testdata/parsing/user_keyword_settings.robot | 1 + src/robot/running/builder/transformers.py | 16 ++++++++++------ src/robot/running/userkeyword.py | 4 ++++ utest/running/test_userlibrary.py | 5 ++++- 9 files changed, 55 insertions(+), 19 deletions(-) diff --git a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt index 04fa3df2ffb..10264809af6 100644 --- a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt +++ b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt @@ -1,5 +1,6 @@ -[[] WARN ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 59: The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. -[[] WARN ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 62: The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. -[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 41: Creating keyword '' failed: User keyword name cannot be empty. -[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 45: Creating keyword 'Empty UK' failed: User keyword cannot be empty. -[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 47: Creating keyword 'Empty UK With Settings' failed: User keyword cannot be empty. +[[] WARN ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 60: The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. +[[] WARN ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 63: The '[[]Return]' setting is deprecated. Use the 'RETURN' statement instead. +[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 42: Creating keyword '' failed: User keyword name cannot be empty. +[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 46: Creating keyword 'Empty UK' failed: User keyword cannot be empty. +[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 48: Creating keyword 'Empty UK With Settings' failed: User keyword cannot be empty. +[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 62: Creating keyword 'Empty UK With Empty Return' failed: User keyword cannot be empty. diff --git a/atest/robot/cli/model_modifiers/ModelModifier.py b/atest/robot/cli/model_modifiers/ModelModifier.py index 383d618d91d..a7dc85b4559 100644 --- a/atest/robot/cli/model_modifiers/ModelModifier.py +++ b/atest/robot/cli/model_modifiers/ModelModifier.py @@ -22,8 +22,19 @@ def start_suite(self, suite): suite.tests = [t for t in suite.tests if not t.tags.match('fail')] def start_test(self, test): + self.make_non_empty(test, 'Test') + if hasattr(test.parent, 'resource'): + for kw in test.parent.resource.keywords: + self.make_non_empty(kw, 'Keyword') test.tags.add(self.config) + def make_non_empty(self, item, kind): + if not item.name: + item.name = f'{kind} name made non-empty by modifier' + item.body.clear() + if not item.body: + item.body.create_keyword('Log', [f'{kind} body made non-empty by modifier']) + def start_for(self, for_): if for_.parent.name == 'FOR IN RANGE': for_.flavor = 'IN' diff --git a/atest/robot/cli/model_modifiers/pre_run.robot b/atest/robot/cli/model_modifiers/pre_run.robot index 84a265917b9..7032c79109c 100644 --- a/atest/robot/cli/model_modifiers/pre_run.robot +++ b/atest/robot/cli/model_modifiers/pre_run.robot @@ -44,6 +44,15 @@ Error if all tests removed Stderr Should Be Empty Length Should Be ${SUITE.tests} 0 +Modifier can fix empty test and keyword + Run Tests --RunEmptySuite --PreRun ${CURDIR}/ModelModifier.py core/empty_testcase_and_uk.robot + ${tc} = Check Test Case Empty Test Case PASS ${EMPTY} + Check Log Message ${tc.body[0].msgs[0]} Test body made non-empty by modifier + ${tc} = Check Test Case Empty User Keyword PASS ${EMPTY} + Check Log Message ${tc.body[0].body[0].msgs[0]} Keyword body made non-empty by modifier + Check Test Case Test name made non-empty by modifier PASS ${EMPTY} + + Modifiers are used before normal configuration ${result} = Run Tests ... --include added --prerun ${CURDIR}/ModelModifier.py:CREATE:name=Created:tags=added ${TEST DATA} diff --git a/atest/robot/core/empty_tc_and_uk.robot b/atest/robot/core/empty_tc_and_uk.robot index 240d8844401..db63baf13a8 100644 --- a/atest/robot/core/empty_tc_and_uk.robot +++ b/atest/robot/core/empty_tc_and_uk.robot @@ -13,12 +13,12 @@ Empty Test Case With Setup And Teardown Check Test Case ${TESTNAME} User Keyword Without Name - Error In File 2 core/empty_testcase_and_uk.robot 41 + Error In File 2 core/empty_testcase_and_uk.robot 42 ... Creating keyword '' failed: User keyword name cannot be empty. Empty User Keyword Check Test Case ${TESTNAME} - Error In File 3 core/empty_testcase_and_uk.robot 45 + Error In File 3 core/empty_testcase_and_uk.robot 46 ... Creating keyword 'Empty UK' failed: User keyword cannot be empty. User Keyword With Only Non-Empty [Return] Works @@ -26,9 +26,11 @@ User Keyword With Only Non-Empty [Return] Works User Keyword With Empty [Return] Does Not Work Check Test Case ${TESTNAME} + Error In File 5 core/empty_testcase_and_uk.robot 62 + ... Creating keyword 'Empty UK With Empty Return' failed: User keyword cannot be empty. Empty User Keyword With Other Settings Than [Return] - Error In File 4 core/empty_testcase_and_uk.robot 47 + Error In File 4 core/empty_testcase_and_uk.robot 48 ... Creating keyword 'Empty UK With Settings' failed: User keyword cannot be empty. Check Test Case ${TESTNAME} diff --git a/atest/testdata/core/empty_testcase_and_uk.robot b/atest/testdata/core/empty_testcase_and_uk.robot index c2a9a7b9e7e..f5989421caf 100644 --- a/atest/testdata/core/empty_testcase_and_uk.robot +++ b/atest/testdata/core/empty_testcase_and_uk.robot @@ -18,10 +18,11 @@ Empty User Keyword Empty UK User Keyword With Only Non-Empty [Return] Works - UK With Return + Empty UK With Return User Keyword With Empty [Return] Does Not Work - UK With Empty Return + [Documentation] FAIL User keyword cannot be empty. + Empty UK With Empty Return Empty User Keyword With Other Settings Than [Return] [Documentation] FAIL User keyword cannot be empty. @@ -55,8 +56,8 @@ Non Empty UK Using Empty UK UK Log In UK -UK With Return +Empty UK With Return [Return] This is a return value -UK With Empty Return +Empty UK With Empty Return [Return] diff --git a/atest/testdata/parsing/user_keyword_settings.robot b/atest/testdata/parsing/user_keyword_settings.robot index 6a963561566..e3d9ceaa047 100644 --- a/atest/testdata/parsing/user_keyword_settings.robot +++ b/atest/testdata/parsing/user_keyword_settings.robot @@ -213,3 +213,4 @@ Small typo should provide recommendation Invalid empty line continuation in arguments should throw an error [Arguments] ... + No Operation diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index bbddc0c4020..b97cc33c77f 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -170,14 +170,16 @@ def __init__(self, suite: TestSuite, settings: FileSettings): self._test_has_tags = False def visit_TestCase(self, node): - error = format_error(node.errors + node.header.errors) settings = self.settings + # Possible parsing errors aren't reported further with tests because: + # - We only validate that test body or name isn't empty. + # - That is validated again during execution. + # - This way e.g. model modifiers can add content to body. self.test = self.suite.tests.create(name=node.name, lineno=node.lineno, tags=settings.test_tags, timeout=settings.test_timeout, - template=settings.test_template, - error=error) + template=settings.test_template) if settings.test_setup: self.test.setup.config(**settings.test_setup) if settings.test_teardown: @@ -278,11 +280,13 @@ def __init__(self, resource: ResourceFile, settings: FileSettings): self.kw = None def visit_Keyword(self, node): - error = format_error(node.errors + node.header.errors) + # Possible parsing errors aren't reported further because: + # - We only validate that keyword body or name isn't empty. + # - That is validated again during execution. + # - This way e.g. model modifiers can add content to body. self.kw = self.resource.keywords.create(name=node.name, tags=self.settings.keyword_tags, - lineno=node.lineno, - error=error) + lineno=node.lineno) self.generic_visit(node) def visit_Documentation(self, node): diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index 729ce286ec7..d05856017e9 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -49,6 +49,10 @@ def __init__(self, resource, resource_file=True): def _create_handler(self, kw): if kw.error: raise DataError(kw.error) + if not kw.body and not kw.return_: + raise DataError('User keyword cannot be empty.') + if not kw.name: + raise DataError('User keyword name cannot be empty.') embedded = EmbeddedArguments.from_name(kw.name) if not embedded: return UserKeywordHandler(kw, self.name) diff --git a/utest/running/test_userlibrary.py b/utest/running/test_userlibrary.py index e3210b088f4..dfabecb2af8 100644 --- a/utest/running/test_userlibrary.py +++ b/utest/running/test_userlibrary.py @@ -120,7 +120,10 @@ def test_handlers_getitem_with_existing_keyword(self): def _get_userlibrary(self, *keywords, **conf): resource = ResourceFile(**conf) - resource.keywords = [UserKeyword(name) for name in keywords] + for name in keywords: + kw = UserKeyword(name) + kw.body.create_keyword('No Operation') + resource.keywords.append(kw) return UserLibrary(resource, resource_file='source' in conf) def _lib_has_embedded_arg_keyword(self, lib, count=1): From 53fd1cb12d0b6eaca4584bf486c629d41c0c49a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 10 Oct 2023 11:09:30 +0300 Subject: [PATCH 0754/1592] JSON schema: List 'Error' as last body item --- doc/schema/running.json | 82 +++++++++++++++---------------- doc/schema/running_json_schema.py | 10 ++-- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/doc/schema/running.json b/doc/schema/running.json index f216909f436..13d34124e90 100644 --- a/doc/schema/running.json +++ b/doc/schema/running.json @@ -37,8 +37,8 @@ ], "additionalProperties": false }, - "Error": { - "title": "Error", + "Break": { + "title": "Break", "type": "object", "properties": { "lineno": { @@ -49,28 +49,17 @@ "title": "Error", "type": "string" }, - "values": { - "title": "Values", - "type": "array", - "items": { - "type": "string" - } - }, "type": { "title": "Type", - "default": "ERROR", - "const": "ERROR", + "default": "BREAK", + "const": "BREAK", "type": "string" } }, - "required": [ - "error", - "values" - ], "additionalProperties": false }, - "Break": { - "title": "Break", + "Continue": { + "title": "Continue", "type": "object", "properties": { "lineno": { @@ -83,15 +72,15 @@ }, "type": { "title": "Type", - "default": "BREAK", - "const": "BREAK", + "default": "CONTINUE", + "const": "CONTINUE", "type": "string" } }, "additionalProperties": false }, - "Continue": { - "title": "Continue", + "Return": { + "title": "Return", "type": "object", "properties": { "lineno": { @@ -102,17 +91,27 @@ "title": "Error", "type": "string" }, + "values": { + "title": "Values", + "type": "array", + "items": { + "type": "string" + } + }, "type": { "title": "Type", - "default": "CONTINUE", - "const": "CONTINUE", + "default": "RETURN", + "const": "RETURN", "type": "string" } }, + "required": [ + "values" + ], "additionalProperties": false }, - "Return": { - "title": "Return", + "Error": { + "title": "Error", "type": "object", "properties": { "lineno": { @@ -132,12 +131,13 @@ }, "type": { "title": "Type", - "default": "RETURN", - "const": "RETURN", + "default": "ERROR", + "const": "ERROR", "type": "string" } }, "required": [ + "error", "values" ], "additionalProperties": false @@ -199,9 +199,6 @@ { "$ref": "#/definitions/Try" }, - { - "$ref": "#/definitions/Error" - }, { "$ref": "#/definitions/Break" }, @@ -210,6 +207,9 @@ }, { "$ref": "#/definitions/Return" + }, + { + "$ref": "#/definitions/Error" } ] } @@ -297,9 +297,6 @@ { "$ref": "#/definitions/Try" }, - { - "$ref": "#/definitions/Error" - }, { "$ref": "#/definitions/Break" }, @@ -308,6 +305,9 @@ }, { "$ref": "#/definitions/Return" + }, + { + "$ref": "#/definitions/Error" } ] } @@ -398,9 +398,6 @@ { "$ref": "#/definitions/Try" }, - { - "$ref": "#/definitions/Error" - }, { "$ref": "#/definitions/Break" }, @@ -409,6 +406,9 @@ }, { "$ref": "#/definitions/Return" + }, + { + "$ref": "#/definitions/Error" } ] } @@ -487,9 +487,6 @@ { "$ref": "#/definitions/Try" }, - { - "$ref": "#/definitions/Error" - }, { "$ref": "#/definitions/Break" }, @@ -498,6 +495,9 @@ }, { "$ref": "#/definitions/Return" + }, + { + "$ref": "#/definitions/Error" } ] } @@ -729,10 +729,10 @@ "$ref": "#/definitions/Try" }, { - "$ref": "#/definitions/Error" + "$ref": "#/definitions/Return" }, { - "$ref": "#/definitions/Return" + "$ref": "#/definitions/Error" } ] } diff --git a/doc/schema/running_json_schema.py b/doc/schema/running_json_schema.py index 3ac83743586..388b1f8f588 100755 --- a/doc/schema/running_json_schema.py +++ b/doc/schema/running_json_schema.py @@ -60,7 +60,7 @@ class For(BodyItem): start: str | None mode: str | None fill: str | None - body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] + body: list['Keyword | For | While | If | Try | Break | Continue | Return | Error'] class While(BodyItem): @@ -69,13 +69,13 @@ class While(BodyItem): limit: str | None on_limit: str | None on_limit_message: str | None - body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] + body: list['Keyword | For | While | If | Try | Break | Continue | Return | Error'] class IfBranch(BodyItem): type: Literal['IF', 'ELSE IF', 'ELSE'] condition: str | None - body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] + body: list['Keyword | For | While | If | Try | Break | Continue | Return | Error'] class If(BodyItem): @@ -88,7 +88,7 @@ class TryBranch(BodyItem): patterns: Sequence[str] | None pattern_type: str | None assign: str | None - body: list['Keyword | For | While | If | Try | Error | Break | Continue | Return'] + body: list['Keyword | For | While | If | Try | Break | Continue | Return | Error'] class Try(BodyItem): @@ -158,7 +158,7 @@ class UserKeyword(BaseModel): lineno: int | None error: str | None teardown: Keyword | None - body: list[Keyword | For | While | If | Try | Error | Return] + body: list[Keyword | For | While | If | Try | Return | Error] class Resource(BaseModel): From 71b91304bbf6fa9867fb6c832ff1c5a2985b4170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 10 Oct 2023 11:49:40 +0300 Subject: [PATCH 0755/1592] UG: Document that `[Return]` is deprecated. #4876 --- .../CreatingTestData/CreatingUserKeywords.rst | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 880eda7814a..7f9bb323c81 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -68,10 +68,6 @@ this section. `[Arguments]`:setting: Specifies `user keyword arguments`_. -`[Return]`:setting: - Specifies `user keyword return values`_. `RETURN` statement (new in RF 5.0) - should be used instead. - `[Teardown]`:setting: Specify `user keyword teardown`_. @@ -79,6 +75,10 @@ this section. Sets the possible `user keyword timeout`_. Timeouts_ are discussed in a section of their own. +`[Return]`:setting: + Specifies `user keyword return values`_. Deprecated in Robot Framework 7.0, + the RETURN_ statement should be used instead. + .. note:: The format used above is recommended, but setting names are case-insensitive and spaces are allowed between brackets and the name. For example, `[ TAGS ]`:setting is valid. @@ -890,10 +890,10 @@ User keyword return values Similarly as library keywords, also user keywords can return values. When using Robot Framework 5.0 or newer, the recommended approach is -using the native `RETURN` statement. Old :setting:`[Return]` -setting and BuiltIn_ keywords :name:`Return From Keyword` and -:name:`Return From Keyword If` still work but they will be deprecated -and removed in the future. +using the native RETURN_ statement. The old :setting:`[Return]` +setting was deprecated in Robot Framework 7.0 and also BuiltIn_ keywords +:name:`Return From Keyword` and :name:`Return From Keyword If` are considered +deprecated. Regardless how values are returned, they can be `assigned to variables`__ in test cases and in other user keywords. @@ -969,7 +969,6 @@ If you want to test the above examples yourself, you can use them with these tes ${index} = Find Index non existing ${list} Should Be Equal ${index} ${-1} - .. note:: `RETURN` syntax is case-sensitive similarly as IF_ and FOR_. .. note:: `RETURN` is new in Robot Framework 5.0. Use approaches explained @@ -997,11 +996,10 @@ can be created using it. Return Three Values [Return] a b c -.. note:: The :setting:`[Return]` setting is effectively deprecated and the `RETURN` - statement should be used unless there is a need to support also older - versions than Robot Framework 5.0. There is no visible deprecation warning - when using the setting yet, but it will be loudly deprecated and eventually - removed in the future. +.. note:: The :setting:`[Return]` setting was deprecated in Robot Framework 7.0 + and the `RETURN` statement should be used instead. If there is a need + to support older Robot Framework versions that do not support `RETURN`, + it is possible to use the special keywords discussed in the next section. Using special keywords to return ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From cd22336b77f1345a3f85f0ccad3eef2f1b26cd16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 10 Oct 2023 15:37:15 +0300 Subject: [PATCH 0756/1592] Support `[Setup]` with keywords. Fixes #4747. --- atest/robot/core/keyword_setup.robot | 46 +++++++++++ atest/robot/parsing/translations.robot | 1 + atest/testdata/core/keyword_setup.robot | 78 +++++++++++++++++++ .../translations/custom/custom_per_file.robot | 1 + .../parsing/translations/custom/tests.robot | 1 + .../parsing/translations/finnish/tests.robot | 1 + .../translations/per_file_config/fi.robot | 1 + doc/schema/running.json | 3 + doc/schema/running_json_schema.py | 1 + .../src/Appendices/AvailableSettings.rst | 9 ++- .../CreatingTestData/ControlStructures.rst | 12 +-- .../CreatingTestData/CreatingTestCases.rst | 2 +- .../CreatingTestData/CreatingUserKeywords.rst | 47 +++++++---- .../src/ExecutingTestCases/TestExecution.rst | 24 ++++-- doc/userguide/src/RobotFrameworkUserGuide.rst | 2 - src/robot/model/visitor.py | 5 ++ src/robot/parsing/lexer/settings.py | 1 + src/robot/reporting/jsmodelbuilders.py | 2 + src/robot/result/model.py | 34 ++++++-- src/robot/running/builder/transformers.py | 6 +- src/robot/running/model.py | 32 +++++++- src/robot/running/userkeyword.py | 1 + src/robot/running/userkeywordrunner.py | 12 +-- utest/parsing/test_lexer.py | 23 +++--- utest/reporting/test_jsmodelbuilders.py | 7 ++ utest/result/test_visitor.py | 8 +- utest/running/test_run_model.py | 5 +- utest/running/test_userhandlers.py | 1 + 28 files changed, 300 insertions(+), 66 deletions(-) create mode 100644 atest/robot/core/keyword_setup.robot create mode 100644 atest/testdata/core/keyword_setup.robot diff --git a/atest/robot/core/keyword_setup.robot b/atest/robot/core/keyword_setup.robot new file mode 100644 index 00000000000..f7ba4f05495 --- /dev/null +++ b/atest/robot/core/keyword_setup.robot @@ -0,0 +1,46 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} core/keyword_setup.robot +Resource atest_resource.robot + +*** Test Cases *** +Passing setup + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc.body[0].setup.msgs[0]} Hello, setup! + +Failing setup + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc.body[0].setup.msgs[0]} Hello, setup! FAIL + Should Be Equal ${tc.body[0].body[0].status} NOT RUN + +Failing setup and passing teardown + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc.setup.setup.msgs[0]} Hello, setup! FAIL + Should Be Equal ${tc.setup.body[0].status} NOT RUN + Check Log Message ${tc.setup.teardown.msgs[0]} Hello, teardown! INFO + +Failing setup and teardown + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc.body[0].setup.msgs[0]} Hello, setup! FAIL + Should Be Equal ${tc.body[0].body[0].status} NOT RUN + Check Log Message ${tc.body[0].teardown.msgs[0]} Hello, teardown! FAIL + +Continue-on-failure mode is not enabled in setup + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc.setup.setup.body[0].msgs[0]} Hello, setup! INFO + Check Log Message ${tc.setup.setup.body[1].msgs[0]} Hello again, setup! FAIL + Should Be Equal ${tc.setup.setup.body[2].status} NOT RUN + +NONE is same as no setup + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.body[0].setup.name} ${None} + +Empty [Setup] is same as no setup + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.body[0].setup.name} ${None} + +Using variable + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.body[0].setup.name} Log + Should Be Equal ${tc.body[1].setup.name} ${None} + Should Be Equal ${tc.body[2].setup.name} ${None} + Should Be Equal ${tc.body[3].setup.name} Fail diff --git a/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot index a9fe09e2799..dd171f36b9f 100644 --- a/atest/robot/parsing/translations.robot +++ b/atest/robot/parsing/translations.robot @@ -86,6 +86,7 @@ Validate Translations Should Be Equal ${tc.body[0].doc} Keyword documentation. Should Be Equal ${tc.body[0].tags} ${{['keyword', 'tags', 'own tag']}} Should Be Equal ${tc.body[0].timeout} 1 hour + Should Be Equal ${tc.body[0].setup.full_name} BuiltIn.Log Should Be Equal ${tc.body[0].teardown.full_name} BuiltIn.No Operation Validate Task Translations diff --git a/atest/testdata/core/keyword_setup.robot b/atest/testdata/core/keyword_setup.robot new file mode 100644 index 00000000000..bb4b3e12154 --- /dev/null +++ b/atest/testdata/core/keyword_setup.robot @@ -0,0 +1,78 @@ +*** Test Cases *** +Passing setup + Passing setup + +Failing setup + [Documentation] FAIL Hello, setup! + Failing setup + +Failing setup and passing teardown + [Documentation] FAIL Setup failed:\nHello, setup! + [Setup] Failing setup and passing teardown + No Operation + +Failing setup and teardown + [Documentation] FAIL Hello, setup! + ... + ... Also keyword teardown failed: + ... Hello, teardown! + Failing setup and teardown + +Continue-on-failure mode is not enabled in setup + [Documentation] FAIL Setup failed:\nHello again, setup! + [Setup] Continue-on-failure mode is not enabled in setup + No Operation + +NONE is same as no setup + NONE is same as no setup + +Empty [Setup] is same as no setup + Empty [Setup] is same as no setup + +Using variable + [Documentation] FAIL Hello, setup! + Using variable Log + Using variable None + Using variable ${None} + Using variable Fail + +*** Keywords *** +Passing setup + [Setup] Log Hello, setup! + Log Hello, body! + +Failing setup + [Setup] Fail Hello, setup! + Fail Not executed + +Failing setup and passing teardown + [Setup] Fail Hello, setup! + Fail Not executed + [Teardown] Log Hello, teardown! + +Failing setup and teardown + [Setup] Fail Hello, setup! + Fail Not executed + [Teardown] Fail Hello, teardown! + +Continue-on-failure mode is not enabled in setup + [Setup] Multiple failures + Fail Not executed + +Multiple failures + Log Hello, setup! + Fail Hello again, setup! + Fail Not executed + +NONE is same as no setup + [Setup] NONE + No Operation + +Empty [Setup] is same as no setup + [Setup] + No Operation + +Using variable + [Arguments] ${setup} + [Setup] ${setup} Hello, setup! + No Operation diff --git a/atest/testdata/parsing/translations/custom/custom_per_file.robot b/atest/testdata/parsing/translations/custom/custom_per_file.robot index a32d124e97f..e6ad3c1030a 100644 --- a/atest/testdata/parsing/translations/custom/custom_per_file.robot +++ b/atest/testdata/parsing/translations/custom/custom_per_file.robot @@ -55,6 +55,7 @@ Keyword [a] ${arg} [ta] own tag [tI] 1h + [S] Log Hello, setup! Should Be Equal ${arg} ${VARIABLE} [TEA] No Operation diff --git a/atest/testdata/parsing/translations/custom/tests.robot b/atest/testdata/parsing/translations/custom/tests.robot index c45e1aa41f0..eebc0cdd306 100644 --- a/atest/testdata/parsing/translations/custom/tests.robot +++ b/atest/testdata/parsing/translations/custom/tests.robot @@ -54,6 +54,7 @@ Keyword [a] ${arg} [ta] own tag [tI] 1h + [S] Log Hello, setup! Should Be Equal ${arg} ${VARIABLE} [TEA] No Operation diff --git a/atest/testdata/parsing/translations/finnish/tests.robot b/atest/testdata/parsing/translations/finnish/tests.robot index 56b939d4c83..dc2371359be 100644 --- a/atest/testdata/parsing/translations/finnish/tests.robot +++ b/atest/testdata/parsing/translations/finnish/tests.robot @@ -54,6 +54,7 @@ Keyword [Argumentit] ${arg} [Tagit] own tag [Aikaraja] 1h + [Alustus] Log Hello, setup! Should Be Equal ${arg} ${VARIABLE} [Alasajo] No Operation diff --git a/atest/testdata/parsing/translations/per_file_config/fi.robot b/atest/testdata/parsing/translations/per_file_config/fi.robot index dcae91c4ea9..9ca5a341aa7 100644 --- a/atest/testdata/parsing/translations/per_file_config/fi.robot +++ b/atest/testdata/parsing/translations/per_file_config/fi.robot @@ -56,6 +56,7 @@ Keyword [Argumentit] ${arg} [Tagit] own tag [Aikaraja] 1h + [Alustus] Log Hello, setup! Should Be Equal ${arg} ${VARIABLE} [Alasajo] No Operation diff --git a/doc/schema/running.json b/doc/schema/running.json index 13d34124e90..8cec93b929b 100644 --- a/doc/schema/running.json +++ b/doc/schema/running.json @@ -705,6 +705,9 @@ "title": "Error", "type": "string" }, + "setup": { + "$ref": "#/definitions/Keyword" + }, "teardown": { "$ref": "#/definitions/Keyword" }, diff --git a/doc/schema/running_json_schema.py b/doc/schema/running_json_schema.py index 388b1f8f588..1353d8a9991 100755 --- a/doc/schema/running_json_schema.py +++ b/doc/schema/running_json_schema.py @@ -157,6 +157,7 @@ class UserKeyword(BaseModel): timeout: str | None lineno: int | None error: str | None + setup: Keyword | None teardown: Keyword | None body: list[Keyword | For | While | If | Try | Return | Error] diff --git a/doc/userguide/src/Appendices/AvailableSettings.rst b/doc/userguide/src/Appendices/AvailableSettings.rst index c623010a9a2..66a019528c4 100644 --- a/doc/userguide/src/Appendices/AvailableSettings.rst +++ b/doc/userguide/src/Appendices/AvailableSettings.rst @@ -48,7 +48,7 @@ importing libraries, resources, and variables. | Force Tags, | `Deprecated settings`__ for specifying test case tags. | | Default Tags | | +-----------------+--------------------------------------------------------+ - | Keyword Tags | User for specifying `user keyword tags`_ for all | + | Keyword Tags | Used for specifying `user keyword tags`_ for all | | | keywords in a certain file. | +-----------------+--------------------------------------------------------+ | Test Setup | Used for specifying a default `test setup`_. | @@ -116,9 +116,14 @@ which they are defined. +-----------------+--------------------------------------------------------+ | [Arguments] | Used for specifying `user keyword arguments`_. | +-----------------+--------------------------------------------------------+ - | [Return] | Used for specifying `user keyword return values`_. | + | [Setup] | Used for specifying a `user keyword setup`_. | + | | New in Robot Framework 7.0. | +-----------------+--------------------------------------------------------+ | [Teardown] | Used for specifying `user keyword teardown`_. | +-----------------+--------------------------------------------------------+ | [Timeout] | Used for specifying a `user keyword timeout`_. | +-----------------+--------------------------------------------------------+ + | [Return] | Used for specifying `user keyword return values`_. | + | | Deprecated in Robot Framework 7.0. Use the RETURN_ | + | | statement instead. | + +-----------------+--------------------------------------------------------+ diff --git a/doc/userguide/src/CreatingTestData/ControlStructures.rst b/doc/userguide/src/CreatingTestData/ControlStructures.rst index bf07cd8d06b..2252bc89860 100644 --- a/doc/userguide/src/CreatingTestData/ControlStructures.rst +++ b/doc/userguide/src/CreatingTestData/ControlStructures.rst @@ -990,7 +990,7 @@ Other ways to execute keywords conditionally There are also other methods to execute keywords conditionally: -- The name of the keyword used as a setup or a teardown with tests__, suites__ or +- The name of the keyword used as a setup or a teardown with suites__, tests__ and keywords__ can be specified using a variable. This facilitates changing them, for example, from the command line. @@ -1009,10 +1009,9 @@ There are also other methods to execute keywords conditionally: - There are several BuiltIn_ keywords that allow executing a named keyword only if a test case or test suite has failed or passed. -__ `Test setup and teardown`_ __ `Suite setup and teardown`_ -__ `Keyword teardown`_ - +__ `Test setup and teardown`_ +__ `User keyword setup and teardown`_ .. _try/except: @@ -1291,9 +1290,12 @@ There are also other methods to execute keywords conditionally: aforementioned :name:`Run Keyword And Ignore Error`. The native syntax is nowadays recommended instead. -- `Test teardowns`_ and `keyword teardowns`_ can be used for cleaning up activities +- `Test teardowns`__ and `keyword teardowns`__ can be used for cleaning up activities similarly as `FINALLY` branches. - When keywords are implemented in Python based libraries_, all Python's error handling features are readily available. This is the recommended approach especially if needed logic gets more complicated. + +__ `Test teardown`_ +__ `User keyword teardown`_ diff --git a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst index 0651f2de867..5fc57afb52e 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestCases.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestCases.rst @@ -810,7 +810,7 @@ that is executed before a test case, and a test teardown is executed after a test case. In Robot Framework setups and teardowns are just normal keywords with possible arguments. -Setup and teardown are always a single keyword. If they need to take care +A setup and a teardown are always a single keyword. If they need to take care of multiple separate tasks, it is possible to create higher-level `user keywords`_ for that purpose. An alternative solution is executing multiple keywords using the BuiltIn_ keyword :name:`Run Keywords`. diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 7f9bb323c81..24df8c78868 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -68,8 +68,9 @@ this section. `[Arguments]`:setting: Specifies `user keyword arguments`_. -`[Teardown]`:setting: - Specify `user keyword teardown`_. +`[Setup]`:setting:, `[Teardown]`:setting: + Specify `user keyword setup and teardown`_. `[Setup]`:setting: is new in + Robot Framework 7.0. `[Timeout]`:setting: Sets the possible `user keyword timeout`_. Timeouts_ are discussed @@ -1046,34 +1047,48 @@ ones are more verbose: 5.0. There is no visible deprecation warning when using these keywords yet, but they will be loudly deprecated and eventually removed in the future. -User keyword teardown ---------------------- +User keyword setup and teardown +------------------------------- + +A user keyword can have a setup and a teardown similarly as tests__. +They are specified using :setting:`[Setup]` and :setting:`[Teardown]` +settings, respectively, directly to the keyword having them. Unlike with +tests, it is not possible to specify a common setup or teardown to all +keywords in a certain file. -User keywords may have a teardown defined using :setting:`[Teardown]` setting. +A setup and a teardown are always a single keyword, but they can themselves be +user keywords executing multiple keywords internally. It is possible to specify +them as variables, and using a special `NONE` value (case-insensitive) is +the same as not having a setup or a teardown at all. -Keyword teardown works much in the same way as a `test case -teardown`__. Most importantly, the teardown is always a single -keyword, although it can be another user keyword, and it gets executed -also when the user keyword fails. In addition, all steps of the -teardown are executed even if one of them fails. However, a failure in -keyword teardown will fail the test case and subsequent steps in the -test are not run. The name of the keyword to be executed as a teardown -can also be a variable. +User keyword setup is not much different to the first keyword inside the created +user keyword. The only functional difference is that a setup can be specified as +a variable, but it can also be useful to be able to explicitly mark a keyword +to be a setup. + +User keyword teardowns are, exactly as test teardowns, executed also if the user +keyword fails. They are thus very useful when needing to do something at the +end of the keyword regardless of its status. To ensure that all cleanup activities +are done, the `continue on failure`_ mode is enabled by default with user keyword +teardowns the same way as with test teardowns. .. sourcecode:: robotframework *** Keywords *** - With Teardown + Setup and teardown + [Setup] Log New in RF 7! Do Something - [Teardown] Log keyword teardown + [Teardown] Log Old feature. Using variables - [Documentation] Teardown given as variable + [Setup] ${SETUP} Do Something [Teardown] ${TEARDOWN} __ `test setup and teardown`_ +.. note:: User keyword setups are new in Robot Framework 7.0. + Private user keywords --------------------- diff --git a/doc/userguide/src/ExecutingTestCases/TestExecution.rst b/doc/userguide/src/ExecutingTestCases/TestExecution.rst index b56f32732de..56c1e928f5a 100644 --- a/doc/userguide/src/ExecutingTestCases/TestExecution.rst +++ b/doc/userguide/src/ExecutingTestCases/TestExecution.rst @@ -45,9 +45,9 @@ Setups and teardowns Setups and teardowns can be used on `test suite`__, `test case`__ and `user keyword`__ levels. -__ `Test setup and teardown`_ __ `Suite setup and teardown`_ -__ `User keyword teardown`_ +__ `Test setup and teardown`_ +__ `User keyword setup and teardown`_ Suite setup ''''''''''' @@ -98,13 +98,21 @@ Similarly as suite teardown, test teardowns are used mainly for cleanup activities. Also they are executed fully even if some of their keywords fail. -Keyword teardown -'''''''''''''''' +User keyword setup +'''''''''''''''''' + +User keyword setup is executed before the keyword body. If the setup fails, +the body is not executed. There is not much difference between the keyword +setup and the first keyword in the body. + +.. note:: User keyword setups are new in Robot Framework 7.0. + +User keyword teardown +''''''''''''''''''''' -`User keywords`_ cannot have setups, but they can have teardowns that work -exactly like other teardowns. Keyword teardowns are run after the keyword is -executed otherwise, regardless the status, and they are executed fully even -if some of their keywords fail. +User keyword teardown is run after the keyword is executed otherwise, regardless +the status. User keyword teardowns are executed fully even if some of their +keywords would fail. Execution order ~~~~~~~~~~~~~~~ diff --git a/doc/userguide/src/RobotFrameworkUserGuide.rst b/doc/userguide/src/RobotFrameworkUserGuide.rst index cf8cbe5dbc4..ca7328c3514 100644 --- a/doc/userguide/src/RobotFrameworkUserGuide.rst +++ b/doc/userguide/src/RobotFrameworkUserGuide.rst @@ -145,10 +145,8 @@ .. _test case documentation: `Test case name and documentation`_ .. _test setup: `Test setup and teardown`_ .. _test teardown: `Test setup and teardown`_ -.. _test teardowns: `Test teardown`_ .. _suite setup: `Suite setup and teardown`_ .. _suite teardown: `Suite setup and teardown`_ -.. _keyword teardowns: `Keyword teardown`_ .. _teardown: `Test teardown`_ .. _teardowns: teardown_ .. _tag: `Tagging test cases`_ diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 79418d05490..cf8f10275be 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -178,10 +178,15 @@ def visit_keyword(self, keyword: 'Keyword'): the body of the keyword """ if self.start_keyword(keyword) is not False: + self._possible_setup(keyword) self._possible_body(keyword) self._possible_teardown(keyword) self.end_keyword(keyword) + def _possible_setup(self, item: 'BodyItem'): + if getattr(item, 'has_setup', False): + item.setup.visit(self) # type: ignore + def _possible_body(self, item: 'BodyItem'): if hasattr(item, 'body'): item.body.visit(self) # type: ignore diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index 23ce33438c1..e5d7955927e 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -259,6 +259,7 @@ class KeywordSettings(Settings): names = ( 'Documentation', 'Arguments', + 'Setup', 'Teardown', 'Timeout', 'Tags', diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index 777337a4a75..4c8c518a428 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -154,6 +154,8 @@ def build_body_item(self, item, split=False): if isinstance (item, Keyword): self._context.check_expansion(item) body = item.body.flatten() + if item.has_setup: + body.insert(0, item.setup) if item.has_teardown: body.append(item.teardown) return self._build(item, item.name, item.owner, item.timeout, item.doc, item.args, diff --git a/src/robot/result/model.py b/src/robot/result/model.py index e8a3f516f31..8eb052e016e 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -666,7 +666,7 @@ class Keyword(model.Keyword, StatusMixin): """Represents an executed library or user keyword.""" body_class = Body __slots__ = ['owner', 'source_name', 'doc', 'timeout', 'status', 'message', - '_start_time', '_end_time', '_elapsed_time', '_teardown'] + '_start_time', '_end_time', '_elapsed_time', '_setup', '_teardown'] def __init__(self, name: 'str|None' = '', owner: 'str|None' = None, @@ -696,6 +696,7 @@ def __init__(self, name: 'str|None' = '', self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time + self._setup = None self._teardown = None self.body = () @@ -769,7 +770,29 @@ def sourcename(self) -> str: def sourcename(self, name: str): self.source_name = name - @property # Cannot use @setter because it would create teardowns recursively. + @property + def setup(self) -> 'Keyword': + """Keyword setup as a :class:`Keyword` object. + + See :attr:`teardown` for more information. New in Robot Framework 7.0. + """ + if self._setup is None: + self.setup = None + return self._setup + + @setup.setter + def setup(self, setup: 'Keyword|DataDict|None'): + self._setup = create_fixture(self.__class__, setup, self, self.SETUP) + + @property + def has_setup(self) -> bool: + """Check does a keyword have a setup without creating a setup object. + + See :attr:`has_teardown` for more information. New in Robot Framework 7.0. + """ + return bool(self._setup) + + @property def teardown(self) -> 'Keyword': """Keyword teardown as a :class:`Keyword` object. @@ -799,14 +822,9 @@ def teardown(self) -> 'Keyword': Framework 4.1.2. """ if self._teardown is None: - self._teardown = create_fixture(self.__class__, None, self, self.TEARDOWN) + self.teardown = None return self._teardown - @property - def has_setup(self): - # Placeholder until keyword setup is added in RF 7. - return False - @teardown.setter def teardown(self, teardown: 'Keyword|DataDict|None'): self._teardown = create_fixture(self.__class__, teardown, self, self.TEARDOWN) diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index b97cc33c77f..f55e913d029 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -312,9 +312,11 @@ def visit_Return(self, node): def visit_Timeout(self, node): self.kw.timeout = node.value + def visit_Setup(self, node): + self.kw.setup.config(name=node.name, args=node.args, lineno=node.lineno) + def visit_Teardown(self, node): - self.kw.teardown.config(name=node.name, args=node.args, - lineno=node.lineno) + self.kw.teardown.config(name=node.name, args=node.args, lineno=node.lineno) def visit_KeywordCall(self, node): self.kw.body.create_keyword(name=node.keyword, args=node.args, diff --git a/src/robot/running/model.py b/src/robot/running/model.py index d93f72c342a..8aed835960c 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -759,7 +759,7 @@ class UserKeyword(ModelObject): repr_args = ('name', 'args') fixture_class = Keyword __slots__ = ['name', 'args', 'doc', 'return_', 'timeout', 'lineno', 'parent', - 'error', '_teardown'] + 'error', '_setup', '_teardown'] def __init__(self, name: str = '', args: Sequence[str] = (), @@ -780,16 +780,40 @@ def __init__(self, name: str = '', self.parent = parent self.error = error self.body = [] + self._setup = None self._teardown = None @setter def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: return Body(self, body) + @property + def setup(self) -> Keyword: + """User keyword setup as a :class:`Keyword` object. + + New in Robot Framework 7.0. + """ + if self._setup is None: + self.setup = None + return self._setup + + @setup.setter + def setup(self, setup: 'Keyword|DataDict|None'): + self._setup = create_fixture(self.fixture_class, setup, self, Keyword.SETUP) + + @property + def has_setup(self) -> bool: + """Check does a keyword have a setup without creating a setup object. + + See :attr:`has_teardown` for more information. New in Robot Framework 7.0. + """ + return bool(self._setup) + @property def teardown(self) -> Keyword: + """User keyword teardown as a :class:`Keyword` object.""" if self._teardown is None: - self._teardown = create_fixture(self.fixture_class, None, self, Keyword.TEARDOWN) + self.teardown = None return self._teardown @teardown.setter @@ -800,7 +824,7 @@ def teardown(self, teardown: 'Keyword|DataDict|None'): def has_teardown(self) -> bool: """Check does a keyword have a teardown without creating a teardown object. - A difference between using ``if uk.has_teardown:`` and ``if uk.teardown:`` + A difference between using ``if kw.has_teardown:`` and ``if kw.teardown:`` is that accessing the :attr:`teardown` attribute creates a :class:`Keyword` object representing the teardown even when the user keyword actually does not have one. This can have an effect on memory usage. @@ -828,6 +852,8 @@ def to_dict(self) -> DataDict: ('error', self.error)]: if value: data[name] = value + if self.has_setup: + data['setup'] = self.setup.to_dict() data['body'] = self.body.to_dicts() if self.has_teardown: data['teardown'] = self.teardown.to_dict() diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py index d05856017e9..79d4969a239 100644 --- a/src/robot/running/userkeyword.py +++ b/src/robot/running/userkeyword.py @@ -83,6 +83,7 @@ def __init__(self, keyword, owner): self.timeout = keyword.timeout self.body = keyword.body self.return_value = tuple(keyword.return_) + self.setup = keyword.setup if keyword.has_setup else None self.teardown = keyword.teardown if keyword.has_teardown else None @property diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index cafa474d4e8..6095552ab37 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -163,8 +163,10 @@ def _execute(self, context): if context.dry_run and handler.tags.robot('no-dry-run'): return None, None error = return_ = pass_ = None + if handler.setup: + error = self._run_setup_or_teardown(handler.setup, context) try: - BodyRunner(context).run(handler.body) + BodyRunner(context, run=not error).run(handler.body) except ReturnFromKeyword as exception: return_ = exception error = exception.earlier_failures @@ -179,7 +181,7 @@ def _execute(self, context): error = exception if handler.teardown: with context.keyword_teardown(error): - td_error = self._run_teardown(handler.teardown, context) + td_error = self._run_setup_or_teardown(handler.teardown, context) else: td_error = None if error or td_error: @@ -200,9 +202,9 @@ def _get_return_value(self, variables, return_): return ret return ret[0] - def _run_teardown(self, teardown, context): + def _run_setup_or_teardown(self, item, context): try: - name = context.variables.replace_string(teardown.name) + name = context.variables.replace_string(item.name) except DataError as err: if context.dry_run: return None @@ -210,7 +212,7 @@ def _run_teardown(self, teardown, context): if name.upper() in ('', 'NONE'): return None try: - KeywordRunner(context).run(teardown, name) + KeywordRunner(context).run(item, name) except PassExecution: return None except ExecutionStatus as err: diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index b377d6e2a3f..792f24bea04 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -464,6 +464,7 @@ def test_keyword_settings(self): [Documentation] Doc in multiple ... parts [Tags] first second + [Setup] Log New in RF 7! [Teardown] No Operation [Timeout] ${TIMEOUT} [Return] Value @@ -488,16 +489,20 @@ def test_keyword_settings(self): (T.ARGUMENT, 'first', 6, 23), (T.ARGUMENT, 'second', 6, 32), (T.EOS, '', 6, 38), - (T.TEARDOWN, '[Teardown]', 7, 4), - (T.NAME, 'No Operation', 7, 23), - (T.EOS, '', 7, 35), - (T.TIMEOUT, '[Timeout]', 8, 4), - (T.ARGUMENT, '${TIMEOUT}', 8, 23), - (T.EOS, '', 8, 33), - (T.RETURN, '[Return]', 9, 4, + (T.SETUP, '[Setup]', 7, 4), + (T.NAME, 'Log', 7, 23), + (T.ARGUMENT, 'New in RF 7!', 7, 30), + (T.EOS, '', 7, 42), + (T.TEARDOWN, '[Teardown]', 8, 4), + (T.NAME, 'No Operation', 8, 23), + (T.EOS, '', 8, 35), + (T.TIMEOUT, '[Timeout]', 9, 4), + (T.ARGUMENT, '${TIMEOUT}', 9, 23), + (T.EOS, '', 9, 33), + (T.RETURN, '[Return]', 10, 4, "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead."), - (T.ARGUMENT, 'Value', 9, 23), - (T.EOS, '', 9, 28) + (T.ARGUMENT, 'Value', 10, 23), + (T.EOS, '', 10, 28) ] assert_tokens(data, expected, get_tokens, data_only=True) assert_tokens(data, expected, get_resource_tokens, data_only=True) diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index d9157dff23c..20b4f41091f 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -99,6 +99,13 @@ def test_keyword_with_body(self): exp2 = self._verify_keyword(root.body.create_keyword('C2'), name='C2') self._verify_keyword(root, name='Root', body=(exp1, exp2)) + def test_keyword_with_setup(self): + root = Keyword('Root') + s = self._verify_keyword(root.setup.config(name='S'), type=1, name='S') + self._verify_keyword(root, name='Root', body=(s,)) + k = self._verify_keyword(root.body.create_keyword('K'), name='K') + self._verify_keyword(root, name='Root', body=(s, k)) + def test_keyword_with_teardown(self): root = Keyword('Root') t = self._verify_keyword(root.teardown.config(name='T'), type=2, name='T') diff --git a/utest/result/test_visitor.py b/utest/result/test_visitor.py index c0104e09d31..0f512ae5172 100644 --- a/utest/result/test_visitor.py +++ b/utest/result/test_visitor.py @@ -41,17 +41,19 @@ def test_visit_setups_and_teardowns(self): self.suite.visit(visitor) assert_equal(visitor.visited, ['SS', 'TS', 'TT', 'ST']) - def test_visit_keyword_teardown(self): + def test_visit_keyword_setup_and_teardown(self): suite = ResultSuite() suite.setup.config(name='SS') suite.teardown.config(name='ST') test = suite.tests.create() test.setup.config(name='TS') test.teardown.config(name='TT') - test.body.create_keyword().teardown.config(name='KT') + kw = test.body.create_keyword() + kw.setup.config(name='KS') + kw.teardown.config(name='KT') visitor = VisitSetupsAndTeardowns() suite.visit(visitor) - assert_equal(visitor.visited, ['SS', 'TS', 'KT', 'TT', 'ST']) + assert_equal(visitor.visited, ['SS', 'TS', 'KS', 'KT', 'TT', 'ST']) def test_dont_visit_inactive_setups_and_teardowns(self): suite = ResultSuite() diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index a050c960f10..c5aace0f1c1 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -15,8 +15,7 @@ Return, ResourceFile, TestCase, TestDefaults, TestSuite, Try, TryBranch, While) from robot.running.model import UserKeyword -from robot.utils.asserts import (assert_equal, assert_false, assert_not_equal, - assert_raises, assert_true) +from robot.utils.asserts import assert_equal, assert_false, assert_not_equal CURDIR = Path(__file__).resolve().parent @@ -436,10 +435,12 @@ def test_user_keyword(self): def test_user_keyword_structure(self): uk = UserKeyword('UK') + uk.setup.config(name='Setup', args=('New', 'in', 'RF 7')) uk.body.create_keyword('K1') uk.body.create_if().body.create_branch(condition='$c').body.create_keyword('K2') uk.teardown.config(name='Teardown') self._verify(uk, name='UK', + setup={'name': 'Setup', 'args': ('New', 'in', 'RF 7')}, body=[{'name': 'K1'}, {'type': 'IF/ELSE ROOT', 'body': [{'type': 'IF', 'condition': '$c', diff --git a/utest/running/test_userhandlers.py b/utest/running/test_userhandlers.py index 25abf0bc89c..8e2d5dd7007 100644 --- a/utest/running/test_userhandlers.py +++ b/utest/running/test_userhandlers.py @@ -41,6 +41,7 @@ def __init__(self, name, args=[]): self.timeout = Fake() self.return_ = Fake() self.tags = () + self.has_setup = False self.has_teardown = False From a221379fe9a8284ecef371018a926b69960aef3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 10 Oct 2023 22:03:50 +0300 Subject: [PATCH 0757/1592] Cleanup --- src/robot/parsing/lexer/statementlexers.py | 15 +++++++------ src/robot/parsing/model/statements.py | 25 ++++++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index af6987d6b15..c3a51c3cc38 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -14,7 +14,6 @@ # limitations under the License. from abc import ABC, abstractmethod -from typing import List from robot.errors import DataError from robot.utils import normalize_whitespace @@ -58,14 +57,16 @@ def accepts_more(self, statement: StatementTokens) -> bool: def input(self, statement: StatementTokens): self.statement = statement + @abstractmethod def lex(self): raise NotImplementedError def _lex_options(self, *names: str, end_index: 'int|None' = None): for token in reversed(self.statement[:end_index]): - if not token.value.startswith(names): + if '=' in token.value and token.value.split('=')[0] in names: + token.type = Token.OPTION + else: break - token.type = Token.OPTION class SingleType(StatementLexer, ABC): @@ -225,9 +226,9 @@ def lex(self): else: token.type = Token.ASSIGN if separator == 'IN ENUMERATE': - self._lex_options('start=') + self._lex_options('start') elif separator == 'IN ZIP': - self._lex_options('mode=', 'fill=') + self._lex_options('mode', 'fill') class IfHeaderLexer(TypeAndArguments): @@ -298,7 +299,7 @@ def lex(self): token.type = Token.ASSIGN else: token.type = Token.ARGUMENT - self._lex_options('type=', end_index=as_index) + self._lex_options('type', end_index=as_index) class FinallyHeaderLexer(TypeAndArguments): @@ -318,7 +319,7 @@ def lex(self): self.statement[0].type = Token.WHILE for token in self.statement[1:]: token.type = Token.ARGUMENT - self._lex_options('limit=', 'on_limit=', 'on_limit_message=') + self._lex_options('limit', 'on_limit', 'on_limit_message') class EndLexer(TypeAndArguments): diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 05472ebc14a..5e74c3e17b1 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -18,7 +18,7 @@ import warnings from abc import ABC, abstractmethod from collections.abc import Iterator, Sequence -from typing import cast, ClassVar, overload, TYPE_CHECKING, Type, TypeVar +from typing import cast, ClassVar, Literal, overload, TYPE_CHECKING, Type, TypeVar from robot.conf import Language from robot.running.arguments import UserKeywordArgumentParser @@ -911,9 +911,12 @@ class ForHeader(Statement): type = Token.FOR @classmethod - def from_params(cls, assign: 'Sequence[str]', values: 'Sequence[str]', - flavor: str = 'IN', indent: str = FOUR_SPACES, - separator: str = FOUR_SPACES, eol: str = EOL) -> 'ForHeader': + def from_params(cls, assign: 'Sequence[str]', + values: 'Sequence[str]', + flavor: Literal['IN', 'IN RANGE', 'IN ENUMERATE', 'IN ZIP'] = 'IN', + indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, + eol: str = EOL) -> 'ForHeader': tokens = [Token(Token.SEPARATOR, indent), Token(Token.FOR), Token(Token.SEPARATOR, separator)] @@ -1167,13 +1170,13 @@ def from_params(cls, condition: str, limit: 'str|None' = None, Token(Token.SEPARATOR, separator), Token(Token.ARGUMENT, condition)] if limit: - tokens.extend([Token(Token.SEPARATOR, indent), + tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.OPTION, f'limit={limit}')]) if on_limit: - tokens.extend([Token(Token.SEPARATOR, indent), + tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.OPTION, f'on_limit={on_limit}')]) if on_limit_message: - tokens.extend([Token(Token.SEPARATOR, indent), + tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.OPTION, f'on_limit_message={on_limit_message}')]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -1211,10 +1214,6 @@ def _add_error(self, error: str): class ReturnStatement(Statement): type = Token.RETURN_STATEMENT - @property - def values(self): - return self.get_values(Token.ARGUMENT) - @classmethod def from_params(cls, values: 'Sequence[str]' = (), indent: str = FOUR_SPACES, separator: str = FOUR_SPACES, eol: str = EOL) -> 'ReturnStatement': @@ -1226,6 +1225,10 @@ def from_params(cls, values: 'Sequence[str]' = (), indent: str = FOUR_SPACES, tokens.append(Token(Token.EOL, eol)) return cls(tokens) + @property + def values(self) -> 'tuple[str, ...]': + return self.get_values(Token.ARGUMENT) + def validate(self, ctx: 'ValidationContext'): if not ctx.in_keyword: self.errors += ('RETURN can only be used inside a user keyword.',) From f411bf6c3565ef2c71c1bf3d1a4c68bf0608282f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 11 Oct 2023 14:10:49 +0300 Subject: [PATCH 0758/1592] Refactor storing variables from the Variables section. Also BuiltIn uses this functionality. The main motivation was to make using this easier with the forthcoming VAR syntax. (#3761) --- src/robot/libraries/BuiltIn.py | 7 +-- src/robot/running/model.py | 4 +- src/robot/running/namespace.py | 4 +- src/robot/running/suiterunner.py | 2 +- src/robot/variables/__init__.py | 2 +- src/robot/variables/scopes.py | 4 +- src/robot/variables/store.py | 2 +- src/robot/variables/tablesetter.py | 81 ++++++++++++++++-------------- src/robot/variables/variables.py | 2 +- 9 files changed, 58 insertions(+), 50 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index de80edf8f7a..9d70fec6ebd 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -36,7 +36,7 @@ from robot.utils.asserts import assert_equal, assert_not_equal from robot.variables import (evaluate_expression, is_dict_variable, is_list_variable, search_variable, - DictVariableTableValue, VariableTableValue) + DictVariableResolver, VariableResolver) from robot.version import get_version @@ -471,7 +471,7 @@ def create_dictionary(self, *items): """ separate, combined = self._split_dict_items(items) result = DotDict(self._format_separate_dict_items(separate)) - combined = DictVariableTableValue(combined).resolve(self._variables) + combined = DictVariableResolver(combined).resolve(self._variables) result.update(combined) return result @@ -1842,7 +1842,8 @@ def _get_var_value(self, name, values): f"is not supported anymore. Create list variable " f"'@{name[1:]}' instead.") return self._variables.replace_scalar(values[0]) - return VariableTableValue(values, name).resolve(self._variables) + resolver = VariableResolver.from_name_and_value(name, values) + return resolver.resolve(self._variables) def _log_set_variable(self, name, value): self.log(format_assign_message(name, value)) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 8aed835960c..e15782b13ab 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -641,7 +641,7 @@ def __init__(self, name: str = '', def source(self) -> 'Path|None': return self.parent.source if self.parent is not None else None - def report_invalid_syntax(self, message: str, level: str = 'ERROR'): + def report_error(self, message: str, level: str = 'ERROR'): source = self.source or '' line = f' on line {self.lineno}' if self.lineno else '' LOGGER.write(f"Error in file '{source}'{line}: " @@ -900,7 +900,7 @@ def select(self, library: Any, resource: Any, variables: Any) -> Any: self.RESOURCE: resource, self.VARIABLES: variables}[self.type] - def report_invalid_syntax(self, message: str, level: str = 'ERROR'): + def report_error(self, message: str, level: str = 'ERROR'): source = self.source or '' line = f' on line {self.lineno}' if self.lineno else '' LOGGER.write(f"Error in file '{source}'{line}: {message}", level) diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 82a6c675132..9b98c0f95e9 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -68,7 +68,7 @@ def _handle_imports(self, import_settings): raise DataError(f'{item.setting_name} setting requires value.') self._import(item) except DataError as err: - item.report_invalid_syntax(err.message) + item.report_error(err.message) def _import(self, import_setting): action = import_setting.select(self._import_library, @@ -84,7 +84,7 @@ def _import_resource(self, import_setting, overwrite=False): 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_table(resource.variables, overwrite) + self.variables.set_from_variable_section(resource.variables, overwrite) user_library = UserLibrary(resource) self._kw_store.resources[path] = user_library self._handle_imports(resource.imports) diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index ec4c50ddad8..ff2c45e05da 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -71,7 +71,7 @@ def start_suite(self, suite): self._settings.skip_teardown_on_exit) ns = Namespace(self._variables, result, suite.resource, self._settings.languages) ns.start_suite() - ns.variables.set_from_variable_table(suite.resource.variables) + ns.variables.set_from_variable_section(suite.resource.variables) EXECUTION_CONTEXTS.start_suite(result, ns, self._output, self._settings.dry_run) self._context.set_suite_variables(result) diff --git a/src/robot/variables/__init__.py b/src/robot/variables/__init__.py index 28bc559d46f..2cb4a44fdd7 100644 --- a/src/robot/variables/__init__.py +++ b/src/robot/variables/__init__.py @@ -29,5 +29,5 @@ is_dict_variable, is_dict_assign, is_list_variable, is_list_assign, VariableIterator) -from .tablesetter import VariableTableValue, DictVariableTableValue +from .tablesetter import VariableResolver, DictVariableResolver from .variables import Variables diff --git a/src/robot/variables/scopes.py b/src/robot/variables/scopes.py index 3092b995b93..b57ec079785 100644 --- a/src/robot/variables/scopes.py +++ b/src/robot/variables/scopes.py @@ -113,9 +113,9 @@ def set_from_file(self, path, args, overwrite=False): else: scope.set_from_file(variables, overwrite=overwrite) - def set_from_variable_table(self, variables, overwrite=False): + def set_from_variable_section(self, variables, overwrite=False): for scope in self._scopes_until_suite: - scope.set_from_variable_table(variables, overwrite) + scope.set_from_variable_section(variables, overwrite) def resolve_delayed(self): for scope in self._scopes_until_suite: diff --git a/src/robot/variables/store.py b/src/robot/variables/store.py index 94e6f96c25d..0aa24e548c3 100644 --- a/src/robot/variables/store.py +++ b/src/robot/variables/store.py @@ -46,7 +46,7 @@ def _resolve_delayed(self, name, value): # Recursive resolving may have already removed variable. if name in self.data: self.data.pop(name) - value.report_error(err) + value.report_error(str(err)) variable_not_found('${%s}' % name, self.data) return self.data[name] diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index 28af801ac62..b4d6dacfaa6 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -14,6 +14,7 @@ # limitations under the License. from contextlib import contextmanager +from typing import Sequence, TYPE_CHECKING from robot.errors import DataError from robot.utils import DotDict, is_string, split_from_equals @@ -21,52 +22,58 @@ from .resolvable import Resolvable from .search import is_assign, is_list_variable, is_dict_variable +if TYPE_CHECKING: + from robot.running.model import Variable + class VariableTableSetter: def __init__(self, store): self._store = store - def set(self, variables, overwrite=False): + def set(self, variables: 'Sequence[Variable]', overwrite: bool = False): for name, value in self._get_items(variables): self._store.add(name, value, overwrite, decorated=False) - def _get_items(self, variables): + def _get_items(self, variables: 'Sequence[Variable]'): for var in variables: - if var.error: - var.report_invalid_syntax(var.error) - continue try: - value = VariableTableValue(var.value, var.name, - var.report_invalid_syntax) + value = VariableResolver.from_variable(var) except DataError as err: - var.report_invalid_syntax(err) + var.report_error(str(err)) else: yield var.name[2:-1], value -def VariableTableValue(value, name, error_reporter=None): - if not is_assign(name): - raise DataError("Invalid variable name '%s'." % name) - VariableTableValue = {'$': ScalarVariableTableValue, - '@': ListVariableTableValue, - '&': DictVariableTableValue}[name[0]] - return VariableTableValue(value, error_reporter) - - -class VariableTableValueBase(Resolvable): +class VariableResolver(Resolvable): - def __init__(self, values, error_reporter=None): - self._values = self._format_values(values) - self._error_reporter = error_reporter + def __init__(self, value: 'str|Sequence[str]', error_reporter=None): + self.value = self._format_value(value) + self.error_reporter = error_reporter self._resolving = False - def _format_values(self, values): - return values + def _format_value(self, value): + return value + + @classmethod + def from_name_and_value(cls, name: str, value: 'str|Sequence[str]', + error_reporter=None) -> 'VariableResolver': + if not is_assign(name): + raise DataError(f"Invalid variable name '{name}'.") + klass = {'$': ScalarVariableResolver, + '@': ListVariableResolver, + '&': DictVariableResolver}[name[0]] + return klass(value, error_reporter) + + @classmethod + def from_variable(cls, var: 'Variable') -> 'VariableResolver': + if var.error: + raise DataError(var.error) + return cls.from_name_and_value(var.name, var.value, var.report_error) def resolve(self, variables): with self._avoid_recursion: - return self._replace_variables(self._values, variables) + return self._replace_variables(self.value, variables) @property @contextmanager @@ -83,13 +90,15 @@ def _replace_variables(self, value, variables): raise NotImplementedError def report_error(self, error): - if self._error_reporter: - self._error_reporter(str(error)) + if self.error_reporter: + self.error_reporter(error) + else: + raise DataError(f'Error reported not set. Reported error was: {error}') -class ScalarVariableTableValue(VariableTableValueBase): +class ScalarVariableResolver(VariableResolver): - def _format_values(self, values): + def _format_value(self, values): separator = None if is_string(values): values = [values] @@ -114,15 +123,15 @@ def _is_single_value(self, separator, values): not is_list_variable(values[0])) -class ListVariableTableValue(VariableTableValueBase): +class ListVariableResolver(VariableResolver): def _replace_variables(self, values, variables): return variables.replace_list(values) -class DictVariableTableValue(VariableTableValueBase): +class DictVariableResolver(VariableResolver): - def _format_values(self, values): + def _format_value(self, values): return list(self._yield_formatted(values)) def _yield_formatted(self, values): @@ -133,18 +142,16 @@ def _yield_formatted(self, values): name, value = split_from_equals(item) if value is None: raise DataError( - "Invalid dictionary variable item '%s'. " - "Items must use 'name=value' syntax or be dictionary " - "variables themselves." % item + f"Invalid dictionary variable item '{item}'. Items must use " + f"'name=value' syntax or be dictionary variables themselves." ) yield name, value def _replace_variables(self, values, variables): try: - return DotDict(self._yield_replaced(values, - variables.replace_scalar)) + return DotDict(self._yield_replaced(values, variables.replace_scalar)) except TypeError as err: - raise DataError('Creating dictionary failed: %s' % err) + raise DataError(f'Creating dictionary failed: {err}') def _yield_replaced(self, values, replace_scalar): for item in values: diff --git a/src/robot/variables/variables.py b/src/robot/variables/variables.py index 1cf6e0f4eab..b6ca5bdefc5 100644 --- a/src/robot/variables/variables.py +++ b/src/robot/variables/variables.py @@ -61,7 +61,7 @@ def set_from_file(self, path_or_variables, args=None, overwrite=False): setter = VariableFileSetter(self.store) return setter.set(path_or_variables, args, overwrite) - def set_from_variable_table(self, variables, overwrite=False): + def set_from_variable_section(self, variables, overwrite=False): setter = VariableTableSetter(self.store) setter.set(variables, overwrite) From adea7fb860a4ae3be50eb58b2ccd250ee640c227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 11 Oct 2023 18:32:30 +0300 Subject: [PATCH 0759/1592] Enhance validation of control structure options. Most importantly, validate values that have a certain set of accepted values already in parser when possible. Because values can be set also as variables, they need to be validated again at the execution time. Also enhance related error messages in general. Part of the motivation is making it easy to validate `scope` accepted by the forthcoming `VAR` syntax (#3761). --- atest/robot/running/for/for_in_zip.robot | 4 ++ .../running/try_except/except_behaviour.robot | 13 +++-- atest/robot/running/while/on_limit.robot | 6 +-- .../running/for/for_in_enumerate.robot | 6 +-- atest/testdata/running/for/for_in_zip.robot | 38 ++++++++------- .../running/try_except/except_behaviour.robot | 18 +++++-- .../running/while/invalid_while.robot | 4 +- atest/testdata/running/while/on_limit.robot | 30 ++++++------ .../testdata/running/while/while_limit.robot | 16 +++---- src/robot/parsing/model/statements.py | 47 +++++++++++++----- src/robot/running/bodyrunner.py | 38 +++++++-------- utest/parsing/test_model.py | 48 +++++++++++++------ 12 files changed, 166 insertions(+), 102 deletions(-) diff --git a/atest/robot/running/for/for_in_zip.robot b/atest/robot/running/for/for_in_zip.robot index f4c09575f2a..4c844ae8f05 100644 --- a/atest/robot/running/for/for_in_zip.robot +++ b/atest/robot/running/for/for_in_zip.robot @@ -104,6 +104,10 @@ Invalid mode ${tc} = Check Test Case ${TEST NAME} Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=bad +Invalid mode from variable + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=\${{'bad'}} + Config more than once ${tc} = Check Test Case ${TEST NAME} 1 Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=longest, shortest diff --git a/atest/robot/running/try_except/except_behaviour.robot b/atest/robot/running/try_except/except_behaviour.robot index 26c32890efc..0451dba7654 100644 --- a/atest/robot/running/try_except/except_behaviour.robot +++ b/atest/robot/running/try_except/except_behaviour.robot @@ -35,19 +35,22 @@ Non-string pattern FAIL NOT RUN NOT RUN NOT RUN NOT RUN Variable in pattern type - FAIL PASS pattern_types=['\${regexp}'] + FAIL PASS pattern_types=['\${regexp}'] Invalid variable in pattern type - FAIL FAIL PASS pattern_types=['\${does not exist}'] + FAIL FAIL PASS pattern_types=['\${does not exist}'] Invalid pattern type - FAIL FAIL pattern_types=['invalid'] + FAIL NOT RUN NOT RUN pattern_types=['glob', 'invalid'] + +Invalid pattern type from variable + FAIL FAIL pattern_types=["\${{'invalid'}}"] Non-string pattern type - FAIL FAIL pattern_types=['\${42}'] + FAIL FAIL pattern_types=['\${42}'] Pattern type multiple times - FAIL NOT RUN pattern_types=['glob, start'] + FAIL NOT RUN pattern_types=['glob, start'] Pattern type without patterns FAIL PASS diff --git a/atest/robot/running/while/on_limit.robot b/atest/robot/running/while/on_limit.robot index e64304395d6..6d69d51bc65 100644 --- a/atest/robot/running/while/on_limit.robot +++ b/atest/robot/running/while/on_limit.robot @@ -27,6 +27,9 @@ On limit fail with continuable failure Invalid on_limit Check Test Case ${TESTNAME} +Invalid on_limit from variable + Check Test Case ${TESTNAME} + On limit without limit defined Check Test Case ${TESTNAME} @@ -56,6 +59,3 @@ On limit message before limit On limit message with invalid variable Check Test Case ${TESTNAME} - -Wrong WHILE arguments - Check Test Case ${TESTNAME} diff --git a/atest/testdata/running/for/for_in_enumerate.robot b/atest/testdata/running/for/for_in_enumerate.robot index d07fa06e5cf..dd909a4bccc 100644 --- a/atest/testdata/running/for/for_in_enumerate.robot +++ b/atest/testdata/running/for/for_in_enumerate.robot @@ -34,19 +34,19 @@ Escape start Should Be True ${result} == [0, 1] Invalid start - [Documentation] FAIL Invalid start value: Start value must be an integer, got 'invalid'. + [Documentation] FAIL Invalid FOR IN ENUMERATE start value: Value must be an integer, got 'invalid'. FOR ${index} ${item} IN ENUMERATE xxx start=invalid Fail Should not be executed END Invalid variable in start - [Documentation] FAIL Invalid start value: Variable '\${invalid}' not found. + [Documentation] FAIL Invalid FOR IN ENUMERATE start value: Variable '\${invalid}' not found. FOR ${index} ${item} IN ENUMERATE xxx start=${invalid} Fail Should not be executed END Start multiple times - [Documentation] FAIL Option 'start' allowed only once, got values '1', '2' and '3'. + [Documentation] FAIL FOR option 'start' is accepted only once, got 3 values '1', '2' and '3'. FOR ${index} ${item} IN ENUMERATE xxx start=1 start=2 start=3 Fail Should not be executed END diff --git a/atest/testdata/running/for/for_in_zip.robot b/atest/testdata/running/for/for_in_zip.robot index 07b944d109c..c1dc7564e6a 100644 --- a/atest/testdata/running/for/for_in_zip.robot +++ b/atest/testdata/running/for/for_in_zip.robot @@ -134,45 +134,51 @@ Longest mode with custom fill value Should Be True ${result} == [(1, 'a', 1), (2, 'b', 0), (0, 'c', 0)] Invalid mode - [Documentation] FAIL Invalid mode: Mode must be 'STRICT', 'SHORTEST' or 'LONGEST', got 'BAD'. + [Documentation] FAIL FOR option 'mode' does not accept value 'bad'. Valid values are 'STRICT', 'SHORTEST' and 'LONGEST'. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=bad - @{result} = Create List @{result} ${x}:${y} + Fail Should not be executed + END + +Invalid mode from variable + [Documentation] FAIL Invalid FOR IN ZIP mode: Value 'bad' is not accepted. Valid values are 'STRICT', 'SHORTEST' and 'LONGEST'. + FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=${{'bad'}} + Fail Should not be executed END Config more than once 1 - [Documentation] FAIL Option 'mode' allowed only once, got values 'longest' and 'shortest'. + [Documentation] FAIL FOR option 'mode' is accepted only once, got 2 values 'longest' and 'shortest'. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=longest mode=shortest - @{result} = Create List @{result} ${x}:${y} + Fail Should not be executed END Config more than once 2 - [Documentation] FAIL Option 'fill' allowed only once, got values 'x', 'y' and 'z'. + [Documentation] FAIL FOR option 'fill' is accepted only once, got 3 values 'x', 'y' and 'z'. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} fill=x mode=longest fill=y fill=z - @{result} = Create List @{result} ${x}:${y} + Fail Should not be executed END Non-existing variable in mode - [Documentation] FAIL Invalid mode: Variable '\${bad}' not found. + [Documentation] FAIL Invalid FOR IN ZIP mode: Variable '\${bad}' not found. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=${bad} fill=${ignored} - @{result} = Create List @{result} ${x}:${y} + Fail Should not be executed END Non-existing variable in fill value - [Documentation] FAIL Invalid fill value: Variable '\${bad}' not found. + [Documentation] FAIL Invalid FOR IN ZIP fill value: Variable '\${bad}' not found. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=longest fill=${bad} - @{result} = Create List @{result} ${x}:${y} + Fail Should not be executed END Not iterable value [Documentation] FAIL FOR IN ZIP items must be list-like, but item 2 is integer. FOR ${x} ${y} IN ZIP ${LIST1} ${42} - Fail This test case should die before running this. + Fail Should not be executed END Strings are not considered iterables [Documentation] FAIL FOR IN ZIP items must be list-like, but item 3 is string. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} not list - Fail This test case should die before running this. + Fail Should not be executed END Too few variables 1 @@ -180,7 +186,7 @@ Too few variables 1 ... Number of FOR loop values should be multiple of its variables. \ ... Got 2 variables but 3 values. FOR ${too} ${few} IN ZIP ${LIST1} ${LIST1} ${LIST1} - Fail This test case should die before running this. + Fail Should not be executed END Too few variables 2 @@ -189,7 +195,7 @@ Too few variables 2 ... Got 3 variables but 4 values. @{items} = Create List ${LIST1} ${LIST1} ${LIST1} ${LIST1} FOR ${too} ${few} ${still} IN ZIP @{items} - Fail This test case should die before running this. + Fail Should not be executed END Too many variables 1 @@ -197,7 +203,7 @@ Too many variables 1 ... Number of FOR loop values should be multiple of its variables. \ ... Got 3 variables but 2 values. FOR ${too} ${many} ${variables} IN ZIP ${LIST1} ${LIST2} - Fail This test case should die before running this. + Fail Should not be executed END Too many variables 2 @@ -206,5 +212,5 @@ Too many variables 2 ... Got 4 variables but 1 value. @{items} = Create List ${LIST1} FOR ${too} ${many} ${variables} ${again} IN ZIP @{items} - Fail This test case should die before running this. + Fail Should not be executed END diff --git a/atest/testdata/running/try_except/except_behaviour.robot b/atest/testdata/running/try_except/except_behaviour.robot index fb10a20c6d2..01a8874d8cb 100644 --- a/atest/testdata/running/try_except/except_behaviour.robot +++ b/atest/testdata/running/try_except/except_behaviour.robot @@ -107,23 +107,33 @@ Invalid variable in pattern type END Invalid pattern type - [Documentation] FAIL Invalid EXCEPT pattern type 'invalid', expected 'GLOB', 'LITERAL', 'REGEXP' or 'START'. + [Documentation] FAIL EXCEPT option 'type' does not accept value 'invalid'. Valid values are 'GLOB', 'REGEXP', 'START' and 'LITERAL'. TRY Fail Should not be executed + EXCEPT * type=glob + Fail Should not be executed EXCEPT x type=invalid Fail Should not be executed END +Invalid pattern type from variable + [Documentation] FAIL Invalid EXCEPT pattern type 'invalid'. Valid values are 'GLOB', 'REGEXP', 'START' and 'LITERAL'. + TRY + Fail Executed + EXCEPT x type=${{'invalid'}} + Fail Should not be executed + END + Non-string pattern type - [Documentation] FAIL Invalid EXCEPT pattern type '42', expected 'GLOB', 'LITERAL', 'REGEXP' or 'START'. + [Documentation] FAIL Invalid EXCEPT pattern type '42'. Valid values are 'GLOB', 'REGEXP', 'START' and 'LITERAL'. TRY - Fail failure + Fail Executed EXCEPT x type=${42} Fail Should not be executed END Pattern type multiple times - [Documentation] FAIL Option 'type' allowed only once, got values 'glob' and 'start'. + [Documentation] FAIL EXCEPT option 'type' is accepted only once, got 2 values 'glob' and 'start'. TRY Fail failure EXCEPT x type=glob type=start diff --git a/atest/testdata/running/while/invalid_while.robot b/atest/testdata/running/while/invalid_while.robot index f85eb904d61..cffa3dd691b 100644 --- a/atest/testdata/running/while/invalid_while.robot +++ b/atest/testdata/running/while/invalid_while.robot @@ -1,6 +1,6 @@ *** Test Cases *** Multiple conditions - [Documentation] FAIL WHILE loop cannot have more than one condition, got 'Too', 'many', 'conditions' and '!'. + [Documentation] FAIL WHILE accepts only one condition, got 4 conditions 'Too', 'many', 'conditions' and '!'. WHILE Too many conditions ! Fail Not executed! END @@ -34,7 +34,7 @@ Recommend $var syntax if invalid condition contains ${var} ... Try using '$x == 'x'' syntax to avoid that. See Evaluating Expressions appendix in Robot Framework User Guide for more details. ${x} = Set Variable x WHILE ${x} == 'x' - Fail Shouldn't be run + Fail Not executed! END Invalid condition on second round diff --git a/atest/testdata/running/while/on_limit.robot b/atest/testdata/running/while/on_limit.robot index 25a4d7e6815..6b1cc25348e 100644 --- a/atest/testdata/running/while/on_limit.robot +++ b/atest/testdata/running/while/on_limit.robot @@ -61,21 +61,27 @@ On limit fail with continuable failure Fail One more failure! Invalid on_limit - [Documentation] FAIL Invalid WHILE loop 'on_limit' value 'inValid': Value must be 'PASS' or 'FAIL'. + [Documentation] FAIL WHILE option 'on_limit' does not accept value 'inValid'. Valid values are 'PASS' and 'FAIL'. WHILE True limit=5 on_limit=inValid - Fail Oh no! + Fail Should not be executed + END + +Invalid on_limit from variable + [Documentation] FAIL Invalid WHILE loop 'on_limit' value: Value 'inValid' is not accepted. Valid values are 'PASS' and 'FAIL'. + WHILE True limit=5 on_limit=${{'inValid'}} + Fail Should not be executed END On limit without limit defined - [Documentation] FAIL WHILE loop 'on_limit' option cannot be used without 'limit'. + [Documentation] FAIL WHILE option 'on_limit' cannot be used without 'limit'. WHILE True on_limit=PaSS - No Operation + Fail Should not be executed END On limit with invalid variable - [Documentation] FAIL Invalid WHILE loop 'on_limit' value '\${does not exist}': Variable '\${does not exist}' not found. + [Documentation] FAIL Invalid WHILE loop 'on_limit' value: Variable '\${does not exist}' not found. WHILE True limit=5 on_limit=${does not exist} - Fail Oh no! + Fail Should not be executed END On limit message without limit @@ -85,9 +91,9 @@ On limit message without limit END Wrong WHILE argument - [Documentation] FAIL WHILE loop cannot have more than one condition, got '$variable < 2', 'limit=5' and 'limit_exceed_messag=Custom error message'. + [Documentation] FAIL WHILE accepts only one condition, got 3 conditions '$variable < 2', 'limit=5' and 'limit_exceed_messag=Custom error message'. WHILE $variable < 2 limit=5 limit_exceed_messag=Custom error message - Log ${variable} + Fail Should not be executed END On limit message @@ -131,11 +137,5 @@ On limit message before limit On limit message with invalid variable [Documentation] FAIL Invalid WHILE loop 'on_limit_message': 'Variable '${nonExisting}' not found. WHILE $variable < 2 on_limit_message=${nonExisting} limit=5 - Log ${variable} - END - -Wrong WHILE arguments - [Documentation] FAIL WHILE loop cannot have more than one condition, got '$variable < 2', 'limite=5' and 'limit_exceed_messag=Custom error message'. - WHILE $variable < 2 limite=5 limit_exceed_messag=Custom error message - Log ${variable} + Fail Should not be executed END diff --git a/atest/testdata/running/while/while_limit.robot b/atest/testdata/running/while/while_limit.robot index 384fb09cde9..abb90747add 100644 --- a/atest/testdata/running/while/while_limit.robot +++ b/atest/testdata/running/while/while_limit.robot @@ -90,31 +90,31 @@ Continue after limit in teardown Invalid limit invalid suffix [Documentation] FAIL Invalid WHILE loop limit: Invalid time string '1 times'. WHILE $variable < 2 limit=1 times - Log ${variable} + Fail Should not be executed END Invalid limit invalid value [Documentation] FAIL Invalid WHILE loop limit: Iteration count must be a positive integer, got '-100'. WHILE $variable < 2 limit=-100 - Log ${variable} + Fail Should not be executed END Invalid limit mistyped prefix - [Documentation] FAIL WHILE loop cannot have more than one condition, got '$variable < 2' and 'limitation=2'. + [Documentation] FAIL WHILE accepts only one condition, got 2 conditions '$variable < 2' and 'limitation=2'. WHILE $variable < 2 limitation=2 - Log ${variable} + Fail Should not be executed END Limit used multiple times - [Documentation] FAIL Option 'limit' allowed only once, got values '1' and '2'. + [Documentation] FAIL WHILE option 'limit' is accepted only once, got 2 values '1' and '2'. WHILE True limit=1 limit=2 - Log ${variable} + Fail Should not be executed END Invalid values after limit - [Documentation] FAIL WHILE loop cannot have more than one condition, got '$variable < 2', 'limit=2' and 'invalid'. + [Documentation] FAIL WHILE accepts only one condition, got 3 conditions '$variable < 2', 'limit=2' and 'invalid'. WHILE $variable < 2 limit=2 invalid - Log ${variable} + Fail Should not be executed END *** Keywords *** diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 5e74c3e17b1..1c84c2b9172 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -23,7 +23,8 @@ from robot.conf import Language from robot.running.arguments import UserKeywordArgumentParser from robot.utils import normalize_whitespace, seq2str, split_from_equals, test_or_task -from robot.variables import is_scalar_assign, is_dict_variable, search_variable +from robot.variables import (contains_variable, is_scalar_assign, is_dict_variable, + search_variable) from ..lexer import Token @@ -50,6 +51,9 @@ class Statement(Node, ABC): type: str handles_types: 'ClassVar[tuple[str, ...]]' = () statement_handlers: 'ClassVar[dict[str, Type[Statement]]]' = {} + # Accepted configuration options. If the value is a tuple, it lists accepted + # values. If the used value contains a variable, it cannot be validated. + options: 'dict[str, tuple|None]' = {} def __init__(self, tokens: 'Sequence[Token]', errors: 'Sequence[str]' = ()): self.tokens = tuple(tokens) @@ -191,9 +195,16 @@ def validate(self, ctx: 'ValidationContext'): def _validate_options(self): for name, values in self._get_options().items(): - if len(values) > 1: - self.errors += (f"Option '{name}' allowed only once, got values " - f"{seq2str(values)}.",) + if len(values) != 1: + self.errors += (f"{self.type} option '{name}' is accepted only once, " + f"got {len(values)} values {seq2str(values)}.",) + elif self.options[name] is not None: + value = values[0] + expected = self.options[name] + if value.upper() not in expected and not contains_variable(value): + self.errors += (f"{self.type} option '{name}' does not accept " + f"value '{value}'. Valid values are " + f"{seq2str(expected)}.",) def __iter__(self) -> 'Iterator[Token]': return iter(self.tokens) @@ -909,6 +920,11 @@ def args(self) -> 'tuple[str, ...]': @Statement.register class ForHeader(Statement): type = Token.FOR + options = { + 'start': None, + 'mode': ('STRICT', 'SHORTEST', 'LONGEST'), + 'fill': None + } @classmethod def from_params(cls, assign: 'Sequence[str]', @@ -962,7 +978,6 @@ def fill(self) -> 'str|None': return self.get_option('fill') if self.flavor == 'IN ZIP' else None def validate(self, ctx: 'ValidationContext'): - self._validate_options() if not self.assign: self._add_error('no loop variables') if not self.flavor: @@ -973,6 +988,7 @@ def validate(self, ctx: 'ValidationContext'): self._add_error(f"invalid loop variable '{var}'") if not self.values: self._add_error('no loop values') + self._validate_options() def _add_error(self, error: str): self.errors += (f'FOR loop has {error}.',) @@ -1094,6 +1110,9 @@ class TryHeader(NoArgumentHeader): @Statement.register class ExceptHeader(Statement): type = Token.EXCEPT + options = { + 'type': ('GLOB', 'REGEXP', 'START', 'LITERAL') + } @classmethod def from_params(cls, patterns: 'Sequence[str]' = (), type: 'str|None' = None, @@ -1134,7 +1153,6 @@ def variable(self) -> 'str|None': # TODO: Remove in RF 8.0. return self.assign def validate(self, ctx: 'ValidationContext'): - self._validate_options() as_token = self.get_token(Token.AS) if as_token: variables = self.get_tokens(Token.ASSIGN) @@ -1144,6 +1162,7 @@ def validate(self, ctx: 'ValidationContext'): self.errors += ("EXCEPT's AS accepts only one variable.",) elif not is_scalar_assign(variables[0].value): self.errors += (f"EXCEPT's AS variable '{variables[0].value}' is invalid.",) + self._validate_options() @Statement.register @@ -1159,6 +1178,11 @@ class End(NoArgumentHeader): @Statement.register class WhileHeader(Statement): type = Token.WHILE + options = { + 'limit': None, + 'on_limit': ('PASS', 'FAIL'), + 'on_limit_message': None + } @classmethod def from_params(cls, condition: str, limit: 'str|None' = None, @@ -1198,17 +1222,14 @@ def on_limit_message(self) -> 'str|None': return self.get_option('on_limit_message') def validate(self, ctx: 'ValidationContext'): - conditions = self.get_tokens(Token.ARGUMENT) + conditions = self.get_values(Token.ARGUMENT) if len(conditions) > 1: - self._add_error(f'cannot have more than one condition, got ' - f'{seq2str(c.value for c in conditions)}') + self.errors += (f"WHILE accepts only one condition, got {len(conditions)} " + f"conditions {seq2str(conditions)}.",) if self.on_limit and not self.limit: - self._add_error("'on_limit' option cannot be used without 'limit'") + self.errors += ("WHILE option 'on_limit' cannot be used without 'limit'.",) self._validate_options() - def _add_error(self, error: str): - self.errors += (f'WHILE loop {error}.',) - @Statement.register class ReturnStatement(Statement): diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 2858072da9d..cc7910209f6 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -275,13 +275,13 @@ def _resolve_mode(self, mode): if not mode or self._context.dry_run: return None try: - mode = self._context.variables.replace_string(mode).upper() - if mode in ('STRICT', 'SHORTEST', 'LONGEST'): - return mode - raise DataError(f"Mode must be 'STRICT', 'SHORTEST' or 'LONGEST', " - f"got '{mode}'.") + mode = self._context.variables.replace_string(mode) + if mode.upper() in ('STRICT', 'SHORTEST', 'LONGEST'): + return mode.upper() + raise DataError(f"Value '{mode}' is not accepted. Valid values " + f"are 'STRICT', 'SHORTEST' and 'LONGEST'.") except DataError as err: - raise DataError(f'Invalid mode: {err}') + raise DataError(f'Invalid FOR IN ZIP mode: {err}') def _resolve_fill(self, fill): if not fill or self._context.dry_run: @@ -289,7 +289,7 @@ def _resolve_fill(self, fill): try: return self._context.variables.replace_scalar(fill) except DataError as err: - raise DataError(f'Invalid fill value: {err}') + raise DataError(f'Invalid FOR IN ZIP fill value: {err}') def _resolve_dict_values(self, values): raise DataError('FOR IN ZIP loops do not support iterating over dictionaries.', @@ -351,9 +351,9 @@ def _resolve_start(self, start): try: return int(start) except ValueError: - raise DataError(f"Start value must be an integer, got '{start}'.") + raise DataError(f"Value must be an integer, got '{start}'.") except DataError as err: - raise DataError(f'Invalid start value: {err}') + raise DataError(f'Invalid FOR IN ENUMERATE start value: {err}') def _map_dict_values_to_rounds(self, values, per_round): if per_round > 3: @@ -616,9 +616,9 @@ def _should_run_except(self, branch, error): return True matchers = { 'GLOB': lambda m, p: Matcher(p, spaceless=False, caseless=False).match(m), - 'LITERAL': lambda m, p: m == p, 'REGEXP': lambda m, p: re.fullmatch(p, m) is not None, - 'START': lambda m, p: m.startswith(p) + 'START': lambda m, p: m.startswith(p), + 'LITERAL': lambda m, p: m == p, } if branch.pattern_type: pattern_type = self._context.variables.replace_string(branch.pattern_type) @@ -626,8 +626,8 @@ def _should_run_except(self, branch, error): pattern_type = 'LITERAL' matcher = matchers.get(pattern_type.upper()) if not matcher: - raise DataError(f"Invalid EXCEPT pattern type '{pattern_type}', " - f"expected {seq2str(matchers, lastsep=' or ')}.") + raise DataError(f"Invalid EXCEPT pattern type '{pattern_type}'. " + f"Valid values are {seq2str(matchers)}.") for pattern in branch.patterns: if matcher(error.message, self._context.variables.replace_string(pattern)): return True @@ -693,15 +693,15 @@ def parse_on_limit(cls, variables, on_limit): return None try: on_limit = variables.replace_string(on_limit) - if on_limit.upper() not in ['PASS', 'FAIL']: - raise DataError("Value must be 'PASS' or 'FAIL'.") + if on_limit.upper() in ('PASS', 'FAIL'): + return on_limit.upper() + raise DataError(f"Value '{on_limit}' is not accepted. Valid values " + f"are 'PASS' and 'FAIL'.") except DataError as err: - raise DataError(f"Invalid WHILE loop 'on_limit' value '{on_limit}': {err}") - else: - return on_limit.lower() + raise DataError(f"Invalid WHILE loop 'on_limit' value: {err}") def limit_exceeded(self): - on_limit_pass = self.on_limit == 'pass' + on_limit_pass = self.on_limit == 'PASS' if self.on_limit_message: raise LimitExceeded(on_limit_pass, self.on_limit_message) else: diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 56e5def9dc7..8d46d29d6c1 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -430,7 +430,7 @@ def test_invalid(self): data = ''' *** Test Cases *** Example - WHILE too many values ! + WHILE too many values ! limit=1 on_limit=bad # Empty body END ''' @@ -440,9 +440,15 @@ def test_invalid(self): Token(Token.ARGUMENT, 'too', 3, 13), Token(Token.ARGUMENT, 'many', 3, 20), Token(Token.ARGUMENT, 'values', 3, 28), - Token(Token.ARGUMENT, '!', 3, 38)], - errors=("WHILE loop cannot have more than one condition, " - "got 'too', 'many', 'values' and '!'.",) + Token(Token.ARGUMENT, '!', 3, 38), + Token(Token.OPTION, 'limit=1', 3, 43), + Token(Token.OPTION, 'on_limit=bad', 3, 54)], + errors=( + "WHILE accepts only one condition, got 4 conditions 'too', " + "'many', 'values' and '!'.", + "WHILE option 'on_limit' does not accept value 'bad'. " + "Valid values are 'PASS' and 'FAIL'." + ) ), end=End([ Token(Token.END, 'END', 5, 4) @@ -790,18 +796,18 @@ def test_try_except_else_finally(self): Token(Token.ARGUMENT, 'does not match', 5, 14)]), body=[KeywordCall((Token(Token.KEYWORD, 'No operation', 6, 8),))], next=Try( - header=ExceptHeader((Token(Token.EXCEPT, 'EXCEPT', 7, 4), + header=ExceptHeader([Token(Token.EXCEPT, 'EXCEPT', 7, 4), Token(Token.AS, 'AS', 7, 14), - Token(Token.ASSIGN, '${exp}', 7, 20))), - body=[KeywordCall((Token(Token.KEYWORD, 'Log', 8, 8), - Token(Token.ARGUMENT, 'Catch', 8, 15)))], + Token(Token.ASSIGN, '${exp}', 7, 20)]), + body=[KeywordCall([Token(Token.KEYWORD, 'Log', 8, 8), + Token(Token.ARGUMENT, 'Catch', 8, 15)])], next=Try( - header=ElseHeader((Token(Token.ELSE, 'ELSE', 9, 4),)), - body=[KeywordCall((Token(Token.KEYWORD, 'No operation', 10, 8),))], + header=ElseHeader([Token(Token.ELSE, 'ELSE', 9, 4)]), + body=[KeywordCall([Token(Token.KEYWORD, 'No operation', 10, 8)])], next=Try( - header=FinallyHeader((Token(Token.FINALLY, 'FINALLY', 11, 4),)), - body=[KeywordCall((Token(Token.KEYWORD, 'Log', 12, 8), - Token(Token.ARGUMENT, 'finally here!', 12, 15)))] + header=FinallyHeader([Token(Token.FINALLY, 'FINALLY', 11, 4)]), + body=[KeywordCall([Token(Token.KEYWORD, 'Log', 12, 8), + Token(Token.ARGUMENT, 'finally here!', 12, 15)])] ) ) ) @@ -820,6 +826,7 @@ def test_invalid(self): FINALLY invalid # EXCEPT AS invalid + EXCEPT xx type=invalid ''' expected = Try( header=TryHeader( @@ -848,13 +855,26 @@ def test_invalid(self): Token(Token.ASSIGN, 'invalid', 8, 20)], errors=("EXCEPT's AS variable 'invalid' is invalid.",) ), - errors=('EXCEPT branch cannot be empty.',) + errors=('EXCEPT branch cannot be empty.',), + next=Try( + header=ExceptHeader( + tokens=[Token(Token.EXCEPT, 'EXCEPT', 9, 4), + Token(Token.ARGUMENT, 'xx', 9, 14), + Token(Token.OPTION, 'type=invalid', 9, 20)], + errors=("EXCEPT option 'type' does not accept value 'invalid'. " + "Valid values are 'GLOB', 'REGEXP', 'START' and 'LITERAL'.",) + ), + errors=('EXCEPT branch cannot be empty.',), + ) ) ), ), errors=('TRY branch cannot be empty.', 'EXCEPT not allowed after ELSE.', 'EXCEPT not allowed after FINALLY.', + 'EXCEPT not allowed after ELSE.', + 'EXCEPT not allowed after FINALLY.', + 'EXCEPT without patterns must be last.', 'TRY must have closing END.') ) get_and_assert_model(data, expected) From 440328a549217a8b08642cb988d028070a55f3b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 11 Oct 2023 19:09:08 +0300 Subject: [PATCH 0760/1592] More appropriate suite name --- .../{variable_table.robot => variable_section.robot} | 8 ++++---- .../{variable_table.robot => variable_section.robot} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename atest/robot/variables/{variable_table.robot => variable_section.robot} (90%) rename atest/testdata/variables/{variable_table.robot => variable_section.robot} (100%) diff --git a/atest/robot/variables/variable_table.robot b/atest/robot/variables/variable_section.robot similarity index 90% rename from atest/robot/variables/variable_table.robot rename to atest/robot/variables/variable_section.robot index 1472e78e01a..9ae535788fa 100644 --- a/atest/robot/variables/variable_table.robot +++ b/atest/robot/variables/variable_section.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run tests ${EMPTY} variables/variable_table.robot +Suite Setup Run tests ${EMPTY} variables/variable_section.robot Resource atest_resource.robot *** Test Cases *** @@ -78,18 +78,18 @@ Using variable created from non-existing variable in imports fails *** Keywords *** Parsing Variable Should Have Failed [Arguments] ${index} ${lineno} ${name} - Error In File ${index} variables/variable_table.robot ${lineno} + Error In File ${index} variables/variable_section.robot ${lineno} ... Setting variable '${name}' failed: ... Invalid variable name '${name}'. Creating Variable Should Have Failed [Arguments] ${index} ${name} ${lineno} @{message} - Error In File ${index} variables/variable_table.robot ${lineno} + Error In File ${index} variables/variable_section.robot ${lineno} ... Setting variable '${name}' failed: ... @{message} Import Should Have Failed [Arguments] ${index} ${name} ${lineno} @{message} - Error In File ${index} variables/variable_table.robot ${lineno} + Error In File ${index} variables/variable_section.robot ${lineno} ... Replacing variables from setting '${name}' failed: ... @{message} diff --git a/atest/testdata/variables/variable_table.robot b/atest/testdata/variables/variable_section.robot similarity index 100% rename from atest/testdata/variables/variable_table.robot rename to atest/testdata/variables/variable_section.robot From 927973652e9c52e9f20c4dad0b3dc6e1b58d8e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 12 Oct 2023 00:10:11 +0300 Subject: [PATCH 0761/1592] Support `separator` option in Variables section. This option specifies the separator to use if a scalar variable gets more than one value. The old way to accomplish that is using a `SEPARATOR` marker as the first value. The configuration option is already recognized by the parser and also consistent with the `separator` option the new `VAR` syntax (#3761) will get. Fixes #4896. --- atest/robot/variables/variable_section.robot | 20 +++-- .../testdata/variables/variable_section.robot | 18 +++-- .../src/CreatingTestData/Variables.rst | 19 ++++- src/robot/parsing/lexer/statementlexers.py | 5 ++ src/robot/parsing/model/statements.py | 18 ++++- src/robot/running/builder/transformers.py | 2 + src/robot/running/model.py | 4 +- src/robot/variables/tablesetter.py | 80 ++++++++++--------- utest/parsing/test_model.py | 22 +++++ utest/parsing/test_statements.py | 22 ++++- 10 files changed, 155 insertions(+), 55 deletions(-) diff --git a/atest/robot/variables/variable_section.robot b/atest/robot/variables/variable_section.robot index 9ae535788fa..b15c8f42f86 100644 --- a/atest/robot/variables/variable_section.robot +++ b/atest/robot/variables/variable_section.robot @@ -55,24 +55,30 @@ Invalid variable name Parsing Variable Should Have Failed 3 19 \${not}[[]ok] Parsing Variable Should Have Failed 4 20 \${not \${ok}} -Scalar catenated from multile values +Scalar catenated from multiple values + Check Test Case ${TEST NAME} + +Scalar catenated from multiple values with 'SEPARATOR' marker + Check Test Case ${TEST NAME} + +Scalar catenated from multiple values with 'separator' option Check Test Case ${TEST NAME} Creating variable using non-existing variable fails Check Test Case ${TEST NAME} - Creating Variable Should Have Failed 8 \${NONEX 1} 33 + Creating Variable Should Have Failed 8 \${NONEX 1} 35 ... Variable '\${NON EXISTING}' not found. - Creating Variable Should Have Failed 9 \${NONEX 2A} 34 + Creating Variable Should Have Failed 9 \${NONEX 2A} 36 ... Variable '\${NON EX}' not found.* - Creating Variable Should Have Failed 10 \${NONEX 2B} 35 + Creating Variable Should Have Failed 10 \${NONEX 2B} 37 ... Variable '\${NONEX 2A}' not found.* Using variable created from non-existing variable in imports fails - Creating Variable Should Have Failed 5 \${NONEX 3} 36 + Creating Variable Should Have Failed 5 \${NONEX 3} 38 ... Variable '\${NON EXISTING VARIABLE}' not found. - Import Should Have Failed 6 Resource 39 + Import Should Have Failed 6 Resource 41 ... Variable '\${NONEX 3}' not found.* - Import Should Have Failed 7 Library 40 + Import Should Have Failed 7 Library 42 ... Variable '\${NONEX 3}' not found.* *** Keywords *** diff --git a/atest/testdata/variables/variable_section.robot b/atest/testdata/variables/variable_section.robot index 84ee5c9c154..233274ae985 100644 --- a/atest/testdata/variables/variable_section.robot +++ b/atest/testdata/variables/variable_section.robot @@ -28,8 +28,10 @@ ${ASSING MARK} = This syntax works starting from 1.8 @{ASSIGN MARK LIST}= This syntax works starting from ${1.8} ${THREE DOTS} ... @{3DOTS LIST} ... ... -${CATENATED} I am a scalar catenated from many items -${CATENATED W/ SEP} SEPARATOR=- I can haz custom separator +${CATENATED} By default values are joined with a space +${SEPARATOR VALUE} SEPARATOR=- Special SEPARATOR marker as ${1} st value +${SEPARATOR OPTION} Explicit separator option works since RF ${7.0} separator=- +${BOTH SEPARATORS} SEPARATOR=marker has lower precedence than option separator=: ${NONEX 1} Creating variable based on ${NON EXISTING} variable fails. ${NONEX 2A} This ${NON EX} is used for creating another variable. ${NONEX 2B} ${NONEX 2A} @@ -125,9 +127,15 @@ Three dots on the same line should be interpreted as string ${sos} = Catenate SEPARATOR=--- @{3DOTS LIST} Should Be Equal ${sos} ...---... -Scalar catenated from multile values - Should Be Equal ${CATENATED} I am a scalar catenated from many items - Should Be Equal ${CATENATED W/ SEP} I-can-haz-custom-separator +Scalar catenated from multiple values + Should Be Equal ${CATENATED} By default values are joined with a space + +Scalar catenated from multiple values with 'SEPARATOR' marker + Should Be Equal ${SEPARATOR VALUE} Special-SEPARATOR-marker-as-1-st-value + +Scalar catenated from multiple values with 'separator' option + Should Be Equal ${SEPARATOR OPTION} Explicit-separator-option-works-since-RF-7.0 + Should Be Equal ${BOTH SEPARATORS} SEPARATOR=marker:has:lower:precedence:than:option Creating variable using non-existing variable fails Variable Should Not Exist ${NONEX 1} diff --git a/doc/userguide/src/CreatingTestData/Variables.rst b/doc/userguide/src/CreatingTestData/Variables.rst index 46db427cae6..2b218548953 100644 --- a/doc/userguide/src/CreatingTestData/Variables.rst +++ b/doc/userguide/src/CreatingTestData/Variables.rst @@ -495,18 +495,35 @@ variables slightly more explicit. If a scalar variable has a long value, it can be `split into multiple rows`__ by using the `...` syntax. By default rows are concatenated together using -a space, but this can be changed by having `SEPARATOR=` as the first item. +a space, but this can be changed by using a having `separator` configuration +option after the last value: .. sourcecode:: robotframework *** Variables *** ${EXAMPLE} This value is joined ... together with a space. + ${MULTILINE} First line. + ... Second line. + ... Third line. + ... separator=\n + +The `separator` option is new in Robot Framework 7.0, but also older versions +support configuring the separator. With them the first value can contain a +special `SEPARATOR` marker: + +.. sourcecode:: robotframework + + *** Variables *** ${MULTILINE} SEPARATOR=\n ... First line. ... Second line. ... Third line. +Both the `separator` option and the `SEPARATOR` marker are case-sensitive. +Using the `separator` option is recommended, unless there is a need to +support also older versions. + __ `Dividing data to several rows`_ Creating list variables diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index c3a51c3cc38..b2a747b4c5a 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -182,6 +182,11 @@ class VariableLexer(TypeAndArguments): ctx: FileContext token_type = Token.VARIABLE + def lex(self): + super().lex() + if self.statement[0].value[:1] == '$': + self._lex_options('separator') + class KeywordCallLexer(StatementLexer): ctx: 'TestCaseContext|KeywordContext' diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 1c84c2b9172..704a3362426 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -639,15 +639,24 @@ def from_params(cls, value: str, separator: str = FOUR_SPACES, @Statement.register class Variable(Statement): type = Token.VARIABLE + options = { + 'separator': None + } @classmethod - def from_params(cls, name: str, value: 'str|Sequence[str]', - separator: str = FOUR_SPACES, eol: str = EOL) -> 'Variable': + def from_params(cls, name: str, + value: 'str|Sequence[str]', + value_separator: 'str|None' = None, + separator: str = FOUR_SPACES, + eol: str = EOL) -> 'Variable': values = [value] if isinstance(value, str) else value tokens = [Token(Token.VARIABLE, name)] for value in values: tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.ARGUMENT, value)]) + if value_separator is not None: + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f'separator={value_separator}')]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -662,6 +671,10 @@ def name(self) -> str: def value(self) -> 'tuple[str, ...]': return self.get_values(Token.ARGUMENT) + @property + def separator(self) -> 'str|None': + return self.get_option('separator') + def validate(self, ctx: 'ValidationContext'): name = self.get_value(Token.VARIABLE) match = search_variable(name, ignore_errors=True) @@ -669,6 +682,7 @@ def validate(self, ctx: 'ValidationContext'): self.errors += (f"Invalid variable name '{name}'.",) if match.is_dict_assign(allow_assign_mark=True): self._validate_dict_items() + self._validate_options() def _validate_dict_items(self): for item in self.get_values(Token.ARGUMENT): diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index f55e913d029..0c8f272830c 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -109,6 +109,7 @@ def visit_SettingSection(self, node): def visit_Variable(self, node): self.suite.resource.variables.create(name=node.name, value=node.value, + separator=node.separator, lineno=node.lineno, error=format_error(node.errors)) @@ -154,6 +155,7 @@ def visit_VariablesImport(self, node): def visit_Variable(self, node): self.resource.variables.create(name=node.name, value=node.value, + separator=node.separator, lineno=node.lineno, error=format_error(node.errors)) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index e15782b13ab..e35c6393ed5 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -624,15 +624,17 @@ def to_dict(self) -> DataDict: class Variable(ModelObject): - repr_args = ('name', 'value') + repr_args = ('name', 'value', 'separator') def __init__(self, name: str = '', value: Sequence[str] = (), + separator: 'str|None' = None, parent: 'ResourceFile|None' = None, lineno: 'int|None' = None, error: 'str|None' = None): self.name = name self.value = tuple(value) + self.separator = separator self.parent = parent self.lineno = lineno self.error = error diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index b4d6dacfaa6..6ecb155d627 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -17,7 +17,7 @@ from typing import Sequence, TYPE_CHECKING from robot.errors import DataError -from robot.utils import DotDict, is_string, split_from_equals +from robot.utils import DotDict, split_from_equals from .resolvable import Resolvable from .search import is_assign, is_list_variable, is_dict_variable @@ -47,21 +47,22 @@ def _get_items(self, variables: 'Sequence[Variable]'): class VariableResolver(Resolvable): - def __init__(self, value: 'str|Sequence[str]', error_reporter=None): - self.value = self._format_value(value) + def __init__(self, value: Sequence[str], error_reporter=None): + self.value = value self.error_reporter = error_reporter self._resolving = False - def _format_value(self, value): - return value - @classmethod def from_name_and_value(cls, name: str, value: 'str|Sequence[str]', + separator: 'str|None' = None, error_reporter=None) -> 'VariableResolver': if not is_assign(name): raise DataError(f"Invalid variable name '{name}'.") - klass = {'$': ScalarVariableResolver, - '@': ListVariableResolver, + if name[0] == '$': + return ScalarVariableResolver(value, separator, error_reporter) + if separator is not None: + raise DataError('Only scalar variables support separators.') + klass = {'@': ListVariableResolver, '&': DictVariableResolver}[name[0]] return klass(value, error_reporter) @@ -69,11 +70,12 @@ def from_name_and_value(cls, name: str, value: 'str|Sequence[str]', def from_variable(cls, var: 'Variable') -> 'VariableResolver': if var.error: raise DataError(var.error) - return cls.from_name_and_value(var.name, var.value, var.report_error) + return cls.from_name_and_value(var.name, var.value, var.separator, + var.report_error) def resolve(self, variables): with self._avoid_recursion: - return self._replace_variables(self.value, variables) + return self._replace_variables(variables) @property @contextmanager @@ -86,7 +88,7 @@ def _avoid_recursion(self): finally: self._resolving = False - def _replace_variables(self, value, variables): + def _replace_variables(self, variables): raise NotImplementedError def report_error(self, error): @@ -98,41 +100,45 @@ def report_error(self, error): class ScalarVariableResolver(VariableResolver): - def _format_value(self, values): - separator = None - if is_string(values): - values = [values] - elif values and values[0].startswith('SEPARATOR='): - separator = values[0][10:] - values = values[1:] - return separator, values - - def _replace_variables(self, values, variables): - separator, values = values - # Avoid converting single value to string. - if self._is_single_value(separator, values): - return variables.replace_scalar(values[0]) + def __init__(self, value: 'str|Sequence[str]', separator: 'str|None' = None, + error_reporter=None): + value, separator = self._get_value_and_separator(value, separator) + super().__init__(value, error_reporter) + self.separator = separator + + def _get_value_and_separator(self, value, separator): + if isinstance(value, str): + value = [value] + elif separator is None and value and value[0].startswith('SEPARATOR='): + separator = value[0][10:] + value = value[1:] + return value, separator + + def _replace_variables(self, variables): + value, separator = self.value, self.separator + if self._is_single_value(value, separator): + return variables.replace_scalar(value[0]) if separator is None: separator = ' ' - separator = variables.replace_string(separator) - values = variables.replace_list(values) - return separator.join(str(item) for item in values) + else: + separator = variables.replace_string(separator) + value = variables.replace_list(value) + return separator.join(str(item) for item in value) - def _is_single_value(self, separator, values): - return (separator is None and len(values) == 1 and - not is_list_variable(values[0])) + def _is_single_value(self, value, separator): + return separator is None and len(value) == 1 and not is_list_variable(value[0]) class ListVariableResolver(VariableResolver): - def _replace_variables(self, values, variables): - return variables.replace_list(values) + def _replace_variables(self, variables): + return variables.replace_list(self.value) class DictVariableResolver(VariableResolver): - def _format_value(self, values): - return list(self._yield_formatted(values)) + def __init__(self, value: Sequence[str], error_reporter=None): + super().__init__(tuple(self._yield_formatted(value)), error_reporter) def _yield_formatted(self, values): for item in values: @@ -147,9 +153,9 @@ def _yield_formatted(self, values): ) yield name, value - def _replace_variables(self, values, variables): + def _replace_variables(self, variables): try: - return DotDict(self._yield_replaced(values, variables.replace_scalar)) + return DotDict(self._yield_replaced(self.value, variables.replace_scalar)) except TypeError as err: raise DataError(f'Creating dictionary failed: {err}') diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 8d46d29d6c1..15c84fdef31 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -908,6 +908,28 @@ def test_valid(self): ) get_and_assert_model(data, expected, depth=0) + def test_separator(self): + data = ''' +*** Variables *** +${x} a b c separator=- +${y} separator= +''' + expected = VariableSection( + header=SectionHeader( + tokens=[Token(Token.VARIABLE_HEADER, '*** Variables ***', 1, 0)] + ), + body=[ + Variable([Token(Token.VARIABLE, '${x}', 2, 0), + Token(Token.ARGUMENT, 'a', 2, 10), + Token(Token.ARGUMENT, 'b', 2, 15), + Token(Token.ARGUMENT, 'c', 2, 20), + Token(Token.OPTION, 'separator=-', 2, 25)]), + Variable([Token(Token.VARIABLE, '${y}', 3, 0), + Token(Token.OPTION, 'separator=', 3, 10)]), + ] + ) + get_and_assert_model(data, expected, depth=0) + def test_invalid(self): data = ''' *** Variables *** diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 5f746d08bff..f303721b470 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -239,7 +239,7 @@ def test_Variable(self): Token(Token.VARIABLE, '${variable_name}'), Token(Token.SEPARATOR, ' '), Token(Token.ARGUMENT, "{'a': 4, 'b': 'abc'}"), - Token(Token.EOL, '\n') + Token(Token.EOL) ] assert_created_statement( tokens, @@ -247,6 +247,24 @@ def test_Variable(self): name='${variable_name}', value="{'a': 4, 'b': 'abc'}" ) + # ${x} a b separator=- + tokens = [ + Token(Token.VARIABLE, '${x}'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'a'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'b'), + Token(Token.SEPARATOR, ' '), + Token(Token.OPTION, 'separator=-'), + Token(Token.EOL) + ] + assert_created_statement( + tokens, + Variable, + name='${x}', + value=['a', 'b'], + value_separator='-' + ) # ${var} first second third # @{var} first second third # &{var} first second third @@ -259,7 +277,7 @@ def test_Variable(self): Token(Token.ARGUMENT, 'second'), Token(Token.SEPARATOR, ' '), Token(Token.ARGUMENT, 'third'), - Token(Token.EOL, '\n') + Token(Token.EOL) ] assert_created_statement( tokens, From 394b9c96bb9510c1982405601ba2f42ac7437caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Tue, 3 Oct 2023 17:04:22 +0300 Subject: [PATCH 0762/1592] output: start getting rid of ModelCombiner --- src/robot/libraries/BuiltIn.py | 2 +- src/robot/output/console/dotted.py | 27 ++-- src/robot/output/console/quiet.py | 5 +- src/robot/output/console/verbose.py | 32 ++--- src/robot/output/logger.py | 76 +++++++++-- src/robot/output/loggerapi.py | 127 ++++++++++++++++++ .../{running => output}/modelcombiner.py | 0 src/robot/output/output.py | 103 +++++++++++--- src/robot/running/bodyrunner.py | 56 ++++---- src/robot/running/context.py | 93 ++++++++++--- src/robot/running/statusreporter.py | 6 +- src/robot/running/suiterunner.py | 14 +- utest/output/test_console.py | 8 +- utest/output/test_logger.py | 30 +++-- utest/running/test_running.py | 3 +- 15 files changed, 447 insertions(+), 135 deletions(-) create mode 100644 src/robot/output/loggerapi.py rename src/robot/{running => output}/modelcombiner.py (100%) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 9d70fec6ebd..419e633b182 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1870,7 +1870,7 @@ def run_keyword(self, name, *args): ctx = self._context if not (ctx.dry_run or self._accepts_embedded_arguments(name, ctx)): name, args = self._replace_variables_in_name([name] + list(args)) - parent = ctx.steps[-1] if ctx.steps else (ctx.test or ctx.suite) + parent = ctx.steps[-1][0] if ctx.steps else (ctx.test or ctx.suite) kw = Keyword(name, args=args, parent=parent, lineno=getattr(parent, 'lineno', None)) return kw.run(ctx) diff --git a/src/robot/output/console/dotted.py b/src/robot/output/console/dotted.py index 11e3ef5515c..4d21bea03c6 100644 --- a/src/robot/output/console/dotted.py +++ b/src/robot/output/console/dotted.py @@ -20,9 +20,10 @@ from robot.utils import plural_or_not as s, secs_to_timestr from .highlighting import HighlightingStream +from ..loggerapi import LoggerApi -class DottedOutput: +class DottedOutput(LoggerApi): def __init__(self, width=78, colors='AUTO', stdout=None, stderr=None): self.width = width @@ -30,31 +31,31 @@ def __init__(self, width=78, colors='AUTO', stdout=None, stderr=None): self.stderr = HighlightingStream(stderr or sys.__stderr__, colors) self.markers_on_row = 0 - def start_suite(self, suite: TestSuite): - if not suite.parent: - count = suite.test_count - ts = ('test' if not suite.rpa else 'task') + s(count) - self.stdout.write(f"Running suite '{suite.name}' with {count} {ts}.\n") + def start_suite(self, data, result): + if not data.parent: + count = data.test_count + ts = ('test' if not data.rpa else 'task') + s(count) + self.stdout.write(f"Running suite '{result.name}' with {count} {ts}.\n") self.stdout.write('=' * self.width + '\n') - def end_test(self, test: TestCase): + def end_test(self, data, result): if self.markers_on_row == self.width: self.stdout.write('\n') self.markers_on_row = 0 self.markers_on_row += 1 - if test.passed: + if result.passed: self.stdout.write('.') - elif test.skipped: + elif result.skipped: self.stdout.highlight('s', 'SKIP') - elif test.tags.robot('exit'): + elif result.tags.robot('exit'): self.stdout.write('x') else: self.stdout.highlight('F', 'FAIL') - def end_suite(self, suite: TestSuite): - if not suite.parent: + def end_suite(self, data, result): + if not data.parent: self.stdout.write('\n') - StatusReporter(self.stdout, self.width).report(suite) + StatusReporter(self.stdout, self.width).report(result) self.stdout.write('\n') def message(self, msg): diff --git a/src/robot/output/console/quiet.py b/src/robot/output/console/quiet.py index 00f03688d93..c366b2fb7ca 100644 --- a/src/robot/output/console/quiet.py +++ b/src/robot/output/console/quiet.py @@ -16,9 +16,10 @@ import sys from .highlighting import HighlightingStream +from ..loggerapi import LoggerApi -class QuietOutput: +class QuietOutput(LoggerApi): def __init__(self, colors='AUTO', stderr=None): self._stderr = HighlightingStream(stderr or sys.__stderr__, colors) @@ -28,5 +29,5 @@ def message(self, msg): self._stderr.error(msg.message, msg.level) -class NoOutput: +class NoOutput(LoggerApi): pass diff --git a/src/robot/output/console/verbose.py b/src/robot/output/console/verbose.py index 984a7b74bf8..cc19d5f6ef6 100644 --- a/src/robot/output/console/verbose.py +++ b/src/robot/output/console/verbose.py @@ -16,13 +16,13 @@ import sys from robot.errors import DataError -from robot.result import Keyword, TestCase, TestSuite from robot.utils import get_console_length, getshortdoc, isatty, pad_console_length from .highlighting import HighlightingStream +from ..loggerapi import LoggerApi -class VerboseOutput: +class VerboseOutput(LoggerApi): def __init__(self, width=78, colors='AUTO', markers='AUTO', stdout=None, stderr=None): @@ -31,36 +31,36 @@ def __init__(self, width=78, colors='AUTO', markers='AUTO', stdout=None, self.started_keywords = 0 self.running_test = False - def start_suite(self, suite: TestSuite): + def start_suite(self, data, result): if not self.started: self.writer.suite_separator() self.started = True - self.writer.info(suite.full_name, suite.doc, start_suite=True) + self.writer.info(data.full_name, result.doc, start_suite=True) self.writer.suite_separator() - def end_suite(self, suite: TestSuite): - self.writer.info(suite.full_name, suite.doc) - self.writer.status(suite.status) - self.writer.message(suite.full_message) + def end_suite(self, data, result): + self.writer.info(data.full_name, result.doc) + self.writer.status(result.status) + self.writer.message(result.full_message) self.writer.suite_separator() - def start_test(self, test: TestCase): - self.writer.info(test.name, test.doc) + def start_test(self, data, result): + self.writer.info(result.name, result.doc) self.running_test = True - def end_test(self, test: TestCase): - self.writer.status(test.status, clear=True) - self.writer.message(test.message) + def end_test(self, data, result): + self.writer.status(result.status, clear=True) + self.writer.message(result.message) self.writer.test_separator() self.running_test = False - def start_keyword(self, kw: Keyword): + def start_body_item(self, data, result): self.started_keywords += 1 - def end_keyword(self, kw: Keyword): + def end_body_item(self, data, result): self.started_keywords -= 1 if self.running_test and not self.started_keywords: - self.writer.keyword_marker(kw.status) + self.writer.keyword_marker(result.status) def message(self, msg): if msg.level in ('WARN', 'ERROR'): diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index a200b2ec7d3..6de2819f242 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -20,8 +20,11 @@ from .console import ConsoleOutput from .filelogger import FileLogger +from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger, AbstractLoggerProxy +from .modelcombiner import ModelCombiner from .stdoutlogsplitter import StdoutLogSplitter +from ..result import ResultVisitor class Logger(AbstractLogger): @@ -83,7 +86,8 @@ def register_console_logger(self, type='verbose', width=78, colors='AUTO', self._console_logger = self._wrap_and_relay(logger) def _wrap_and_relay(self, logger): - logger = LoggerProxy(logger) + if not isinstance(logger, LoggerApi): + logger = LoggerProxy(logger) self._relay_cached_messages(logger) return logger @@ -196,33 +200,79 @@ def enable_library_import_logging(self): def disable_library_import_logging(self): self.log_message = self._prev_log_message_handlers.pop() - def start_suite(self, suite): + def start_suite(self, data, result): + suite = ModelCombiner(data, result, + tests=data.tests, + suites=data.suites, + test_count=data.test_count) for logger in self.start_loggers: - logger.start_suite(suite) + if isinstance(logger, LoggerApi): + logger.start_suite(data, result) + else: + logger.start_suite(suite) - def end_suite(self, suite): + def end_suite(self, data, result): + suite = ModelCombiner(data, result) for logger in self.end_loggers: - logger.end_suite(suite) + if isinstance(logger, LoggerApi): + logger.end_suite(data, result) + else: + logger.end_suite(suite) - def start_test(self, test): + def start_test(self, data, result): + test = ModelCombiner(data, result) for logger in self.start_loggers: - logger.start_test(test) + if isinstance(logger, LoggerApi): + logger.start_test(data, result) + else: + logger.start_test(test) - def end_test(self, test): + def end_test(self, data, result): + test = ModelCombiner(data, result) for logger in self.end_loggers: - logger.end_test(test) + if isinstance(logger, LoggerApi): + logger.end_test(data, result) + else: + logger.end_test(test) - def start_keyword(self, keyword): + def start_keyword(self, data, result): + keyword = ModelCombiner(data, result) # TODO: Could _prev_log_message_handlers be used also here? self._started_keywords += 1 self.log_message = self._log_message for logger in self.start_loggers: - logger.start_keyword(keyword) + if isinstance(logger, LoggerApi): + logger.start_keyword(data, result) + else: + logger.start_keyword(keyword) - def end_keyword(self, keyword): + def end_keyword(self, data, result): + keyword = ModelCombiner(data, result) self._started_keywords -= 1 for logger in self.end_loggers: - logger.end_keyword(keyword) + if isinstance(logger, LoggerApi): + logger.end_keyword(data, result) + else: + logger.end_keyword(keyword) + if not self._started_keywords: + self.log_message = self.message + + def start_for(self, data, result): + self._started_keywords += 1 + self.log_message = self._log_message + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_for(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + def end_for(self, data, result): + self._started_keywords -= 1 + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_for(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) if not self._started_keywords: self.log_message = self.message diff --git a/src/robot/output/loggerapi.py b/src/robot/output/loggerapi.py new file mode 100644 index 00000000000..a3d09b3f701 --- /dev/null +++ b/src/robot/output/loggerapi.py @@ -0,0 +1,127 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from robot import running, result, model + + +class LoggerApi: + + def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): pass + + def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): pass + + def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): pass + + def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): pass + + def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + self.start_body_item(data, result) + + def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + self.end_body_item(data, result) + + def start_for(self, data: 'running.For', result: 'result.For'): + self.start_body_item(data, result) + + def end_for(self, data: 'running.For', result: 'result.For'): + self.end_body_item(data, result) + + def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + self.start_body_item(data, result) + + def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + self.end_body_item(data, result) + + def start_while(self, data: 'running.While', result: 'result.While'): + self.start_body_item(data, result) + + def end_while(self, data: 'running.While', result: 'result.While'): + self.end_body_item(data, result) + + def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + self.start_body_item(data, result) + + def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + self.end_body_item(data, result) + + def start_if(self, data: 'running.If', result: 'result.If'): + self.start_body_item(data, result) + + def end_if(self, data: 'running.If', result: 'result.If'): + self.end_body_item(data, result) + + def start_if_branch(self, data: 'running.If', result: 'result.IfBranch'): + self.start_body_item(data, result) + + def end_if_branch(self, data: 'running.If', result: 'result.IfBranch'): + self.end_body_item(data, result) + + def start_try(self, data: 'running.Try', result: 'result.Try'): + self.start_body_item(data, result) + + def end_try(self, data: 'running.Try', result: 'result.Try'): + self.end_body_item(data, result) + + def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + self.start_body_item(data, result) + + def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + self.end_body_item(data, result) + + def start_break(self, data, result): + self.start_body_item(data, result) + + def end_break(self, data, result): + self.end_body_item(data, result) + + def start_continue(self, data, result): + self.start_body_item(data, result) + + def end_continue(self, data, result): + self.end_body_item(data, result) + + def start_return(self, data, result): + self.start_body_item(data, result) + + def end_return(self, data, result): + self.end_body_item(data, result) + + def start_error(self, data, result): + self.start_body_item(data, result) + + def end_error(self, data, result): + self.end_body_item(data, result) + + def start_body_item(self, data, result): + pass + + def end_body_item(self, data, result): + pass + + def log_message(self, message: 'model.Message'): + pass + + def message(self, message: 'model.Message'): + pass + + # FIXME: + def output_file(self, type_: str, path: str): + pass + + def log_file(self, path: str): + pass + + def report_file(self, path: str): + pass + + def xunit_file(self, path: str): + pass + + def debug_file(self, path: str): + pass + + def imported(self, import_type: str, name: str, attrs): + pass + + def close(self): + pass diff --git a/src/robot/running/modelcombiner.py b/src/robot/output/modelcombiner.py similarity index 100% rename from src/robot/running/modelcombiner.py rename to src/robot/output/modelcombiner.py diff --git a/src/robot/output/output.py b/src/robot/output/output.py index ff27dc4d259..d71cb8c10ec 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -17,11 +17,12 @@ from .debugfile import DebugFile from .listeners import LibraryListeners, Listeners from .logger import LOGGER, LoggerProxy +from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger from .xmllogger import XmlLogger, FlatXmlLogger -class Output(AbstractLogger): +class Output(AbstractLogger, LoggerApi): def __init__(self, settings): AbstractLogger.__init__(self) @@ -55,31 +56,103 @@ def close(self, result): LOGGER.unregister_xml_logger() LOGGER.output_file('Output', self._settings['Output']) - def start_suite(self, suite): - LOGGER.start_suite(suite) + def start_suite(self, data, result): + LOGGER.start_suite(data, result) - def end_suite(self, suite): - LOGGER.end_suite(suite) + def end_suite(self, data, result): + LOGGER.end_suite(data, result) - def start_test(self, test): - LOGGER.start_test(test) + def start_test(self, data, result): + LOGGER.start_test(data, result) - def end_test(self, test): - LOGGER.end_test(test) + def end_test(self, data, result): + LOGGER.end_test(data, result) - def start_keyword(self, kw): - LOGGER.start_keyword(kw) - if kw.type in kw.KEYWORD_TYPES and kw.tags.robot('flatten'): + def start_keyword(self, data, result): + LOGGER.start_keyword(data, result) + if result.type in result.KEYWORD_TYPES and result.tags.robot('flatten'): self._flatten_level += 1 if self._flatten_level == 1: LOGGER._xml_logger = LoggerProxy(self.flat_xml_logger) - def end_keyword(self, kw): - if kw.type in kw.KEYWORD_TYPES and kw.tags.robot('flatten'): + def end_keyword(self, data, result): + if result.type in result.KEYWORD_TYPES and result.tags.robot('flatten'): self._flatten_level -= 1 if not self._flatten_level: LOGGER._xml_logger = LoggerProxy(self._xmllogger) - LOGGER.end_keyword(kw) + LOGGER.end_keyword(data, result) + + def start_for(self, data, result): + LOGGER.start_for(data, result) + + def end_for(self, data, result): + LOGGER.end_for(data, result) + + def start_for_iteration(self, data, result): + LOGGER.start_keyword(data, result) + + def end_for_iteration(self, data, result): + LOGGER.end_keyword(data, result) + + def start_while(self, data, result): + LOGGER.start_keyword(data, result) + + def end_while(self, data, result): + LOGGER.end_keyword(data, result) + + def start_while_iteration(self, data, result): + LOGGER.start_keyword(data, result) + + def end_while_iteration(self, data, result): + LOGGER.end_keyword(data, result) + + def start_if(self, data, result): + LOGGER.start_keyword(data, result) + + def end_if(self, data, result): + LOGGER.end_keyword(data, result) + + def start_if_branch(self, data, result): + LOGGER.start_keyword(data, result) + + def end_if_branch(self, data, result): + LOGGER.end_keyword(data, result) + + def start_try(self, data, result): + LOGGER.start_keyword(data, result) + + def end_try(self, data, result): + LOGGER.end_keyword(data, result) + + def start_try_branch(self, data, result): + LOGGER.start_keyword(data, result) + + def end_try_branch(self, data, result): + LOGGER.end_keyword(data, result) + + def start_break(self, data, result): + LOGGER.start_keyword(data, result) + + def end_break(self, data, result): + LOGGER.end_keyword(data, result) + + def start_continue(self, data, result): + LOGGER.start_keyword(data, result) + + def end_continue(self, data, result): + LOGGER.end_keyword(data, result) + + def start_return(self, data, result): + LOGGER.start_keyword(data, result) + + def end_return(self, data, result): + LOGGER.end_keyword(data, result) + + def start_error(self, data, result): + LOGGER.start_keyword(data, result) + + def end_error(self, data, result): + LOGGER.end_keyword(data, result) def message(self, msg): LOGGER.log_message(msg) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index cc7910209f6..7a03033dfea 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -467,10 +467,11 @@ def __init__(self, context, run=True, templated=False): def run(self, data): with self._dry_run_recursion_detection(data) as recursive_dry_run: error = None - with StatusReporter(data, IfResult(), self._context, self._run): + result = IfResult() + with StatusReporter(data, result, self._context, self._run): for branch in data.body: try: - if self._run_if_branch(branch, recursive_dry_run, data.error): + if self._run_if_branch(branch, result, recursive_dry_run, data.error): self._run = False except ExecutionStatus as err: error = err @@ -492,9 +493,9 @@ def _dry_run_recursion_detection(self, data): if dry_run: self._dry_run_stack.pop() - def _run_if_branch(self, branch, recursive_dry_run=False, syntax_error=None): + def _run_if_branch(self, branch, result, recursive_dry_run=False, syntax_error=None): context = self._context - result = IfBranchResult(branch.type, branch.condition, start_time=datetime.now()) + result = result.body.create_branch(branch.type, branch.condition, start_time=datetime.now()) error = None if syntax_error: run_branch = False @@ -539,28 +540,29 @@ def __init__(self, context, run=True, templated=False): def run(self, data): run = self._run - with StatusReporter(data, TryResult(), self._context, run): + result = TryResult() + with StatusReporter(data, result, self._context, run): if data.error: - self._run_invalid(data) + self._run_invalid(data, result) return - error = self._run_try(data, run) + error = self._run_try(data, result, run) run_excepts_or_else = self._should_run_excepts_or_else(error, run) if error: - error = self._run_excepts(data, error, run=run_excepts_or_else) - self._run_else(data, run=False) + error = self._run_excepts(data, result, error, run=run_excepts_or_else) + self._run_else(data, result, run=False) else: - self._run_excepts(data, error, run=False) - error = self._run_else(data, run=run_excepts_or_else) - error = self._run_finally(data, run) or error + self._run_excepts(data, result, error, run=False) + error = self._run_else(data, result, run=run_excepts_or_else) + error = self._run_finally(data, result, run) or error if error: raise error - def _run_invalid(self, data): + def _run_invalid(self, data, result): error_reported = False for branch in data.body: - result = TryBranchResult(branch.type, branch.patterns, branch.pattern_type, - branch.assign) - with StatusReporter(branch, result, self._context, run=False, suppress=True): + branch_result = result.body.create_branch(branch.type, branch.patterns, + branch.pattern_type, branch.assign) + with StatusReporter(branch, branch_result, self._context, run=False, suppress=True): runner = BodyRunner(self._context, run=False, templated=self._templated) runner.run(branch.body) if not error_reported: @@ -568,8 +570,8 @@ def _run_invalid(self, data): raise DataError(data.error, syntax=True) raise ExecutionFailed(data.error, syntax=True) - def _run_try(self, data, run): - result = TryBranchResult(data.TRY) + def _run_try(self, data, result, run): + result = result.body.create_branch(data.TRY) return self._run_branch(data.try_branch, result, run) def _should_run_excepts_or_else(self, error, run): @@ -591,7 +593,7 @@ def _run_branch(self, branch, result, run=True, error=None): else: return None - def _run_excepts(self, data, error, run): + def _run_excepts(self, data, result, error, run): for branch in data.except_branches: try: run_branch = run and self._should_run_except(branch, error) @@ -600,15 +602,15 @@ def _run_excepts(self, data, error, run): pattern_error = err else: pattern_error = None - result = TryBranchResult(branch.type, branch.patterns, - branch.pattern_type, branch.assign) + branch_result = result.body.create_branch(branch.type, branch.patterns, + branch.pattern_type, branch.assign) if run_branch: if branch.assign: self._context.variables[branch.assign] = str(error) - error = self._run_branch(branch, result, error=pattern_error) + error = self._run_branch(branch, branch_result, error=pattern_error) run = False else: - self._run_branch(branch, result, run=False) + self._run_branch(branch, branch_result, run=False) return error def _should_run_except(self, branch, error): @@ -633,14 +635,14 @@ def _should_run_except(self, branch, error): return True return False - def _run_else(self, data, run): + def _run_else(self, data, result, run): if data.else_branch: - result = TryBranchResult(data.ELSE) + result = result.body.create_branch(data.ELSE) return self._run_branch(data.else_branch, result, run) - def _run_finally(self, data, run): + def _run_finally(self, data, result, run): if data.finally_branch: - result = TryBranchResult(data.FINALLY) + result = result.body.create_branch(data.FINALLY) try: with StatusReporter(data.finally_branch, result, self._context, run): runner = BodyRunner(self._context, run, self._templated) diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 581c10382f7..b820ad2e884 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -180,20 +180,20 @@ def continue_on_failure(self, default=False): @property def allow_loop_control(self): - for step in reversed(self.steps): + for _, step in reversed(self.steps): if step.type == 'ITERATION': return True if step.type == 'KEYWORD' and step.owner != 'BuiltIn': return False return False - def end_suite(self, suite): + def end_suite(self, data, result): for name in ['${PREV_TEST_NAME}', '${PREV_TEST_STATUS}', '${PREV_TEST_MESSAGE}']: self.variables.set_global(name, self.variables[name]) - self.output.end_suite(suite) - self.namespace.end_suite(suite) + self.output.end_suite(data, result) + self.namespace.end_suite(data) EXECUTION_CONTEXTS.end_suite() def set_suite_variables(self, suite): @@ -206,13 +206,14 @@ def report_suite_status(self, status, message): self.variables['${SUITE_STATUS}'] = status self.variables['${SUITE_MESSAGE}'] = message - def start_test(self, test): - self.test = test - self._add_timeout(test.timeout) + def start_test(self, data, result): + self.test = result + self._add_timeout(result.timeout) self.namespace.start_test() - self.variables.set_test('${TEST_NAME}', test.name) - self.variables.set_test('${TEST_DOCUMENTATION}', test.doc) - self.variables.set_test('@{TEST_TAGS}', list(test.tags)) + self.variables.set_test('${TEST_NAME}', result.name) + self.variables.set_test('${TEST_DOCUMENTATION}', result.doc) + self.variables.set_test('@{TEST_TAGS}', list(result.tags)) + self.output.start_test(data, result) def _add_timeout(self, timeout): if timeout: @@ -232,15 +233,75 @@ def end_test(self, test): self.variables.set_suite('${PREV_TEST_MESSAGE}', test.message) self.timeout_occurred = False - def start_keyword(self, keyword): - self.steps.append(keyword) + def start_body_item(self, data, result): + self.steps.append((data, result)) if len(self.steps) > self._started_keywords_threshold: raise DataError('Maximum limit of started keywords and control ' 'structures exceeded.') - self.output.start_keyword(keyword) - - def end_keyword(self, keyword): - self.output.end_keyword(keyword) + if result.type == result.ELSE: + method = { + result.IF_ELSE_ROOT: self.output.start_if_branch, + result.TRY_EXCEPT_ROOT: self.output.start_try_branch, + }[result.parent.type] + elif result.type == result.ITERATION: + method = { + result.FOR: self.output.start_for_iteration, + result.WHILE: self.output.start_while_iteration, + }[result.parent.type] + else: + method = { + result.KEYWORD: self.output.start_keyword, + result.SETUP: self.output.start_keyword, + result.TEARDOWN: self.output.start_keyword, + result.FOR: self.output.start_for, + result.WHILE: self.output.start_while, + result.IF_ELSE_ROOT: self.output.start_if, + result.IF: self.output.start_if_branch, + result.ELSE: self.output.start_if_branch, + result.ELSE_IF: self.output.start_if_branch, + result.TRY_EXCEPT_ROOT: self.output.start_try, + result.TRY: self.output.start_try_branch, + result.EXCEPT: self.output.start_try_branch, + result.FINALLY: self.output.start_try_branch, + result.BREAK: self.output.start_break, + result.CONTINUE: self.output.start_continue, + result.RETURN: self.output.start_return, + result.ERROR: self.output.start_error, + }[result.type] + method(data, result) + + def end_body_item(self, data, result): + if result.type == result.ELSE: + method = { + result.IF_ELSE_ROOT: self.output.end_if_branch, + result.TRY_EXCEPT_ROOT: self.output.end_try_branch, + }[result.parent.type] + elif result.type == result.ITERATION: + method = { + result.FOR: self.output.end_for_iteration, + result.WHILE: self.output.end_while_iteration, + }[result.parent.type] + else: + method = { + result.KEYWORD: self.output.end_keyword, + result.SETUP: self.output.end_keyword, + result.TEARDOWN: self.output.end_keyword, + result.FOR: self.output.end_for, + result.WHILE: self.output.end_while, + result.IF_ELSE_ROOT: self.output.end_if, + result.IF: self.output.end_if_branch, + result.ELSE: self.output.end_if_branch, + result.ELSE_IF: self.output.end_if_branch, + result.TRY_EXCEPT_ROOT: self.output.end_try, + result.TRY: self.output.end_try_branch, + result.EXCEPT: self.output.end_try_branch, + result.FINALLY: self.output.end_try_branch, + result.BREAK: self.output.end_break, + result.CONTINUE: self.output.end_continue, + result.RETURN: self.output.end_return, + result.ERROR: self.output.end_error, + }[result.type] + method(data, result) self.steps.pop() def get_runner(self, name): diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index 981ed193050..473674e10b4 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -19,8 +19,6 @@ ExecutionStatus, HandlerExecutionFailed, ReturnFromKeyword) from robot.utils import ErrorDetails -from .modelcombiner import ModelCombiner - class StatusReporter: @@ -42,7 +40,7 @@ def __enter__(self): self.initial_test_status = context.test.status if context.test else None if not result.start_time: result.start_time = datetime.now() - context.start_keyword(ModelCombiner(self.data, result)) + context.start_body_item(self.data, result) if result.type in result.KEYWORD_TYPES: self._warn_if_deprecated(result.doc, result.full_name) return self @@ -65,7 +63,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self.initial_test_status == 'PASS': context.test.status = result.status result.elapsed_time = datetime.now() - result.start_time - context.end_keyword(ModelCombiner(self.data, result)) + context.end_body_item(self.data, result) if failure is not exc_val and not self.suppress: raise failure return self.suppress diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index ff2c45e05da..688a374c75c 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -23,7 +23,6 @@ from .bodyrunner import BodyRunner, KeywordRunner from .context import EXECUTION_CONTEXTS -from .modelcombiner import ModelCombiner from .namespace import Namespace from .status import SuiteStatus, TestStatus from .timeouts import TestTimeout @@ -82,10 +81,7 @@ def start_suite(self, suite): result.metadata = [(self._resolve_setting(n), self._resolve_setting(v)) for n, v in result.metadata.items()] self._context.set_suite_variables(result) - self._output.start_suite(ModelCombiner(suite, result, - tests=suite.tests, - suites=suite.suites, - test_count=suite.test_count)) + self._output.start_suite(suite, result) self._output.register_error_listener(self._suite_status.error_occurred) self._run_setup(suite, self._suite_status, run=self._any_test_run(suite)) @@ -117,7 +113,7 @@ def end_suite(self, suite): self._suite.suite_teardown_failed(str(failure)) self._suite.end_time = datetime.now() self._suite.message = self._suite_status.message - self._context.end_suite(ModelCombiner(suite, self._suite)) + self._context.end_suite(suite, self._suite) self._executed.pop() self._suite = self._suite.parent self._suite_status = self._suite_status.parent @@ -138,8 +134,7 @@ def visit_test(self, test): self._get_timeout(test), test.lineno, start_time=datetime.now()) - self._context.start_test(result) - self._output.start_test(ModelCombiner(test, result)) + self._context.start_test(test, result) status = TestStatus(self._suite_status, result, settings.skip_on_failure, settings.rpa) if status.exit: @@ -191,7 +186,8 @@ def visit_test(self, test): result.status = status.status result.end_time = datetime.now() failed_before_listeners = result.failed - self._output.end_test(ModelCombiner(test, result)) + # TODO: can this be removed to context + self._output.end_test(test, result) if result.failed and not failed_before_listeners: status.failure_occurred() self._context.end_test(result) diff --git a/utest/output/test_console.py b/utest/output/test_console.py index f3f3f5facbc..28d405f7cbc 100644 --- a/utest/output/test_console.py +++ b/utest/output/test_console.py @@ -10,7 +10,7 @@ def setUp(self, markers='AUTO', isatty=True): self.stream = StreamStub(isatty) self.console = VerboseOutput(width=16, colors='off', markers=markers, stdout=self.stream, stderr=self.stream) - self.console.start_test(Stub()) + self.console.start_test(Stub(), Stub()) def test_write_pass_marker(self): self._write_marker() @@ -39,7 +39,7 @@ def test_more_markers_than_fit_into_status_area(self): def test_clear_markers_when_test_status_is_written(self): self._write_marker(count=5) - self.console.end_test(Stub()) + self.console.end_test(Stub(), Stub()) self._verify('| PASS |\n%s\n' % ('-'*self.console.writer.width)) def test_clear_markers_when_there_are_warnings(self): @@ -69,8 +69,8 @@ def test_markers_auto_off(self): def _write_marker(self, status='PASS', count=1): for i in range(count): - self.console.start_keyword(Stub()) - self.console.end_keyword(Stub(status=status)) + self.console.start_keyword(Stub(), Stub()) + self.console.end_keyword(Stub(), Stub(status=status)) def _verify(self, after='', before=''): assert_equal(str(self.stream), '%sX :: D %s' % (before, after)) diff --git a/utest/output/test_logger.py b/utest/output/test_logger.py index 30398d16e63..fbf27ae9dcb 100644 --- a/utest/output/test_logger.py +++ b/utest/output/test_logger.py @@ -2,7 +2,7 @@ from robot.utils.asserts import assert_equal, assert_true, assert_false -from robot.output.logger import Logger +from robot.output.logger import Logger, LoggerApi from robot.output.console.verbose import VerboseOutput @@ -115,22 +115,25 @@ def test_message_cache_can_be_turned_off(self): assert_false(hasattr(logger, 'msg')) def test_start_and_end_suite_test_and_keyword(self): - class MyLogger: - def start_suite(self, suite): self.started_suite = suite - def end_suite(self, suite): self.ended_suite = suite - def start_test(self, test): self.started_test = test - def end_test(self, test): self.ended_test = test - def start_keyword(self, keyword): self.started_keyword = keyword - def end_keyword(self, keyword): self.ended_keyword = keyword + class MyLogger(LoggerApi): + def start_suite(self, suite, result): self.started_suite = suite + def end_suite(self, suite, result): self.ended_suite = suite + def start_test(self, test, result): self.started_test = test + def end_test(self, test, result): self.ended_test = test + def start_keyword(self, keyword, result): self.started_keyword = keyword + def end_keyword(self, keyword, result): self.ended_keyword = keyword class Arg: type = None + tests = () + suites = () + test_count = 0 logger = MyLogger() self.logger.register_logger(logger) for name in 'suite', 'test', 'keyword': arg = Arg() arg.result = arg for stend in 'start', 'end': - getattr(self.logger, stend + '_' + name)(arg) + getattr(self.logger, stend + '_' + name)(arg, arg) assert_equal(getattr(logger, stend + 'ed_' + name), arg) def test_verbose_console_output_is_automatically_registered(self): @@ -185,7 +188,6 @@ def test_unregistering_non_registered_logger_is_ok(self): def test_start_and_end_loggers_and_iter(self): logger = Logger() - console = logger._console_logger.logger xml = LoggerMock() listener = LoggerMock() lib_listener = LoggerMock() @@ -193,10 +195,10 @@ def test_start_and_end_loggers_and_iter(self): logger.register_xml_logger(xml) logger.register_listeners(listener, lib_listener) logger.register_logger(other) - assert_equal([proxy.logger for proxy in logger.start_loggers], - [other, console, xml, listener, lib_listener]) - assert_equal([proxy.logger for proxy in logger.end_loggers], - [listener, lib_listener, console, xml, other]) + assert_equal([proxy.logger for proxy in logger.start_loggers if not isinstance(proxy, LoggerApi)], + [other, xml, listener, lib_listener]) + assert_equal([proxy.logger for proxy in logger.end_loggers if not isinstance(proxy, LoggerApi)], + [listener, lib_listener, xml, other]) assert_equal(list(logger), list(logger.end_loggers)) def _number_of_registered_loggers_should_be(self, number, logger=None): diff --git a/utest/running/test_running.py b/utest/running/test_running.py index cd81354a0ed..c8ec0330c0d 100644 --- a/utest/running/test_running.py +++ b/utest/running/test_running.py @@ -5,6 +5,7 @@ from io import StringIO from os.path import abspath, dirname, join +from robot.model import BodyItem from robot.running import TestSuite, TestSuiteBuilder from robot.utils.asserts import assert_equal @@ -184,7 +185,7 @@ def test_failing_test_with_failing_teardown(self): def test_nested_setups_and_teardowns(self): root = TestSuite(name='Root') - root.teardown.config(name='Fail', args=['Top level'], type='teardown') + root.teardown.config(name='Fail', args=['Top level'], type=BodyItem.TEARDOWN) root.suites.append(self.suite) suite = run(root, variable=['SUITE SETUP:Fail', 'SUITE TEARDOWN:Fail']) assert_suite(suite, 'Root', 'FAIL', From caeb03b06d9b4c42cbb28b10cdb38ded31b65c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Wed, 4 Oct 2023 18:12:52 +0300 Subject: [PATCH 0763/1592] output: make FileLogger compatible with LogginApi' --- src/robot/output/filelogger.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/robot/output/filelogger.py b/src/robot/output/filelogger.py index c66b3675c3b..1b5d6bf1718 100644 --- a/src/robot/output/filelogger.py +++ b/src/robot/output/filelogger.py @@ -16,9 +16,10 @@ from robot.utils import file_writer from .loggerhelper import AbstractLogger +from .loggerapi import LoggerApi -class FileLogger(AbstractLogger): +class FileLogger(AbstractLogger, LoggerApi): def __init__(self, path, level): super().__init__(level) @@ -33,23 +34,23 @@ def message(self, msg): msg.message) self._writer.write(entry) - def start_suite(self, suite): - self.info("Started suite '%s'." % suite.name) + def start_suite(self, data, result): + self.info("Started suite '%s'." % result.name) - def end_suite(self, suite): - self.info("Ended suite '%s'." % suite.name) + def end_suite(self, data, result): + self.info("Ended suite '%s'." % result.name) - def start_test(self, test): - self.info("Started test '%s'." % test.name) + def start_test(self, data, result): + self.info("Started test '%s'." % result.name) - def end_test(self, test): - self.info("Ended test '%s'." % test.name) + def end_test(self, data, result): + self.info("Ended test '%s'." % result.name) - def start_keyword(self, kw): - self.debug(lambda: "Started keyword '%s'." % kw.name) + def start_body_item(self, data, result): + self.debug(lambda: "Started keyword '%s'." % result.name if result.type in result.KEYWORD_TYPES else result._name) - def end_keyword(self, kw): - self.debug(lambda: "Ended keyword '%s'." % kw.name) + def end_body_item(self, data, result): + self.debug(lambda: "Ended keyword '%s'." % result.name if result.type in result.KEYWORD_TYPES else result._name) def output_file(self, name, path): self.info('%s: %s' % (name, path)) From 3704a2867f7f0c4cbcda505d2ae0c58aedee5ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Sat, 7 Oct 2023 13:51:25 +0300 Subject: [PATCH 0764/1592] output: make xmllogger compatible with LoggerApi --- src/robot/output/logger.py | 211 ++++++++++++++++++++++++++++++++-- src/robot/output/output.py | 71 ++++++------ src/robot/output/xmllogger.py | 174 ++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+), 50 deletions(-) diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index 6de2819f242..9a978e37176 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -27,6 +27,24 @@ from ..result import ResultVisitor +def start_body_item(method): + def wrapper(self, *args): + # TODO: Could _prev_log_message_handlers be used also here? + self._started_keywords += 1 + self.log_message = self._log_message + method(self, *args) + return wrapper + + +def end_body_item(method): + def wrapper(self, *args): + self._started_keywords -= 1 + method(self, *args) + if not self._started_keywords: + self.log_message = self.message + return wrapper + + class Logger(AbstractLogger): """A global logger proxy to delegating messages to registered loggers. @@ -235,46 +253,217 @@ def end_test(self, data, result): else: logger.end_test(test) + @start_body_item def start_keyword(self, data, result): keyword = ModelCombiner(data, result) - # TODO: Could _prev_log_message_handlers be used also here? - self._started_keywords += 1 - self.log_message = self._log_message for logger in self.start_loggers: if isinstance(logger, LoggerApi): logger.start_keyword(data, result) else: logger.start_keyword(keyword) + @end_body_item def end_keyword(self, data, result): keyword = ModelCombiner(data, result) - self._started_keywords -= 1 for logger in self.end_loggers: if isinstance(logger, LoggerApi): logger.end_keyword(data, result) else: logger.end_keyword(keyword) - if not self._started_keywords: - self.log_message = self.message + @start_body_item def start_for(self, data, result): - self._started_keywords += 1 - self.log_message = self._log_message for logger in self.start_loggers: if isinstance(logger, LoggerApi): logger.start_for(data, result) else: logger.start_keyword(ModelCombiner(data, result)) + @end_body_item def end_for(self, data, result): - self._started_keywords -= 1 for logger in self.end_loggers: if isinstance(logger, LoggerApi): logger.end_for(data, result) else: logger.end_keyword(ModelCombiner(data, result)) - if not self._started_keywords: - self.log_message = self.message + + @start_body_item + def start_for_iteration(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_for_iteration(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_for_iteration(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_for_iteration(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_while(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_while(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_while(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_while(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_while_iteration(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_while_iteration(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_while_iteration(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_while_iteration(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_if(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_if(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_if(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_if(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_if_branch(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_if_branch(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_if_branch(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_if_branch(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_try(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_try(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_try(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_try(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_try_branch(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_try_branch(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_try_branch(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_try_branch(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_break(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_break(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_break(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_break(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_continue(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_continue(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_continue(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_continue(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) + + @start_body_item + def start_return(self, data, result): + keyword = ModelCombiner(data, result) + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_return(data, result) + else: + logger.start_keyword(keyword) + + @end_body_item + def end_return(self, data, result): + keyword = ModelCombiner(data, result) + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_return(data, result) + else: + logger.end_keyword(keyword) + + @start_body_item + def start_error(self, data, result): + for logger in self.start_loggers: + if isinstance(logger, LoggerApi): + logger.start_error(data, result) + else: + logger.start_keyword(ModelCombiner(data, result)) + + @end_body_item + def end_error(self, data, result): + for logger in self.end_loggers: + if isinstance(logger, LoggerApi): + logger.end_error(data, result) + else: + logger.end_keyword(ModelCombiner(data, result)) def imported(self, import_type, name, **attrs): for logger in self: diff --git a/src/robot/output/output.py b/src/robot/output/output.py index d71cb8c10ec..30b192b88de 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -16,33 +16,26 @@ from . import pyloggingconf from .debugfile import DebugFile from .listeners import LibraryListeners, Listeners -from .logger import LOGGER, LoggerProxy +from .logger import LOGGER from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger -from .xmllogger import XmlLogger, FlatXmlLogger +from .xmllogger import XmlLogger, FlatXmlLogger, XmlLoggerFacade class Output(AbstractLogger, LoggerApi): def __init__(self, settings): AbstractLogger.__init__(self) - self._xmllogger = XmlLogger(settings.output, settings.log_level, - settings.rpa) - self._flat_xml_logger = None + self._xml_logger = XmlLoggerFacade(settings.output, settings.log_level, + settings.rpa) self.listeners = Listeners(settings.listeners, settings.log_level) self.library_listeners = LibraryListeners(settings.log_level) self._register_loggers(DebugFile(settings.debug_file)) self._settings = settings self._flatten_level = 0 - @property - def flat_xml_logger(self): - if self._flat_xml_logger is None: - self._flat_xml_logger = FlatXmlLogger(self._xmllogger) - return self._flat_xml_logger - def _register_loggers(self, debug_file): - LOGGER.register_xml_logger(self._xmllogger) + LOGGER.register_xml_logger(self._xml_logger) LOGGER.register_listeners(self.listeners or None, self.library_listeners) if debug_file: LOGGER.register_logger(debug_file) @@ -51,8 +44,8 @@ def register_error_listener(self, listener): LOGGER.register_error_listener(listener) def close(self, result): - self._xmllogger.visit_statistics(result.statistics) - self._xmllogger.close() + self._xml_logger.visit_statistics(result.statistics) + self._xml_logger.close() LOGGER.unregister_xml_logger() LOGGER.output_file('Output', self._settings['Output']) @@ -73,13 +66,13 @@ def start_keyword(self, data, result): if result.type in result.KEYWORD_TYPES and result.tags.robot('flatten'): self._flatten_level += 1 if self._flatten_level == 1: - LOGGER._xml_logger = LoggerProxy(self.flat_xml_logger) + self._xml_logger.flatten(True) def end_keyword(self, data, result): if result.type in result.KEYWORD_TYPES and result.tags.robot('flatten'): self._flatten_level -= 1 if not self._flatten_level: - LOGGER._xml_logger = LoggerProxy(self._xmllogger) + self._xml_logger.flatten(False) LOGGER.end_keyword(data, result) def start_for(self, data, result): @@ -89,70 +82,70 @@ def end_for(self, data, result): LOGGER.end_for(data, result) def start_for_iteration(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_for_iteration(data, result) def end_for_iteration(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_for_iteration(data, result) def start_while(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_while(data, result) def end_while(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_while(data, result) def start_while_iteration(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_while_iteration(data, result) def end_while_iteration(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_while_iteration(data, result) def start_if(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_if(data, result) def end_if(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_if(data, result) def start_if_branch(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_if_branch(data, result) def end_if_branch(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_if_branch(data, result) def start_try(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_try(data, result) def end_try(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_try(data, result) def start_try_branch(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_try_branch(data, result) def end_try_branch(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_try_branch(data, result) def start_break(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_break(data, result) def end_break(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_break(data, result) def start_continue(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_continue(data, result) def end_continue(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_continue(data, result) def start_return(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_return(data, result) def end_return(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_return(data, result) def start_error(self, data, result): - LOGGER.start_keyword(data, result) + LOGGER.start_error(data, result) def end_error(self, data, result): - LOGGER.end_keyword(data, result) + LOGGER.end_error(data, result) def message(self, msg): LOGGER.log_message(msg) @@ -165,4 +158,4 @@ def set_log_level(self, level): pyloggingconf.set_level(level) self.listeners.set_log_level(level) self.library_listeners.set_log_level(level) - return self._xmllogger.set_log_level(level) + return self._xml_logger.set_log_level(level) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 1e9e4c57251..95422e284db 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -19,9 +19,183 @@ from robot.version import get_full_version from robot.result.visitor import ResultVisitor +from .loggerapi import LoggerApi from .loggerhelper import IsLogged +class XmlLoggerFacade(LoggerApi): + + def __init__(self, path, log_level='TRACE', rpa=False, generator='Robot'): + self._xml_logger = XmlLogger(path, log_level, rpa, generator) + self._flat_xml_logger = None + self._logger = self._xml_logger + + @property + def flat_xml_logger(self): + if self._flat_xml_logger is None: + self._flat_xml_logger = FlatXmlLogger(self._logger) + return self._flat_xml_logger + + def flatten(self, flatten): + if flatten: + self._logger = self.flat_xml_logger + else: + self._logger = self._xml_logger + + def close(self): + self._logger.close() + + def set_log_level(self, level): + return self._logger.set_log_level(level) + + def start_suite(self, data, result): + self._logger.start_suite(result) + + def end_suite(self, data, result): + self._logger.end_suite(result) + + def start_test(self, data, result): + self._logger.start_test(result) + + def end_test(self, data, result): + self._logger.end_test(result) + + def start_keyword(self, data, result): + self._logger.start_keyword(result) + + def end_keyword(self, data, result): + self._logger.end_keyword(result) + + def start_for(self, data, result): + self._logger.start_for(result) + + def end_for(self, data, result): + self._logger.end_for(result) + + def start_for_iteration(self, data, result): + self._logger.start_for_iteration(result) + + def end_for_iteration(self, data, result): + self._logger.end_for_iteration(result) + + def start_while(self, data, result): + self._logger.start_while(result) + + def end_while(self, data, result): + self._logger.end_while(result) + + def start_while_iteration(self, data, result): + self._logger.start_while_iteration(result) + + def end_while_iteration(self, data, result): + self._logger.end_while_iteration(result) + + def start_if(self, data, result): + self._logger.start_if(result) + + def end_if(self, data, result): + self._logger.end_if(result) + + def start_if_branch(self, data, result): + self._logger.start_if_branch(result) + + def end_if_branch(self, data, result): + self._logger.end_if_branch(result) + + def start_try(self, data, result): + self._logger.start_try(result) + + def end_try(self, data, result): + self._logger.end_try(result) + + def start_try_branch(self, data, result): + self._logger.start_try_branch(result) + + def end_try_branch(self, data, result): + self._logger.end_try_branch(result) + + def start_break(self, data, result): + self._logger.start_break(result) + + def end_break(self, data, result): + self._logger.end_break(result) + + def start_continue(self, data, result): + self._logger.start_continue(result) + + def end_continue(self, data, result): + self._logger.end_continue(result) + + def start_return(self, data, result): + self._logger.start_return(result) + + def end_return(self, data, result): + self._logger.end_return(result) + + def start_error(self, data, result): + self._logger.start_error(result) + + def end_error(self, data, result): + self._logger.end_error(result) + + def log_message(self, message): + self._logger.log_message(message) + + def message(self, message): + self._logger.message(message) + + def start_statistics(self, stats): + self._logger.start_statistics(stats) + + def end_statistics(self, stats): + self._logger.end_statistics(stats) + + def start_total_statistics(self, total_stats): + self._logger.start_total_statistics(total_stats) + + def end_total_statistics(self, total_stats): + self._logger.end_total_statistics(total_stats) + + def start_tag_statistics(self, tag_stats): + self._logger.start_tag_statistics(tag_stats) + + def end_tag_statistics(self, tag_stats): + self._logger.end_tag_statistics(tag_stats) + + def start_suite_statistics(self, suite_stats): + self._logger.start_suite_statistics(suite_stats) + + def end_suite_statistics(self, suite_stats): + self._logger.end_suite_statistics(suite_stats) + + def visit_statistics(self, stats): + if self.start_statistics(stats) is not False: + stats.total.visit(self) + stats.tags.visit(self) + stats.suite.visit(self) + self.end_statistics(stats) + + def visit_total_statistics(self, stats): + if self.start_total_statistics(stats) is not False: + stats.visit(self) + self.end_total_statistics(stats) + + def visit_tag_statistics(self, stats): + if self.start_tag_statistics(stats) is not False: + for stat in stats: + stat.visit(self) + self.end_tag_statistics(stats) + + def visit_suite_statistics(self, stats): + if self.start_suite_statistics(stats) is not False: + for stat in stats: + stat.visit(self) + self.end_suite_statistics(stats) + + def visit_stat(self, stat): + self._logger.visit_stat(stat) + + class XmlLogger(ResultVisitor): def __init__(self, path, log_level='TRACE', rpa=False, generator='Robot'): From 70102b6d94b6976926264d97889d751674d6f926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Sat, 7 Oct 2023 14:26:49 +0300 Subject: [PATCH 0765/1592] output: make debugfile compatible with loggerApi --- src/robot/output/debugfile.py | 39 +++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/robot/output/debugfile.py b/src/robot/output/debugfile.py index 559c5c77c69..6f79ce515b3 100644 --- a/src/robot/output/debugfile.py +++ b/src/robot/output/debugfile.py @@ -17,6 +17,7 @@ from robot.utils import file_writer, seq2str2 from .logger import LOGGER +from .loggerapi import LoggerApi from .loggerhelper import IsLogged @@ -34,7 +35,7 @@ def DebugFile(path): return _DebugFileWriter(outfile) -class _DebugFileWriter: +class _DebugFileWriter(LoggerApi): _separators = {'SUITE': '=', 'TEST': '-', 'KEYWORD': '~'} def __init__(self, outfile): @@ -44,39 +45,47 @@ def __init__(self, outfile): self._outfile = outfile self._is_logged = IsLogged('DEBUG') - def start_suite(self, suite): + def start_suite(self, data, result): self._separator('SUITE') - self._start('SUITE', suite.full_name, suite.start_time) + self._start('SUITE', data.full_name, result.start_time) self._separator('SUITE') - def end_suite(self, suite): + def end_suite(self, data, result): self._separator('SUITE') - self._end('SUITE', suite.full_name, suite.end_time, suite.elapsed_time) + self._end('SUITE', data.full_name, result.end_time, result.elapsed_time) self._separator('SUITE') if self._indent == 0: LOGGER.output_file('Debug', self._outfile.name) self.close() - def start_test(self, test): + def start_test(self, data, result): self._separator('TEST') - self._start('TEST', test.name, test.start_time) + self._start('TEST', result.name, result.start_time) self._separator('TEST') - def end_test(self, test): + def end_test(self, data, result): self._separator('TEST') - self._end('TEST', test.name, test.end_time, test.elapsed_time) + self._end('TEST', result.name, result.end_time, result.elapsed_time) self._separator('TEST') - def start_keyword(self, kw): + def start_keyword(self, data, result): if self._kw_level == 0: self._separator('KEYWORD') - name = kw.full_name if kw.type in kw.KEYWORD_TYPES else kw._name - self._start(kw.type, name, kw.start_time, seq2str2(kw.args)) + self._start(result.type, result.full_name, result.start_time, seq2str2(result.args)) self._kw_level += 1 - def end_keyword(self, kw): - name = kw.full_name if kw.type in kw.KEYWORD_TYPES else kw._name - self._end(kw.type, name, kw.end_time, kw.elapsed_time) + def end_keyword(self, data, result): + self._end(result.type, result.full_name, result.end_time, result.elapsed_time) + self._kw_level -= 1 + + def start_body_item(self, data, result): + if self._kw_level == 0: + self._separator('KEYWORD') + self._start(result.type, result._name, result.start_time) + self._kw_level += 1 + + def end_body_item(self, data, result): + self._end(result.type, result._name, result.end_time, result.elapsed_time) self._kw_level -= 1 def log_message(self, msg): From 236b8d25b4caf5d06d7f2631c344bcb3abd27e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Sun, 8 Oct 2023 08:17:35 +0300 Subject: [PATCH 0766/1592] outoput: remove unused LoggerProxy --- src/robot/output/logger.py | 63 ++----------------------------------- utest/output/test_logger.py | 9 ++---- 2 files changed, 6 insertions(+), 66 deletions(-) diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index 9a978e37176..0987ec296a1 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -21,7 +21,7 @@ from .console import ConsoleOutput from .filelogger import FileLogger from .loggerapi import LoggerApi -from .loggerhelper import AbstractLogger, AbstractLoggerProxy +from .loggerhelper import AbstractLogger from .modelcombiner import ModelCombiner from .stdoutlogsplitter import StdoutLogSplitter from ..result import ResultVisitor @@ -104,8 +104,6 @@ def register_console_logger(self, type='verbose', width=78, colors='AUTO', self._console_logger = self._wrap_and_relay(logger) def _wrap_and_relay(self, logger): - if not isinstance(logger, LoggerApi): - logger = LoggerProxy(logger) self._relay_cached_messages(logger) return logger @@ -149,8 +147,8 @@ def register_logger(self, *loggers): def unregister_logger(self, *loggers): for logger in loggers: - self._other_loggers = [proxy for proxy in self._other_loggers - if proxy.logger is not logger] + self._other_loggers = [l for l in self._other_loggers + if l is not logger] def disable_message_cache(self): self._message_cache = None @@ -480,59 +478,4 @@ def close(self): self.__init__(register_console_logger=False) -class LoggerProxy(AbstractLoggerProxy): - _methods = ('start_suite', 'end_suite', 'start_test', 'end_test', - 'start_keyword', 'end_keyword', 'message', 'log_message', - 'imported', 'output_file', 'close') - - _start_keyword_methods = { - 'For': 'start_for', - 'ForIteration': 'start_for_iteration', - 'While': 'start_while', - 'WhileIteration': 'start_while_iteration', - 'If': 'start_if', - 'IfBranch': 'start_if_branch', - 'Try': 'start_try', - 'TryBranch': 'start_try_branch', - 'Return': 'start_return', - 'Continue': 'start_continue', - 'Break': 'start_break', - 'Error': 'start_error' - } - _end_keyword_methods = { - 'For': 'end_for', - 'ForIteration': 'end_for_iteration', - 'While': 'end_while', - 'WhileIteration': 'end_while_iteration', - 'If': 'end_if', - 'IfBranch': 'end_if_branch', - 'Try': 'end_try', - 'TryBranch': 'end_try_branch', - 'Return': 'end_return', - 'Continue': 'end_continue', - 'Break': 'end_break', - 'Error': 'end_error' - } - - def start_keyword(self, kw): - # Dispatch start_keyword calls to more precise methods when logger - # implements them. This horrible hack is needed because internal logger - # knows only about keywords. It should be rewritten. - name = self._start_keyword_methods.get(type(kw.result).__name__) - if name and hasattr(self.logger, name): - method = getattr(self.logger, name) - else: - method = self.logger.start_keyword - method(kw) - - def end_keyword(self, kw): - # See start_keyword comment for explanation of this horrible hack. - name = self._end_keyword_methods.get(type(kw.result).__name__) - if name and hasattr(self.logger, name): - method = getattr(self.logger, name) - else: - method = self.logger.end_keyword - method(kw) - - LOGGER = Logger() diff --git a/utest/output/test_logger.py b/utest/output/test_logger.py index fbf27ae9dcb..927ffba85c2 100644 --- a/utest/output/test_logger.py +++ b/utest/output/test_logger.py @@ -29,6 +29,9 @@ def message(self, msg): def copy(self): return LoggerMock(*self.expected) + def close(self): + pass + class LoggerMock2(LoggerMock): @@ -82,12 +85,6 @@ def test_all_methods(self): assert_equal(logger.output_file, ('name', 'path')) assert_true(logger.closed) - def test_registered_logger_does_not_need_all_methods(self): - logger = LoggerMock(('Hello, world!', 'INFO')) - self.logger.register_logger(logger) - self.logger.output_file('name', 'path') - self.logger.close() - def test_close_removes_registered_loggers(self): logger = LoggerMock(('Hello, world!', 'INFO')) logger2 = LoggerMock2(('Hello, world!', 'INFO')) From 5f8aea4471c088d0b3120bd8e12114dcd209c29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Sun, 8 Oct 2023 20:59:03 +0300 Subject: [PATCH 0767/1592] output: make listener module compatible with LoggerApi --- src/robot/output/listeners.py | 132 +++++++++++++++++++++++++++ src/robot/output/logger.py | 163 +++++++--------------------------- src/robot/output/output.py | 8 +- utest/output/test_logger.py | 3 +- 4 files changed, 168 insertions(+), 138 deletions(-) diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index 20d6f3b36fd..ab1c5f87552 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -19,8 +19,10 @@ from robot.utils import Importer, is_string, split_args_from_name_or_path, type_name from .listenermethods import ListenerMethods, LibraryListenerMethods +from .loggerapi import LoggerApi from .loggerhelper import AbstractLoggerProxy, IsLogged from .logger import LOGGER +from .modelcombiner import ModelCombiner class Listeners: @@ -172,3 +174,133 @@ def import_listeners(cls, listeners, method_names, prefix=None, raise DataError(msg) LOGGER.error(msg) return imported + + +class ListenerAdapter(LoggerApi): + + def __init__(self, listener): + self.listener = listener + + def register(self, listeners, library): + self.listener.register(listeners, library) + + def unregister(self, library, close=False): + self.listener.unregister(library, close) + + def new_suite_scope(self): + self.listener.new_suite_scope() + + def discard_suite_scope(self): + self.listener.discard_suite_scope() + + def message(self, message: 'model.Message'): + self.listener.message(message) + + def log_message(self, message: 'model.Message'): + self.listener.log_message(message) + + def set_log_level(self, level): + self.listener.set_log_level(level) + + def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + suite = ModelCombiner(data, result, + tests=data.tests, + suites=data.suites, + test_count=data.test_count) + self.listener.start_suite(suite) + + def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + self.listener.end_suite(ModelCombiner(data, result)) + + def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + self.listener.start_test(ModelCombiner(data, result)) + + def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + self.listener.end_test(ModelCombiner(data, result)) + + def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_for(self, data: 'running.For', result: 'result.For'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_for(self, data: 'running.For', result: 'result.For'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_while(self, data: 'running.While', result: 'result.While'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_while(self, data: 'running.While', result: 'result.While'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_if(self, data: 'running.If', result: 'result.If'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_if(self, data: 'running.If', result: 'result.If'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_if_branch(self, data: 'running.If', result: 'result.IfBranch'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_if_branch(self, data: 'running.If', result: 'result.IfBranch'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_try(self, data: 'running.Try', result: 'result.Try'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_try(self, data: 'running.Try', result: 'result.Try'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_break(self, data, result): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_break(self, data, result): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_continue(self, data, result): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_continue(self, data, result): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_return(self, data, result): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_return(self, data, result): + self.listener.end_keyword(ModelCombiner(data, result)) + + def start_error(self, data, result): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_error(self, data, result): + self.listener.end_keyword(ModelCombiner(data, result)) + + def imported(self, import_type: str, name: str, attrs): + self.listener.imported(import_type, name, attrs) + + def output_file(self, type_: str, path: str): + self.listener.output_file(type_, path) + + def close(self): + self.listener.close() diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index 0987ec296a1..8c804d7e9e1 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -20,9 +20,7 @@ from .console import ConsoleOutput from .filelogger import FileLogger -from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger -from .modelcombiner import ModelCombiner from .stdoutlogsplitter import StdoutLogSplitter from ..result import ResultVisitor @@ -217,251 +215,150 @@ def disable_library_import_logging(self): self.log_message = self._prev_log_message_handlers.pop() def start_suite(self, data, result): - suite = ModelCombiner(data, result, - tests=data.tests, - suites=data.suites, - test_count=data.test_count) for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_suite(data, result) - else: - logger.start_suite(suite) + logger.start_suite(data, result) def end_suite(self, data, result): - suite = ModelCombiner(data, result) for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_suite(data, result) - else: - logger.end_suite(suite) + logger.end_suite(data, result) def start_test(self, data, result): - test = ModelCombiner(data, result) for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_test(data, result) - else: - logger.start_test(test) + logger.start_test(data, result) def end_test(self, data, result): - test = ModelCombiner(data, result) for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_test(data, result) - else: - logger.end_test(test) + logger.end_test(data, result) @start_body_item def start_keyword(self, data, result): - keyword = ModelCombiner(data, result) for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_keyword(data, result) - else: - logger.start_keyword(keyword) + logger.start_keyword(data, result) @end_body_item def end_keyword(self, data, result): - keyword = ModelCombiner(data, result) for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_keyword(data, result) - else: - logger.end_keyword(keyword) + logger.end_keyword(data, result) @start_body_item def start_for(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_for(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_for(data, result) @end_body_item def end_for(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_for(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_for(data, result) @start_body_item def start_for_iteration(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_for_iteration(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_for_iteration(data, result) @end_body_item def end_for_iteration(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_for_iteration(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_for_iteration(data, result) @start_body_item def start_while(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_while(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_while(data, result) @end_body_item def end_while(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_while(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_while(data, result) @start_body_item def start_while_iteration(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_while_iteration(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_while_iteration(data, result) @end_body_item def end_while_iteration(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_while_iteration(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_while_iteration(data, result) @start_body_item def start_if(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_if(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_if(data, result) @end_body_item def end_if(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_if(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_if(data, result) @start_body_item def start_if_branch(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_if_branch(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_if_branch(data, result) @end_body_item def end_if_branch(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_if_branch(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_if_branch(data, result) @start_body_item def start_try(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_try(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_try(data, result) @end_body_item def end_try(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_try(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_try(data, result) @start_body_item def start_try_branch(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_try_branch(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_try_branch(data, result) @end_body_item def end_try_branch(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_try_branch(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_try_branch(data, result) @start_body_item def start_break(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_break(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_break(data, result) @end_body_item def end_break(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_break(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_break(data, result) @start_body_item def start_continue(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_continue(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_continue(data, result) @end_body_item def end_continue(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_continue(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_continue(data, result) @start_body_item def start_return(self, data, result): - keyword = ModelCombiner(data, result) for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_return(data, result) - else: - logger.start_keyword(keyword) + logger.start_return(data, result) @end_body_item def end_return(self, data, result): - keyword = ModelCombiner(data, result) for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_return(data, result) - else: - logger.end_keyword(keyword) + logger.end_return(data, result) @start_body_item def start_error(self, data, result): for logger in self.start_loggers: - if isinstance(logger, LoggerApi): - logger.start_error(data, result) - else: - logger.start_keyword(ModelCombiner(data, result)) + logger.start_error(data, result) @end_body_item def end_error(self, data, result): for logger in self.end_loggers: - if isinstance(logger, LoggerApi): - logger.end_error(data, result) - else: - logger.end_keyword(ModelCombiner(data, result)) + logger.end_error(data, result) def imported(self, import_type, name, **attrs): for logger in self: diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 30b192b88de..0d5cddb4b02 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -15,11 +15,11 @@ from . import pyloggingconf from .debugfile import DebugFile -from .listeners import LibraryListeners, Listeners +from .listeners import LibraryListeners, Listeners, ListenerAdapter from .logger import LOGGER from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger -from .xmllogger import XmlLogger, FlatXmlLogger, XmlLoggerFacade +from .xmllogger import XmlLoggerFacade class Output(AbstractLogger, LoggerApi): @@ -28,8 +28,8 @@ def __init__(self, settings): AbstractLogger.__init__(self) self._xml_logger = XmlLoggerFacade(settings.output, settings.log_level, settings.rpa) - self.listeners = Listeners(settings.listeners, settings.log_level) - self.library_listeners = LibraryListeners(settings.log_level) + self.listeners = ListenerAdapter(Listeners(settings.listeners, settings.log_level)) + self.library_listeners = ListenerAdapter(LibraryListeners(settings.log_level)) self._register_loggers(DebugFile(settings.debug_file)) self._settings = settings self._flatten_level = 0 diff --git a/utest/output/test_logger.py b/utest/output/test_logger.py index 927ffba85c2..745e7a956fc 100644 --- a/utest/output/test_logger.py +++ b/utest/output/test_logger.py @@ -2,7 +2,8 @@ from robot.utils.asserts import assert_equal, assert_true, assert_false -from robot.output.logger import Logger, LoggerApi +from robot.output.logger import Logger +from robot.output.loggerapi import LoggerApi from robot.output.console.verbose import VerboseOutput From 6ab23b33a9d44e29596e21c95b9813a91843b7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Sun, 8 Oct 2023 21:18:34 +0300 Subject: [PATCH 0768/1592] xmllogger: refactoring --- src/robot/output/logger.py | 1 - src/robot/output/loggerapi.py | 18 ++--- src/robot/output/output.py | 8 +-- src/robot/output/xmllogger.py | 129 ++++++++++------------------------ 4 files changed, 52 insertions(+), 104 deletions(-) diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index 8c804d7e9e1..f3e9db3fea2 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -22,7 +22,6 @@ from .filelogger import FileLogger from .loggerhelper import AbstractLogger from .stdoutlogsplitter import StdoutLogSplitter -from ..result import ResultVisitor def start_body_item(method): diff --git a/src/robot/output/loggerapi.py b/src/robot/output/loggerapi.py index a3d09b3f701..8ca6d3b1433 100644 --- a/src/robot/output/loggerapi.py +++ b/src/robot/output/loggerapi.py @@ -68,28 +68,28 @@ def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): self.end_body_item(data, result) - def start_break(self, data, result): + def start_break(self, data: 'running.Break', result: 'result.Break'): self.start_body_item(data, result) - def end_break(self, data, result): + def end_break(self, data: 'running.Break', result: 'result.Break'): self.end_body_item(data, result) - def start_continue(self, data, result): + def start_continue(self, data: 'running.Continue', result: 'result.Continue'): self.start_body_item(data, result) - def end_continue(self, data, result): + def end_continue(self, data: 'running.Continue', result: 'result.Continue'): self.end_body_item(data, result) - def start_return(self, data, result): + def start_return(self, data: 'running.Return', result: 'result.Return'): self.start_body_item(data, result) - def end_return(self, data, result): + def end_return(self, data: 'running.Return', result: 'result.Return'): self.end_body_item(data, result) - def start_error(self, data, result): + def start_error(self, data: 'running.Error', result: 'result.Error'): self.start_body_item(data, result) - def end_error(self, data, result): + def end_error(self, data: 'running.Error', result: 'result.Error'): self.end_body_item(data, result) def start_body_item(self, data, result): @@ -104,7 +104,7 @@ def log_message(self, message: 'model.Message'): def message(self, message: 'model.Message'): pass - # FIXME: + # FIXME: This should probably be removed? def output_file(self, type_: str, path: str): pass diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 0d5cddb4b02..ffe17767079 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -19,15 +19,15 @@ from .logger import LOGGER from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger -from .xmllogger import XmlLoggerFacade +from .xmllogger import XmlLoggerAdapter class Output(AbstractLogger, LoggerApi): def __init__(self, settings): AbstractLogger.__init__(self) - self._xml_logger = XmlLoggerFacade(settings.output, settings.log_level, - settings.rpa) + self._xml_logger = XmlLoggerAdapter(settings.output, settings.log_level, + settings.rpa) self.listeners = ListenerAdapter(Listeners(settings.listeners, settings.log_level)) self.library_listeners = ListenerAdapter(LibraryListeners(settings.log_level)) self._register_loggers(DebugFile(settings.debug_file)) @@ -44,7 +44,7 @@ def register_error_listener(self, listener): LOGGER.register_error_listener(listener) def close(self, result): - self._xml_logger.visit_statistics(result.statistics) + self._xml_logger.logger.visit_statistics(result.statistics) self._xml_logger.close() LOGGER.unregister_xml_logger() LOGGER.output_file('Output', self._settings['Output']) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 95422e284db..04b85ca506b 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -23,177 +23,126 @@ from .loggerhelper import IsLogged -class XmlLoggerFacade(LoggerApi): +class XmlLoggerAdapter(LoggerApi): def __init__(self, path, log_level='TRACE', rpa=False, generator='Robot'): self._xml_logger = XmlLogger(path, log_level, rpa, generator) self._flat_xml_logger = None - self._logger = self._xml_logger + self.logger = self._xml_logger @property def flat_xml_logger(self): if self._flat_xml_logger is None: - self._flat_xml_logger = FlatXmlLogger(self._logger) + self._flat_xml_logger = FlatXmlLogger(self.logger) return self._flat_xml_logger def flatten(self, flatten): if flatten: - self._logger = self.flat_xml_logger + self.logger = self.flat_xml_logger else: - self._logger = self._xml_logger + self.logger = self._xml_logger def close(self): - self._logger.close() + self.logger.close() def set_log_level(self, level): - return self._logger.set_log_level(level) + return self.logger.set_log_level(level) def start_suite(self, data, result): - self._logger.start_suite(result) + self.logger.start_suite(result) def end_suite(self, data, result): - self._logger.end_suite(result) + self.logger.end_suite(result) def start_test(self, data, result): - self._logger.start_test(result) + self.logger.start_test(result) def end_test(self, data, result): - self._logger.end_test(result) + self.logger.end_test(result) def start_keyword(self, data, result): - self._logger.start_keyword(result) + self.logger.start_keyword(result) def end_keyword(self, data, result): - self._logger.end_keyword(result) + self.logger.end_keyword(result) def start_for(self, data, result): - self._logger.start_for(result) + self.logger.start_for(result) def end_for(self, data, result): - self._logger.end_for(result) + self.logger.end_for(result) def start_for_iteration(self, data, result): - self._logger.start_for_iteration(result) + self.logger.start_for_iteration(result) def end_for_iteration(self, data, result): - self._logger.end_for_iteration(result) + self.logger.end_for_iteration(result) def start_while(self, data, result): - self._logger.start_while(result) + self.logger.start_while(result) def end_while(self, data, result): - self._logger.end_while(result) + self.logger.end_while(result) def start_while_iteration(self, data, result): - self._logger.start_while_iteration(result) + self.logger.start_while_iteration(result) def end_while_iteration(self, data, result): - self._logger.end_while_iteration(result) + self.logger.end_while_iteration(result) def start_if(self, data, result): - self._logger.start_if(result) + self.logger.start_if(result) def end_if(self, data, result): - self._logger.end_if(result) + self.logger.end_if(result) def start_if_branch(self, data, result): - self._logger.start_if_branch(result) + self.logger.start_if_branch(result) def end_if_branch(self, data, result): - self._logger.end_if_branch(result) + self.logger.end_if_branch(result) def start_try(self, data, result): - self._logger.start_try(result) + self.logger.start_try(result) def end_try(self, data, result): - self._logger.end_try(result) + self.logger.end_try(result) def start_try_branch(self, data, result): - self._logger.start_try_branch(result) + self.logger.start_try_branch(result) def end_try_branch(self, data, result): - self._logger.end_try_branch(result) + self.logger.end_try_branch(result) def start_break(self, data, result): - self._logger.start_break(result) + self.logger.start_break(result) def end_break(self, data, result): - self._logger.end_break(result) + self.logger.end_break(result) def start_continue(self, data, result): - self._logger.start_continue(result) + self.logger.start_continue(result) def end_continue(self, data, result): - self._logger.end_continue(result) + self.logger.end_continue(result) def start_return(self, data, result): - self._logger.start_return(result) + self.logger.start_return(result) def end_return(self, data, result): - self._logger.end_return(result) + self.logger.end_return(result) def start_error(self, data, result): - self._logger.start_error(result) + self.logger.start_error(result) def end_error(self, data, result): - self._logger.end_error(result) + self.logger.end_error(result) def log_message(self, message): - self._logger.log_message(message) + self.logger.log_message(message) def message(self, message): - self._logger.message(message) - - def start_statistics(self, stats): - self._logger.start_statistics(stats) - - def end_statistics(self, stats): - self._logger.end_statistics(stats) - - def start_total_statistics(self, total_stats): - self._logger.start_total_statistics(total_stats) - - def end_total_statistics(self, total_stats): - self._logger.end_total_statistics(total_stats) - - def start_tag_statistics(self, tag_stats): - self._logger.start_tag_statistics(tag_stats) - - def end_tag_statistics(self, tag_stats): - self._logger.end_tag_statistics(tag_stats) - - def start_suite_statistics(self, suite_stats): - self._logger.start_suite_statistics(suite_stats) - - def end_suite_statistics(self, suite_stats): - self._logger.end_suite_statistics(suite_stats) - - def visit_statistics(self, stats): - if self.start_statistics(stats) is not False: - stats.total.visit(self) - stats.tags.visit(self) - stats.suite.visit(self) - self.end_statistics(stats) - - def visit_total_statistics(self, stats): - if self.start_total_statistics(stats) is not False: - stats.visit(self) - self.end_total_statistics(stats) - - def visit_tag_statistics(self, stats): - if self.start_tag_statistics(stats) is not False: - for stat in stats: - stat.visit(self) - self.end_tag_statistics(stats) - - def visit_suite_statistics(self, stats): - if self.start_suite_statistics(stats) is not False: - for stat in stats: - stat.visit(self) - self.end_suite_statistics(stats) - - def visit_stat(self, stat): - self._logger.visit_stat(stat) + self.logger.message(message) class XmlLogger(ResultVisitor): From b989523994a5a9032ae5fa33619447d116ef77b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 10 Oct 2023 23:24:11 +0300 Subject: [PATCH 0769/1592] Add VAR support to parser. #3761 All runtime functionality still missing. --- src/robot/api/parsing.py | 4 +- src/robot/parsing/lexer/blocklexers.py | 30 ++--- src/robot/parsing/lexer/statementlexers.py | 17 +++ src/robot/parsing/lexer/tokens.py | 8 +- src/robot/parsing/model/blocks.py | 4 +- src/robot/parsing/model/statements.py | 101 +++++++++++++---- utest/parsing/test_lexer.py | 98 ++++++++++++++++ utest/parsing/test_model.py | 124 ++++++++++++++++++++- utest/parsing/test_statements.py | 43 ++++++- 9 files changed, 389 insertions(+), 40 deletions(-) diff --git a/src/robot/api/parsing.py b/src/robot/api/parsing.py index ca0a8ec8883..56f075cdfb1 100644 --- a/src/robot/api/parsing.py +++ b/src/robot/api/parsing.py @@ -223,7 +223,7 @@ class were exposed directly via the :mod:`robot.api` package, but other - :class:`~robot.parsing.model.statements.Template` - :class:`~robot.parsing.model.statements.Timeout` - :class:`~robot.parsing.model.statements.Arguments` -- :class:`~robot.parsing.model.statements.Return` (deprecated, will mean ``ReturnStatement`` in RF 7.0) +- :class:`~robot.parsing.model.statements.Return` (deprecated, will mean ``ReturnStatement`` in RF 8.0) - :class:`~robot.parsing.model.statements.ReturnSetting` (alias for ``Return``, new in RF 6.1) - :class:`~robot.parsing.model.statements.KeywordCall` - :class:`~robot.parsing.model.statements.TemplateArguments` @@ -236,6 +236,7 @@ class were exposed directly via the :mod:`robot.api` package, but other - :class:`~robot.parsing.model.statements.FinallyHeader` - :class:`~robot.parsing.model.statements.ForHeader` - :class:`~robot.parsing.model.statements.WhileHeader` +- :class:`~robot.parsing.model.statements.Var` (new in RF 7.0) - :class:`~robot.parsing.model.statements.End` - :class:`~robot.parsing.model.statements.ReturnStatement` - :class:`~robot.parsing.model.statements.Break` @@ -545,6 +546,7 @@ def visit_File(self, node): ForHeader, WhileHeader, End, + Var, ReturnStatement, Continue, Break, diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py index 5bbaae7811b..6e24d4acd09 100644 --- a/src/robot/parsing/lexer/blocklexers.py +++ b/src/robot/parsing/lexer/blocklexers.py @@ -29,8 +29,9 @@ KeywordSettingLexer, Lexer, ReturnLexer, SettingLexer, SettingSectionHeaderLexer, SyntaxErrorLexer, TaskSectionHeaderLexer, TestCaseSectionHeaderLexer, - TestCaseSettingLexer, TryHeaderLexer, VariableLexer, - VariableSectionHeaderLexer, WhileHeaderLexer) + TestCaseSettingLexer, TryHeaderLexer, VarLexer, + VariableLexer, VariableSectionHeaderLexer, + WhileHeaderLexer) from .tokens import StatementTokens, Token @@ -200,8 +201,8 @@ def lex(self): self._lex_with_priority(priority=TestCaseSettingLexer) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': - return (TestCaseSettingLexer, ForLexer, InlineIfLexer, IfLexer, - TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) + return (TestCaseSettingLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, + WhileLexer, VarLexer, SyntaxErrorLexer, KeywordCallLexer) class KeywordLexer(TestOrKeywordLexer): @@ -211,8 +212,8 @@ def __init__(self, ctx: FileContext): super().__init__(ctx.keyword_context()) def lexer_classes(self) -> 'tuple[type[Lexer], ...]': - return (KeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, ReturnLexer, - TryLexer, WhileLexer, SyntaxErrorLexer, KeywordCallLexer) + return (KeywordSettingLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, + WhileLexer, VarLexer, ReturnLexer, SyntaxErrorLexer, KeywordCallLexer) class NestedBlockLexer(BlockLexer, ABC): @@ -242,7 +243,8 @@ def handles(self, statement: StatementTokens) -> bool: def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (ForHeaderLexer, InlineIfLexer, IfLexer, TryLexer, WhileLexer, EndLexer, - ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, KeywordCallLexer) + VarLexer, ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, + KeywordCallLexer) class WhileLexer(NestedBlockLexer): @@ -252,7 +254,8 @@ def handles(self, statement: StatementTokens) -> bool: def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (WhileHeaderLexer, ForLexer, InlineIfLexer, IfLexer, TryLexer, EndLexer, - ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, KeywordCallLexer) + VarLexer, ReturnLexer, ContinueLexer, BreakLexer, SyntaxErrorLexer, + KeywordCallLexer) class TryLexer(NestedBlockLexer): @@ -262,8 +265,9 @@ def handles(self, statement: StatementTokens) -> bool: def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (TryHeaderLexer, ExceptHeaderLexer, ElseHeaderLexer, FinallyHeaderLexer, - ForLexer, InlineIfLexer, IfLexer, WhileLexer, EndLexer, ReturnLexer, - BreakLexer, ContinueLexer, SyntaxErrorLexer, KeywordCallLexer) + ForLexer, InlineIfLexer, IfLexer, WhileLexer, EndLexer, VarLexer, + ReturnLexer, BreakLexer, ContinueLexer, SyntaxErrorLexer, + KeywordCallLexer) class IfLexer(NestedBlockLexer): @@ -273,8 +277,8 @@ def handles(self, statement: StatementTokens) -> bool: def lexer_classes(self) -> 'tuple[type[Lexer], ...]': return (InlineIfLexer, IfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, - ForLexer, TryLexer, WhileLexer, EndLexer, ReturnLexer, ContinueLexer, - BreakLexer, SyntaxErrorLexer, KeywordCallLexer) + ForLexer, TryLexer, WhileLexer, EndLexer, VarLexer, ReturnLexer, + ContinueLexer, BreakLexer, SyntaxErrorLexer, KeywordCallLexer) class InlineIfLexer(NestedBlockLexer): @@ -288,7 +292,7 @@ def accepts_more(self, statement: StatementTokens) -> bool: return False def lexer_classes(self) -> 'tuple[type[Lexer], ...]': - return (InlineIfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, + return (InlineIfHeaderLexer, ElseIfHeaderLexer, ElseHeaderLexer, VarLexer, ReturnLexer, ContinueLexer, BreakLexer, KeywordCallLexer) def input(self, statement: StatementTokens): diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index b2a747b4c5a..3a993547ea1 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -334,6 +334,23 @@ def handles(self, statement: StatementTokens) -> bool: return statement[0].value == 'END' +class VarLexer(StatementLexer): + token_type = Token.VAR + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == 'VAR' + + def lex(self): + self.statement[0].type = Token.VAR + if len(self.statement) > 1: + name, *values = self.statement[1:] + name.type = Token.VARIABLE + for value in values: + value.type = Token.ARGUMENT + options = ['scope', 'separator'] if name.value[0] == '$' else ['scope'] + self._lex_options(*options) + + class ReturnLexer(TypeAndArguments): token_type = Token.RETURN_STATEMENT diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 301a9a68de5..25dbaae52a3 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -99,6 +99,7 @@ class Token: EXCEPT = 'EXCEPT' FINALLY = 'FINALLY' WHILE = 'WHILE' + VAR = 'VAR' RETURN_STATEMENT = 'RETURN STATEMENT' CONTINUE = 'CONTINUE' BREAK = 'BREAK' @@ -172,9 +173,10 @@ def __init__(self, type: 'str|None' = None, value: 'str|None' = None, Token.IF: 'IF', Token.INLINE_IF: 'IF', Token.ELSE_IF: 'ELSE IF', Token.ELSE: 'ELSE', Token.FOR: 'FOR', Token.WHILE: 'WHILE', Token.TRY: 'TRY', Token.EXCEPT: 'EXCEPT', Token.FINALLY: 'FINALLY', - Token.END: 'END', Token.CONTINUE: 'CONTINUE', Token.BREAK: 'BREAK', - Token.RETURN_STATEMENT: 'RETURN', Token.CONTINUATION: '...', - Token.EOL: '\n', Token.WITH_NAME: 'AS', Token.AS: 'AS' + Token.END: 'END', Token.VAR: 'VAR', Token.CONTINUE: 'CONTINUE', + Token.BREAK: 'BREAK', Token.RETURN_STATEMENT: 'RETURN', + Token.CONTINUATION: '...', Token.EOL: '\n', Token.WITH_NAME: 'AS', + Token.AS: 'AS' }.get(type, '') # type: ignore self.value = cast(str, value) self.lineno = lineno diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py index 38eb48efe29..031cd8fe1f9 100644 --- a/src/robot/parsing/model/blocks.py +++ b/src/robot/parsing/model/blocks.py @@ -25,7 +25,7 @@ Error, FinallyHeader, ForHeader, IfHeader, KeywordCall, KeywordName, Node, ReturnSetting, ReturnStatement, SectionHeader, Statement, TemplateArguments, TestCaseName, - TryHeader, WhileHeader) + TryHeader, Var, WhileHeader) from .visitor import ModelVisitor from ..lexer import Token @@ -98,7 +98,7 @@ def __init__(self, header: 'Statement|None', body: Body = (), errors: Errors = ( def _body_is_empty(self): # This works with tests, keywords, and blocks inside them, not with sections. - valid = (KeywordCall, TemplateArguments, Continue, Break, ReturnSetting, + valid = (KeywordCall, TemplateArguments, Var, Continue, Break, ReturnSetting, ReturnStatement, NestedBlock, Error) return not any(isinstance(node, valid) for node in self.body) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 704a3362426..1c781eb61a3 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -676,27 +676,9 @@ def separator(self) -> 'str|None': return self.get_option('separator') def validate(self, ctx: 'ValidationContext'): - name = self.get_value(Token.VARIABLE) - match = search_variable(name, ignore_errors=True) - if not match.is_assign(allow_assign_mark=True, allow_nested=True): - self.errors += (f"Invalid variable name '{name}'.",) - if match.is_dict_assign(allow_assign_mark=True): - self._validate_dict_items() + VariableValidator(allow_assign_mark=True).validate(self) self._validate_options() - def _validate_dict_items(self): - for item in self.get_values(Token.ARGUMENT): - if not self._is_valid_dict_item(item): - self.errors += ( - f"Invalid dictionary variable item '{item}'. " - f"Items must use 'name=value' syntax or be dictionary " - f"variables themselves.", - ) - - def _is_valid_dict_item(self, item: str) -> bool: - name, value = split_from_equals(item) - return value is not None or is_dict_variable(item) - @Statement.register class TestCaseName(Statement): @@ -1245,6 +1227,60 @@ def validate(self, ctx: 'ValidationContext'): self._validate_options() +@Statement.register +class Var(Statement): + type = Token.VAR + options = { + 'scope': ('GLOBAL', 'SUITE', 'TEST', 'LOCAL'), + 'separator': None + } + + @classmethod + def from_params(cls, name: str, + value: 'str|Sequence[str]', + scope: 'str|None' = None, + value_separator: 'str|None' = None, + indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, + eol: str = EOL) -> 'Var': + tokens = [Token(Token.SEPARATOR, indent), + Token(Token.VAR), + Token(Token.SEPARATOR, separator), + Token(Token.VARIABLE, name)] + values = [value] if isinstance(value, str) else value + for value in values: + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value)]) + if scope: + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f'scope={scope}')]) + if value_separator: + tokens.extend([Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f'separator={value_separator}')]) + tokens.append(Token(Token.EOL, eol)) + return cls(tokens) + + @property + def name(self) -> str: + return self.get_value(Token.VARIABLE, '') + + @property + def value(self) -> 'tuple[str, ...]': + return self.get_values(Token.ARGUMENT) + + @property + def scope(self) -> 'str|None': + return self.get_option('scope') + + @property + def separator(self) -> 'str|None': + return self.get_option('separator') + + def validate(self, ctx: 'ValidationContext'): + VariableValidator().validate(self) + self._validate_options() + + @Statement.register class ReturnStatement(Statement): type = Token.RETURN_STATEMENT @@ -1361,3 +1397,30 @@ class EmptyLine(Statement): @classmethod def from_params(cls, eol: str = EOL): return cls([Token(Token.EOL, eol)]) + + +class VariableValidator: + + def __init__(self, allow_assign_mark: bool = False): + self.allow_assign_mark = allow_assign_mark + + def validate(self, statement: Statement): + name = statement.get_value(Token.VARIABLE, '') + match = search_variable(name, ignore_errors=True) + if not match.is_assign(allow_assign_mark=self.allow_assign_mark, + allow_nested=True): + statement.errors += (f"Invalid variable name '{name}'.",) + if match.identifier == '&': + self._validate_dict_items(statement) + + def _validate_dict_items(self, statement: Statement): + for item in statement.get_values(Token.ARGUMENT): + if not self._is_valid_dict_item(item): + statement.errors += ( + f"Invalid dictionary variable item '{item}'. Items must use " + f"'name=value' syntax or be dictionary variables themselves.", + ) + + def _is_valid_dict_item(self, item: str) -> bool: + name, value = split_from_equals(item) + return value is not None or is_dict_variable(item) diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 792f24bea04..d132c451533 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -2267,6 +2267,104 @@ def _verify(self, data, expected, test=False): assert_tokens(data, expected, data_only=True) +class TestVar(unittest.TestCase): + + def test_simple(self): + data = 'VAR ${name} value' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '${name}', 3, 11), + (T.ARGUMENT, 'value', 3, 22), + (T.EOS, '', 3, 27) + ] + self._verify(data, expected) + + def test_multiple_values(self): + data = 'VAR @{name} v1 v2\n... v3' + expected = [ + (T.VAR, None, 3, 4), + (T.VARIABLE, '@{name}', 3, 11), + (T.ARGUMENT, 'v1', 3, 22), + (T.ARGUMENT, 'v2', 3, 28), + (T.ARGUMENT, 'v3', 4, 7), + (T.EOS, '', 4, 9) + ] + self._verify(data, expected) + + def test_no_values(self): + data = 'VAR @{name}' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '@{name}', 3, 11), + (T.EOS, '', 3, 18) + ] + self._verify(data, expected) + + def test_no_name(self): + data = 'VAR' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.EOS, '', 3, 7) + ] + self._verify(data, expected) + + def test_scope(self): + data = 'VAR ${name} value scope=GLOBAL' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '${name}', 3, 11), + (T.ARGUMENT, 'value', 3, 22), + (T.OPTION, 'scope=GLOBAL', 3, 31), + (T.EOS, '', 3, 43) + ] + self._verify(data, expected) + + def test_separator_with_scalar(self): + data = 'VAR ${name} v1 v2 separator=-' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '${name}', 3, 11), + (T.ARGUMENT, 'v1', 3, 22), + (T.ARGUMENT, 'v2', 3, 28), + (T.OPTION, 'separator=-', 3, 34), + (T.EOS, '', 3, 45) + ] + self._verify(data, expected) + + def test_no_separator_with_list(self): + data = 'VAR @{name} v1 v2 separator=-' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '@{name}', 3, 11), + (T.ARGUMENT, 'v1', 3, 22), + (T.ARGUMENT, 'v2', 3, 28), + (T.ARGUMENT, 'separator=-', 3, 34), + (T.EOS, '', 3, 45) + ] + self._verify(data, expected) + + def test_no_separator_with_dict(self): + data = 'VAR &{name} k1=v1 k2=v2 separator=-' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '&{name}', 3, 11), + (T.ARGUMENT, 'k1=v1', 3, 22), + (T.ARGUMENT, 'k2=v2', 3, 31), + (T.ARGUMENT, 'separator=-', 3, 40), + (T.EOS, '', 3, 51) + ] + self._verify(data, expected) + + def _verify(self, data, expected): + data = ' ' + ' \n'.join(data.splitlines()) + data = f'*** Test Cases ***\nName\n{data}' + expected = [(T.TESTCASE_HEADER, '*** Test Cases ***', 1, 0), + (T.EOS, '', 1, 18), + (T.TESTCASE_NAME, 'Name', 2, 0), + (T.EOS, '', 2, 4)] + expected + assert_tokens(data, expected, data_only=True) + + class TestLanguageConfig(unittest.TestCase): def test_lang_as_code(self): diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 15c84fdef31..c51eb6cffbf 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -13,7 +13,7 @@ Arguments, Break, Comment, Config, Continue, Documentation, ForHeader, End, ElseHeader, ElseIfHeader, EmptyLine, Error, IfHeader, InlineIfHeader, TryHeader, ExceptHeader, FinallyHeader, KeywordCall, KeywordName, Return, ReturnSetting, ReturnStatement, - SectionHeader, TestCaseName, Variable, WhileHeader + SectionHeader, TestCaseName, Var, Variable, WhileHeader ) from robot.utils.asserts import assert_equal, assert_raises_with_msg @@ -139,6 +139,7 @@ def get_and_assert_model(data, expected, depth=2): for _ in range(depth): node = node.body[0] assert_model(node, expected) + return node class TestGetModel(unittest.TestCase): @@ -984,6 +985,127 @@ def test_invalid(self): get_and_assert_model(data, expected, depth=0) +class TestVar(unittest.TestCase): + + def test_valid(self): + data = ''' +*** Test Cases *** +Test + VAR ${x} value + VAR @{y} two values + VAR &{z} one=item + VAR ${x${y}} nested name +''' + expected = TestCase( + header=TestCaseName([Token(Token.TESTCASE_NAME, 'Test', 2, 0)]), + body=[ + Var([Token(Token.VAR, 'VAR', 3, 4), + Token(Token.VARIABLE, '${x}', 3, 11), + Token(Token.ARGUMENT, 'value', 3, 23)]), + Var([Token(Token.VAR, 'VAR', 4, 4), + Token(Token.VARIABLE, '@{y}', 4, 11), + Token(Token.ARGUMENT, 'two', 4, 23), + Token(Token.ARGUMENT, 'values', 4, 30)]), + Var([Token(Token.VAR, 'VAR', 5, 4), + Token(Token.VARIABLE, '&{z}', 5, 11), + Token(Token.ARGUMENT, 'one=item', 5, 23)]), + Var([Token(Token.VAR, 'VAR', 6, 4), + Token(Token.VARIABLE, '${x${y}}', 6, 11), + Token(Token.ARGUMENT, 'nested name', 6, 23)]) + ] + ) + test = get_and_assert_model(data, expected, depth=1) + assert_equal([v.name for v in test.body], ['${x}', '@{y}', '&{z}', '${x${y}}']) + + def test_options(self): + data = r''' +*** Test Cases *** +Test + VAR ${a} a scope=TEST + VAR ${b} a b separator=\n scope=${scope} + VAR @{c} a b separator=normal item scope=global + VAR &{d} k=v separator=normal item scope=LoCaL + VAR ${e} separator=- +''' + expected = TestCase( + header=TestCaseName([Token(Token.TESTCASE_NAME, 'Test', 2, 0)]), + body=[ + Var([Token(Token.VAR, 'VAR', 3, 4), + Token(Token.VARIABLE, '${a}', 3, 11), + Token(Token.ARGUMENT, 'a', 3, 19), + Token(Token.OPTION, 'scope=TEST', 3, 29)]), + Var([Token(Token.VAR, 'VAR', 4, 4), + Token(Token.VARIABLE, '${b}', 4, 11), + Token(Token.ARGUMENT, 'a', 4, 19), + Token(Token.ARGUMENT, 'b', 4, 24), + Token(Token.OPTION, r'separator=\n', 4, 29), + Token(Token.OPTION, 'scope=${scope}', 4, 45)]), + Var([Token(Token.VAR, 'VAR', 5, 4), + Token(Token.VARIABLE, '@{c}', 5, 11), + Token(Token.ARGUMENT, 'a', 5, 19), + Token(Token.ARGUMENT, 'b', 5, 24), + Token(Token.ARGUMENT, 'separator=normal item', 5, 29), + Token(Token.OPTION, 'scope=global', 5, 54)]), + Var([Token(Token.VAR, 'VAR', 6, 4), + Token(Token.VARIABLE, '&{d}', 6, 11), + Token(Token.ARGUMENT, 'k=v', 6, 19), + Token(Token.ARGUMENT, 'separator=normal item', 6, 29), + Token(Token.OPTION, 'scope=LoCaL', 6, 54)]), + Var([Token(Token.VAR, 'VAR', 7, 4), + Token(Token.VARIABLE, '${e}', 7, 11), + Token(Token.OPTION, 'separator=-', 7, 29)]), + ] + ) + test = get_and_assert_model(data, expected, depth=1) + assert_equal([(v.scope, v.separator) for v in test.body], + [('TEST', None), ('${scope}', r'\n'), ('global', None), + ('LoCaL', None), (None, '-')]) + + def test_invalid(self): + data = ''' +*** Keywords *** +Keyword + VAR bad name + VAR ${not closed + VAR ${x}= = not accepted + VAR + VAR &{d} o=k bad + VAR ${x} ok scope=bad +''' + expected = Keyword( + header=KeywordName([Token(Token.KEYWORD_NAME, 'Keyword', 2, 0)]), + body=[ + Var([Token(Token.VAR, 'VAR', 3, 4), + Token(Token.VARIABLE, 'bad', 3, 11), + Token(Token.ARGUMENT, 'name', 3, 20)], + ["Invalid variable name 'bad'."]), + Var([Token(Token.VAR, 'VAR', 4, 4), + Token(Token.VARIABLE, '${not', 4, 11), + Token(Token.ARGUMENT, 'closed', 4, 20)], + ["Invalid variable name '${not'."]), + Var([Token(Token.VAR, 'VAR', 5, 4), + Token(Token.VARIABLE, '${x}=', 5, 11), + Token(Token.ARGUMENT, '= not accepted', 5, 20)], + ["Invalid variable name '${x}='."]), + Var([Token(Token.VAR, 'VAR', 6, 4)], + ["Invalid variable name ''."]), + Var([Token(Token.VAR, 'VAR', 7, 4), + Token(Token.VARIABLE, '&{d}', 7, 11), + Token(Token.ARGUMENT, 'o=k', 7, 20), + Token(Token.ARGUMENT, 'bad', 7, 27)], + ["Invalid dictionary variable item 'bad'. Items must use " + "'name=value' syntax or be dictionary variables themselves."]), + Var([Token(Token.VAR, 'VAR', 8, 4), + Token(Token.VARIABLE, '${x}', 8, 11), + Token(Token.ARGUMENT, 'ok', 8, 20), + Token(Token.OPTION, 'scope=bad', 8, 27)], + ["VAR option 'scope' does not accept value 'bad'. " + "Valid values are 'GLOBAL', 'SUITE', 'TEST' and 'LOCAL'."]), + ] + ) + get_and_assert_model(data, expected, depth=1) + + class TestTestCase(unittest.TestCase): def test_empty_test(self): diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index f303721b470..244f22825ab 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -955,13 +955,54 @@ def test_End(self): tokens = [ Token(Token.SEPARATOR, ' '), Token(Token.END), - Token(Token.EOL, '\n') + Token(Token.EOL) ] assert_created_statement( tokens, End ) + def test_Var(self): + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.VAR), + Token(Token.SEPARATOR, ' '), + Token(Token.VARIABLE, '${name}'), + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'value'), + Token(Token.EOL) + ] + var = assert_created_statement( + tokens, + Var, + name='${name}', + value='value' + ) + assert_equal(var.name, '${name}') + assert_equal(var.value, ('value',)) + assert_equal(var.scope, None) + assert_equal(var.separator, None) + tokens[-1:-1] = [ + Token(Token.SEPARATOR, ' '), + Token(Token.ARGUMENT, 'value 2'), + Token(Token.SEPARATOR, ' '), + Token(Token.OPTION, 'scope=SUITE'), + Token(Token.SEPARATOR, ' '), + Token(Token.OPTION, r'separator=\n'), + ] + var = assert_created_statement( + tokens, + Var, + name='${name}', + value=('value', 'value 2'), + scope='SUITE', + value_separator=r'\n' + ) + assert_equal(var.name, '${name}') + assert_equal(var.value, ('value', 'value 2')) + assert_equal(var.scope, 'SUITE') + assert_equal(var.separator, r'\n') + def test_ReturnStatement(self): tokens = [ Token(Token.SEPARATOR, ' '), From 5fb4327db896d880c4a0aae0de4a78b5b609c69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 12 Oct 2023 16:52:33 +0300 Subject: [PATCH 0770/1592] Support VAR syntax during execution. #3761 Nothing is written to output.xml yet. --- atest/robot/variables/var_syntax.robot | 50 ++++++++++ atest/testdata/variables/var_syntax.robot | 107 ++++++++++++++++++++++ doc/schema/running.json | 62 +++++++++++++ doc/schema/running_json_schema.py | 20 ++-- src/robot/libraries/BuiltIn.py | 2 +- src/robot/model/__init__.py | 3 +- src/robot/model/body.py | 19 ++-- src/robot/model/control.py | 32 +++++++ src/robot/parsing/model/statements.py | 2 +- src/robot/result/__init__.py | 5 +- src/robot/result/model.py | 74 +++++++++------ src/robot/running/__init__.py | 2 +- src/robot/running/builder/transformers.py | 24 +++++ src/robot/running/model.py | 62 ++++++++++++- src/robot/running/statusreporter.py | 6 +- src/robot/variables/scopes.py | 2 +- src/robot/variables/tablesetter.py | 6 +- utest/parsing/test_model.py | 4 +- utest/running/test_run_model.py | 16 +++- 19 files changed, 436 insertions(+), 62 deletions(-) create mode 100644 atest/robot/variables/var_syntax.robot create mode 100644 atest/testdata/variables/var_syntax.robot diff --git a/atest/robot/variables/var_syntax.robot b/atest/robot/variables/var_syntax.robot new file mode 100644 index 00000000000..fec3eb1ecc8 --- /dev/null +++ b/atest/robot/variables/var_syntax.robot @@ -0,0 +1,50 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} variables/var_syntax.robot +Resource atest_resource.robot + +*** Test Cases *** +Scalar + Check Test Case ${TESTNAME} + +Scalar with separator + Check Test Case ${TESTNAME} + +List + Check Test Case ${TESTNAME} + +Dict + Check Test Case ${TESTNAME} + +Scopes + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Invalid scope + Check Test Case ${TESTNAME} + +Invalid scope from variable + Check Test Case ${TESTNAME} + +Non-existing variable as scope + Check Test Case ${TESTNAME} + +Non-existing variable in value + Check Test Case ${TESTNAME} + +Non-existing variable in separator + Check Test Case ${TESTNAME} + +With FOR + Check Test Case ${TESTNAME} + +With WHILE + Check Test Case ${TESTNAME} + +With IF + Check Test Case ${TESTNAME} + +With inline IF + Check Test Case ${TESTNAME} + +With TRY + Check Test Case ${TESTNAME} diff --git a/atest/testdata/variables/var_syntax.robot b/atest/testdata/variables/var_syntax.robot new file mode 100644 index 00000000000..e4d97944d89 --- /dev/null +++ b/atest/testdata/variables/var_syntax.robot @@ -0,0 +1,107 @@ +*** Test Cases *** +Scalar + VAR ${name} value + Should Be Equal ${name} value + +Scalar with separator + VAR ${name} a b c separator=- + Should Be Equal ${name} a-b-c + +List + VAR @{name} v1 v2 v3 + Should Be Equal ${name} ${{['v1', 'v2', 'v3']}} + +Dict + VAR &{name} k1=v1 k2=v2 + Should Be Equal ${name} ${{{'k1': 'v1', 'k2': 'v2'}}} + +Scopes 1 + VAR ${local1} local1 + VAR ${local2} local2 scope=LOCAL + VAR ${test} test scope=test + VAR ${suite} suite scope=${{'suite'}} + VAR ${global} global scope=GLOBAL + Should Be Equal ${local1} local1 + Should Be Equal ${local2} local2 + Should Be Equal ${test} test + Should Be Equal ${suite} suite + Should Be Equal ${global} global + Scopes + Should Be Equal ${test} new-test + Variable Should Not Exist ${local3} + +Scopes 2 + Variable Should Not Exist ${local1} + Variable Should Not Exist ${local2} + Should Be Equal ${suite} suite + Should Be Equal ${global} global + +Invalid scope + [Documentation] FAIL VAR option 'scope' does not accept value 'invalid'. Valid values are 'GLOBAL', 'SUITE', 'TEST', 'TASK' and 'LOCAL'. + VAR ${x} x scope=invalid + +Invalid scope from variable + [Documentation] FAIL Invalid VAR scope: Value 'invalid' is not accepted. Valid values are 'GLOBAL', 'SUITE', 'TEST', 'TASK' and 'LOCAL'. + VAR ${x} x scope=${{'invalid'}} + +Non-existing variable as scope + [Documentation] FAIL Invalid VAR scope: Variable '\${invalid}' not found. + VAR ${x} x scope=${invalid} + +Non-existing variable in value + [Documentation] FAIL Setting variable '\${x} failed: Variable '\${bad}' not found. + VAR ${x} ${bad} + +Non-existing variable in separator + [Documentation] FAIL Setting variable '\${x} failed: Variable '\${bad}' not found. + VAR ${x} a b separator=${bad} + +With FOR + FOR ${x} IN a b c + VAR ${y} ${x} + Should Be Equal ${y} ${x} + END + Should Be Equal ${y} c + +With WHILE + VAR ${cond} True + WHILE ${cond} + VAR ${cond} False + END + Should Be Equal ${cond} False + +With IF + IF True + VAR ${x} set + ELSE + VAR ${x} not set + END + Should Be Equal ${x} set + +With inline IF + IF False VAR ${x} not set ELSE VAR ${x} set + Should Be Equal ${x} set + +With TRY + TRY + VAR ${x} try + EXCEPT + VAR ${x} not set + ELSE + VAR ${x} ${x}-else + FINALLY + VAR ${x} ${x}-finally + END + Should Be Equal ${x} try-else-finally + +*** Keywords *** +Scopes + Variable Should Not Exist ${local1} + Variable Should Not Exist ${local2} + Should Be Equal ${test} test + Should Be Equal ${suite} suite + Should Be Equal ${global} global + VAR ${local3} local3 + VAR ${test} new-${test} scope=test + Should Be Equal ${local3} local3 + Should Be Equal ${test} new-test diff --git a/doc/schema/running.json b/doc/schema/running.json index 8cec93b929b..5c69ff283ac 100644 --- a/doc/schema/running.json +++ b/doc/schema/running.json @@ -37,6 +37,50 @@ ], "additionalProperties": false }, + "Var": { + "title": "Var", + "type": "object", + "properties": { + "lineno": { + "title": "Lineno", + "type": "integer" + }, + "error": { + "title": "Error", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "value": { + "title": "Value", + "type": "array", + "items": { + "type": "string" + } + }, + "scope": { + "title": "Scope", + "type": "string" + }, + "separator": { + "title": "Separator", + "type": "string" + }, + "type": { + "title": "Type", + "default": "VAR", + "const": "VAR", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false + }, "Break": { "title": "Break", "type": "object", @@ -199,6 +243,9 @@ { "$ref": "#/definitions/Try" }, + { + "$ref": "#/definitions/Var" + }, { "$ref": "#/definitions/Break" }, @@ -297,6 +344,9 @@ { "$ref": "#/definitions/Try" }, + { + "$ref": "#/definitions/Var" + }, { "$ref": "#/definitions/Break" }, @@ -398,6 +448,9 @@ { "$ref": "#/definitions/Try" }, + { + "$ref": "#/definitions/Var" + }, { "$ref": "#/definitions/Break" }, @@ -487,6 +540,9 @@ { "$ref": "#/definitions/Try" }, + { + "$ref": "#/definitions/Var" + }, { "$ref": "#/definitions/Break" }, @@ -578,6 +634,9 @@ { "$ref": "#/definitions/Try" }, + { + "$ref": "#/definitions/Var" + }, { "$ref": "#/definitions/Error" } @@ -734,6 +793,9 @@ { "$ref": "#/definitions/Return" }, + { + "$ref": "#/definitions/Var" + }, { "$ref": "#/definitions/Error" } diff --git a/doc/schema/running_json_schema.py b/doc/schema/running_json_schema.py index 1353d8a9991..0076a366026 100755 --- a/doc/schema/running_json_schema.py +++ b/doc/schema/running_json_schema.py @@ -27,6 +27,14 @@ class BodyItem(BaseModel): error: str | None +class Var(BodyItem): + type = Field('VAR', const=True) + name: str + value: Sequence[str] + scope: str | None + separator: str | None + + class Return(BodyItem): type = Field('RETURN', const=True) values: Sequence[str] @@ -60,7 +68,7 @@ class For(BodyItem): start: str | None mode: str | None fill: str | None - body: list['Keyword | For | While | If | Try | Break | Continue | Return | Error'] + body: list['Keyword | For | While | If | Try | Var | Break | Continue | Return | Error'] class While(BodyItem): @@ -69,13 +77,13 @@ class While(BodyItem): limit: str | None on_limit: str | None on_limit_message: str | None - body: list['Keyword | For | While | If | Try | Break | Continue | Return | Error'] + body: list['Keyword | For | While | If | Try | Var | Break | Continue | Return | Error'] class IfBranch(BodyItem): type: Literal['IF', 'ELSE IF', 'ELSE'] condition: str | None - body: list['Keyword | For | While | If | Try | Break | Continue | Return | Error'] + body: list['Keyword | For | While | If | Try | Var | Break | Continue | Return | Error'] class If(BodyItem): @@ -88,7 +96,7 @@ class TryBranch(BodyItem): patterns: Sequence[str] | None pattern_type: str | None assign: str | None - body: list['Keyword | For | While | If | Try | Break | Continue | Return | Error'] + body: list['Keyword | For | While | If | Try | Var | Break | Continue | Return | Error'] class Try(BodyItem): @@ -106,7 +114,7 @@ class TestCase(BaseModel): error: str | None setup: Keyword | None teardown: Keyword | None - body: list[Keyword | For | While | If | Try | Error] + body: list[Keyword | For | While | If | Try | Var | Error] class TestSuite(BaseModel): @@ -159,7 +167,7 @@ class UserKeyword(BaseModel): error: str | None setup: Keyword | None teardown: Keyword | None - body: list[Keyword | For | While | If | Try | Return | Error] + body: list[Keyword | For | While | If | Try | Return | Var | Error] class Resource(BaseModel): diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 419e633b182..f71ccf38a1d 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1672,7 +1672,7 @@ def set_local_variable(self, name, *values): """ name = self._get_var_name(name) value = self._get_var_value(name, values) - self._variables.set_local_variable(name, value) + self._variables.set_local(name, value) self._log_set_variable(name, value) @run_keyword_variant(resolve=0) diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index dd69a8dde7d..87056fb00d1 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -27,7 +27,8 @@ from .body import BaseBody, Body, BodyItem, BaseBranches from .configurer import SuiteConfigurer -from .control import Break, Continue, Error, For, If, IfBranch, Return, Try, TryBranch, While +from .control import (Break, Continue, Error, For, If, IfBranch, Return, Try, + TryBranch, Var, While) from .fixture import create_fixture from .itemlist import ItemList from .keyword import Keyword diff --git a/src/robot/model/body.py b/src/robot/model/body.py index 29430d54b74..cc342418395 100644 --- a/src/robot/model/body.py +++ b/src/robot/model/body.py @@ -26,7 +26,7 @@ from robot.result.model import ForIteration, WhileIteration from robot.running.model import UserKeyword, ResourceFile from .control import (Break, Continue, Error, For, If, IfBranch, Return, - Try, TryBranch, While) + Try, TryBranch, Var, While) from .keyword import Keyword from .message import Message from .testcase import TestCase @@ -35,13 +35,14 @@ BodyItemParent = Union['TestSuite', 'TestCase', 'UserKeyword', 'For', 'ForIteration', 'If', 'IfBranch', 'Try', 'TryBranch', 'While', 'WhileIteration', - 'Keyword', 'Return', 'Continue', 'Break', 'Error', None] + 'Keyword', 'Var', 'Return', 'Continue', 'Break', 'Error', None] BI = TypeVar('BI', bound='BodyItem') KW = TypeVar('KW', bound='Keyword') F = TypeVar('F', bound='For') W = TypeVar('W', bound='While') I = TypeVar('I', bound='If') T = TypeVar('T', bound='Try') +V = TypeVar('V', bound='Var') R = TypeVar('R', bound='Return') C = TypeVar('C', bound='Continue') B = TypeVar('B', bound='Break') @@ -65,6 +66,7 @@ class BodyItem(ModelObject): EXCEPT = 'EXCEPT' FINALLY = 'FINALLY' WHILE = 'WHILE' + VAR = 'VAR' RETURN = 'RETURN' CONTINUE = 'CONTINUE' BREAK = 'BREAK' @@ -112,7 +114,7 @@ def to_dict(self) -> DataDict: raise NotImplementedError -class BaseBody(ItemList[BodyItem], Generic[KW, F, W, I, T, R, C, B, M, E]): +class BaseBody(ItemList[BodyItem], Generic[KW, F, W, I, T, V, R, C, B, M, E]): """Base class for Body and Branches objects.""" __slots__ = [] # Set using 'BaseBody.register' when these classes are created. @@ -121,6 +123,7 @@ class BaseBody(ItemList[BodyItem], Generic[KW, F, W, I, T, R, C, B, M, E]): while_class: Type[W] = KnownAtRuntime if_class: Type[I] = KnownAtRuntime try_class: Type[T] = KnownAtRuntime + var_class: Type[V] = KnownAtRuntime return_class: Type[R] = KnownAtRuntime continue_class: Type[C] = KnownAtRuntime break_class: Type[B] = KnownAtRuntime @@ -186,6 +189,10 @@ def create_try(self, *args, **kwargs) -> try_class: def create_while(self, *args, **kwargs) -> while_class: return self._create(self.while_class, 'create_while', args, kwargs) + @copy_signature(var_class) + def create_var(self, *args, **kwargs) -> var_class: + return self._create(self.var_class, 'create_var', args, kwargs) + @copy_signature(return_class) def create_return(self, *args, **kwargs) -> return_class: return self._create(self.return_class, 'create_return', args, kwargs) @@ -268,8 +275,8 @@ def flatten(self) -> 'list[BodyItem]': return steps -class Body(BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', - 'Break', 'Message', 'Error']): +class Body(BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Var', 'Return', + 'Continue', 'Break', 'Message', 'Error']): """A list-like object representing a body of a test, keyword, etc. Body contains the keywords and other structures such as FOR loops. @@ -282,7 +289,7 @@ class BranchType(Generic[IT]): pass -class BaseBranches(BaseBody[KW, F, W, I, T, R, C, B, M, E], BranchType[IT]): +class BaseBranches(BaseBody[KW, F, W, I, T, V, R, C, B, M, E], BranchType[IT]): """A list-like object representing IF and TRY branches.""" __slots__ = ['branch_class'] branch_type: Type[IT] = KnownAtRuntime diff --git a/src/robot/model/control.py b/src/robot/model/control.py index bcf8204835d..a8a570b900f 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -358,6 +358,38 @@ def to_dict(self) -> DataDict: 'body': self.body.to_dicts()} +@Body.register +class Var(BodyItem): + """Represents ``VAR``.""" + type = BodyItem.VAR + repr_args = ('name', 'value', 'scope', 'separator') + __slots__ = ['name', 'value', 'scope', 'separator'] + + def __init__(self, name: str = '', + value: 'str|Sequence[str]' = (), + scope: 'str|None' = None, + separator: 'str|None' = None, + parent: BodyItemParent = None): + self.name = name + self.value = (value,) if isinstance(value, str) else tuple(value) + self.scope = scope + self.separator = separator + self.parent = parent + + def visit(self, visitor: SuiteVisitor): + visitor.visit_var(self) + + def to_dict(self) -> DataDict: + data = {'type': self.type, + 'name': self.name, + 'value': self.value} + if self.scope is not None: + data['scope'] = self.scope + if self.separator is not None: + data['separator'] = self.separator + return data + + @Body.register class Return(BodyItem): """Represents ``RETURN``.""" diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 1c781eb61a3..0d416a8b31c 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -1231,7 +1231,7 @@ def validate(self, ctx: 'ValidationContext'): class Var(Statement): type = Token.VAR options = { - 'scope': ('GLOBAL', 'SUITE', 'TEST', 'LOCAL'), + 'scope': ('GLOBAL', 'SUITE', 'TEST', 'TASK', 'LOCAL'), 'separator': None } diff --git a/src/robot/result/__init__.py b/src/robot/result/__init__.py index 2ff680c1ae6..319b2a909f8 100644 --- a/src/robot/result/__init__.py +++ b/src/robot/result/__init__.py @@ -38,7 +38,8 @@ """ from .executionresult import Result -from .model import (Break, Continue, Error, For, ForIteration, If, IfBranch, Keyword, Message, - Return, TestCase, TestSuite, Try, TryBranch, While, WhileIteration) +from .model import (Break, Continue, Error, For, ForIteration, If, IfBranch, Keyword, + Message, Return, TestCase, TestSuite, Try, TryBranch, Var, While, + WhileIteration) from .resultbuilder import ExecutionResult, ExecutionResultBuilder from .visitor import ResultVisitor diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 8eb052e016e..d026b0e2b66 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -42,9 +42,8 @@ from typing import Generic, Literal, Mapping, Sequence, Type, Union, TypeVar from robot import model -from robot.model import (BodyItem, create_fixture, DataDict, Tags, - SuiteVisitor, TotalStatistics, TotalStatisticsBuilder, - TestSuites) +from robot.model import (BodyItem, create_fixture, DataDict, SuiteVisitor, Tags, + TestSuites, TotalStatistics, TotalStatisticsBuilder) from robot.utils import copy_signature, KnownAtRuntime, setter from .configurer import SuiteConfigurer @@ -53,19 +52,19 @@ from .keywordremover import KeywordRemover from .suiteteardownfailed import SuiteTeardownFailed, SuiteTeardownFailureHandler + IT = TypeVar('IT', bound='IfBranch|TryBranch') FW = TypeVar('FW', bound='ForIteration|WhileIteration') - -BodyItemParent = Union['TestSuite', 'TestCase', 'For', 'ForIteration', 'If', 'IfBranch', - 'Try', 'TryBranch', 'While', 'WhileIteration', None] +BodyItemParent = Union['TestSuite', 'TestCase', 'Keyword', 'For', 'ForIteration', 'If', + 'IfBranch', 'Try', 'TryBranch', 'While', 'WhileIteration', None] -class Body(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', - 'Break', 'Message', 'Error']): +class Body(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Var', 'Return', + 'Continue', 'Break', 'Message', 'Error']): __slots__ = [] -class Branches(model.BaseBranches['Keyword', 'For', 'While', 'If', 'Try', 'Return', +class Branches(model.BaseBranches['Keyword', 'For', 'While', 'If', 'Try', 'Var', 'Return', 'Continue', 'Break', 'Message', 'Error', IT]): __slots__ = [] @@ -178,6 +177,10 @@ def _elapsed_time_from_children(self) -> timedelta: for child in self.body: if hasattr(child, 'elapsed_time'): elapsed += child.elapsed_time + if getattr(self, 'has_setup', False): + elapsed += self.setup.elapsed_time + if getattr(self, 'has_teardown', False): + elapsed += self.teardown.elapsed_time return elapsed @elapsed_time.setter @@ -535,6 +538,40 @@ def __init__(self, status: str = 'FAIL', self.elapsed_time = elapsed_time +@Body.register +class Var(model.Var, StatusMixin, DeprecatedAttributesMixin): + __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] + body_class = Body + + def __init__(self, name: str = '', + value: 'str|Sequence[str]' = (), + scope: 'str|None' = None, + separator: 'str|None' = None, + status: str = 'FAIL', + message: str = '', + start_time: 'datetime|str|None' = None, + end_time: 'datetime|str|None' = None, + elapsed_time: 'timedelta|int|float|None' = None, + parent: BodyItemParent = None): + super().__init__(name, value, scope, separator, parent) + self.status = status + self.message = message + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time + self.body = () + + @setter + def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: + """Child keywords and messages as a :class:`~.Body` object. + + Typically empty. Only contains something if running VAR has failed + due to a syntax error or listeners have logged messages or executed + keywords. + """ + return self.body_class(self, body) + + @Body.register class Return(model.Return, StatusMixin, DeprecatedAttributesMixin): __slots__ = ['status', 'message', '_start_time', '_end_time', '_elapsed_time'] @@ -700,14 +737,6 @@ def __init__(self, name: 'str|None' = '', self._teardown = None self.body = () - def _elapsed_time_from_children(self) -> timedelta: - elapsed = super()._elapsed_time_from_children() - if self.has_setup: - elapsed += self.setup.elapsed_time - if self.has_teardown: - elapsed += self.teardown.elapsed_time - return elapsed - @setter def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """Possible keyword body as a :class:`~.Body` object. @@ -870,23 +899,12 @@ def __init__(self, name: str = '', elapsed_time: 'timedelta|int|float|None' = None, parent: 'TestSuite|None' = None): super().__init__(name, doc, tags, timeout, lineno, parent) - #: Status as a string ``PASS`` or ``FAIL``. See also :attr:`passed`. self.status = status - #: Test message. Typically a failure message but can be set also when - #: test passes. self.message = message self.start_time = start_time self.end_time = end_time self.elapsed_time = elapsed_time - def _elapsed_time_from_children(self) -> timedelta: - elapsed = super()._elapsed_time_from_children() - if self.has_setup: - elapsed += self.setup.elapsed_time - if self.has_teardown: - elapsed += self.teardown.elapsed_time - return elapsed - @property def not_run(self) -> bool: return False diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index 34594d3152f..bf5ed4c084d 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -111,7 +111,7 @@ from .builder import ResourceFileBuilder, TestDefaults, TestSuiteBuilder from .context import EXECUTION_CONTEXTS from .model import (Break, Continue, Error, For, If, IfBranch, Keyword, Return, - ResourceFile, TestCase, TestSuite, Try, TryBranch, While) + ResourceFile, TestCase, TestSuite, Try, TryBranch, Var, While) from .runkwregister import RUN_KW_REGISTER from .testlibraries import TestLibrary from .usererrorhandler import UserErrorHandler diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 0c8f272830c..b8b323cecdb 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -257,6 +257,10 @@ def visit_KeywordCall(self, node): self.test.body.create_keyword(name=node.keyword, args=node.args, assign=node.assign, lineno=node.lineno) + def visit_Var(self, node): + self.test.body.create_var(node.name, node.value, node.scope, node.separator, + lineno=node.lineno, error=format_error(node.errors)) + def visit_ReturnStatement(self, node): self.test.body.create_return(node.values, lineno=node.lineno, error=format_error(node.errors)) @@ -324,6 +328,10 @@ def visit_KeywordCall(self, node): self.kw.body.create_keyword(name=node.keyword, args=node.args, assign=node.assign, lineno=node.lineno) + def visit_Var(self, node): + self.kw.body.create_var(node.name, node.value, node.scope, node.separator, + lineno=node.lineno, error=format_error(node.errors)) + def visit_ReturnStatement(self, node): self.kw.body.create_return(node.values, lineno=node.lineno, error=format_error(node.errors)) @@ -382,6 +390,10 @@ def visit_KeywordCall(self, node): def visit_TemplateArguments(self, node): self.model.body.create_keyword(args=node.args, lineno=node.lineno) + def visit_Var(self, node): + self.model.body.create_var(node.name, node.value, node.scope, node.separator, + lineno=node.lineno, error=format_error(node.errors)) + def visit_For(self, node): ForBuilder(self.model).build(node) @@ -458,6 +470,10 @@ def visit_KeywordCall(self, node): def visit_TemplateArguments(self, node): self.model.body.create_keyword(args=node.args, lineno=node.lineno) + def visit_Var(self, node): + self.model.body.create_var(node.name, node.value, node.scope, node.separator, + lineno=node.lineno, error=format_error(node.errors)) + def visit_For(self, node): ForBuilder(self.model).build(node) @@ -546,6 +562,10 @@ def visit_KeywordCall(self, node): self.model.body.create_keyword(name=node.keyword, args=node.args, assign=node.assign, lineno=node.lineno) + def visit_Var(self, node): + self.model.body.create_var(node.name, node.value, node.scope, node.separator, + lineno=node.lineno, error=format_error(node.errors)) + def visit_TemplateArguments(self, node): self.template_error = 'Templates cannot be used with TRY.' @@ -580,6 +600,10 @@ def visit_KeywordCall(self, node): self.model.body.create_keyword(name=node.keyword, args=node.args, assign=node.assign, lineno=node.lineno) + def visit_Var(self, node): + self.model.body.create_var(node.name, node.value, node.scope, node.separator, + lineno=node.lineno, error=format_error(node.errors)) + def visit_TemplateArguments(self, node): self.model.body.create_keyword(args=node.args, lineno=node.lineno) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index e35c6393ed5..2a32fed5d39 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -41,11 +41,12 @@ from robot import model from robot.conf import RobotSettings from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword -from robot.model import (BodyItem, create_fixture, DataDict, ModelObject, TestSuites) +from robot.model import BodyItem, create_fixture, DataDict, ModelObject, TestSuites from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, - Error as ErrorResult, Return as ReturnResult) + Error as ErrorResult, Return as ReturnResult, Var as VarResult) from robot.utils import setter +from robot.variables import VariableResolver from .bodyrunner import ForRunner, IfRunner, KeywordRunner, TryRunner, WhileRunner from .randomizer import Randomizer @@ -60,8 +61,8 @@ 'Try', 'TryBranch', 'While', None] -class Body(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', - 'Break', 'model.Message', 'Error']): +class Body(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Var', 'Return', + 'Continue', 'Break', 'model.Message', 'Error']): __slots__ = [] @@ -266,6 +267,59 @@ def to_dict(self) -> DataDict: return data +@Body.register +class Var(model.Var, WithSource): + __slots__ = ['lineno', 'error'] + + def __init__(self, name: str = '', + value: 'str|Sequence[str]' = (), + scope: 'str|None' = None, + separator: 'str|None' = None, + parent: BodyItemParent = None, + lineno: 'int|None' = None, + error: 'str|None' = None): + super().__init__(name, value, scope, separator, parent) + self.lineno = lineno + self.error = error + + def run(self, context, run=True, templated=False): + result = VarResult(self.name, self.value, self.scope, self.separator) + with StatusReporter(self, result, context, run): + if run: + if self.error: + raise DataError(self.error, syntax=True) + if not context.dry_run: + scope = self._get_scope(context.variables) + setter = getattr(context.variables, f'set_{scope}') + resolver = VariableResolver.from_variable(self) + try: + setter(self.name, resolver.resolve(context.variables)) + except DataError as err: + raise DataError(f"Setting variable '{self.name} failed: {err}") + + def _get_scope(self, variables): + if not self.scope: + return 'local' + try: + scope = variables.replace_string(self.scope) + if scope.upper() == 'TASK': + return 'test' + if scope.upper() in ('GLOBAL', 'SUITE', 'TEST', 'LOCAL'): + return scope.lower() + raise DataError(f"Value '{scope}' is not accepted. Valid values are " + f"'GLOBAL', 'SUITE', 'TEST', 'TASK' and 'LOCAL'.") + except DataError as err: + raise DataError(f"Invalid VAR scope: {err}") + + def to_dict(self) -> DataDict: + data = super().to_dict() + if self.lineno: + data['lineno'] = self.lineno + if self.error: + data['error'] = self.error + return data + + @Body.register class Return(model.Return, WithSource): __slots__ = ['lineno', 'error'] diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index 473674e10b4..c4d62761b93 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -40,7 +40,8 @@ def __enter__(self): self.initial_test_status = context.test.status if context.test else None if not result.start_time: result.start_time = datetime.now() - context.start_body_item(self.data, result) + if result.type != result.VAR: + context.start_body_item(self.data, result) if result.type in result.KEYWORD_TYPES: self._warn_if_deprecated(result.doc, result.full_name) return self @@ -63,7 +64,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self.initial_test_status == 'PASS': context.test.status = result.status result.elapsed_time = datetime.now() - result.start_time - context.end_body_item(self.data, result) + if result.type != result.VAR: + context.end_body_item(self.data, result) if failure is not exc_val and not self.suppress: raise failure return self.suppress diff --git a/src/robot/variables/scopes.py b/src/robot/variables/scopes.py index b57ec079785..04fab0ee6a8 100644 --- a/src/robot/variables/scopes.py +++ b/src/robot/variables/scopes.py @@ -154,7 +154,7 @@ def set_keyword(self, name, value): self.current[name] = value self._variables_set.set_keyword(name, value) - def set_local_variable(self, name, value): + def set_local(self, name, value): self.current[name] = value def as_dict(self, decoration=True): diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index 6ecb155d627..fc1d13820a7 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -23,7 +23,7 @@ from .search import is_assign, is_list_variable, is_dict_variable if TYPE_CHECKING: - from robot.running.model import Variable + from robot.running.model import Var, Variable class VariableTableSetter: @@ -67,11 +67,11 @@ def from_name_and_value(cls, name: str, value: 'str|Sequence[str]', return klass(value, error_reporter) @classmethod - def from_variable(cls, var: 'Variable') -> 'VariableResolver': + def from_variable(cls, var: 'Var|Variable') -> 'VariableResolver': if var.error: raise DataError(var.error) return cls.from_name_and_value(var.name, var.value, var.separator, - var.report_error) + getattr(var, 'report_error', None)) def resolve(self, variables): with self._avoid_recursion: diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index c51eb6cffbf..2ecae47a408 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1099,8 +1099,8 @@ def test_invalid(self): Token(Token.VARIABLE, '${x}', 8, 11), Token(Token.ARGUMENT, 'ok', 8, 20), Token(Token.OPTION, 'scope=bad', 8, 27)], - ["VAR option 'scope' does not accept value 'bad'. " - "Valid values are 'GLOBAL', 'SUITE', 'TEST' and 'LOCAL'."]), + ["VAR option 'scope' does not accept value 'bad'. Valid values " + "are 'GLOBAL', 'SUITE', 'TEST', 'TASK' and 'LOCAL'."]), ] ) get_and_assert_model(data, expected, depth=1) diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index c5aace0f1c1..5f9a2f2cd92 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -13,7 +13,7 @@ from robot.parsing import get_resource_model from robot.running import (Break, Continue, Error, For, If, IfBranch, Keyword, Return, ResourceFile, TestCase, TestDefaults, TestSuite, - Try, TryBranch, While) + Try, TryBranch, Var, While) from robot.running.model import UserKeyword from robot.utils.asserts import assert_equal, assert_false, assert_not_equal @@ -372,6 +372,12 @@ def test_return_continue_break(self): self._verify(Break(lineno=11, error='E'), type='BREAK', lineno=11, error='E') + def test_var(self): + self._verify(Var(), type='VAR', name='', value=()) + self._verify(Var('${x}', 'y', 'TEST', '-', lineno=1, error='err'), + type='VAR', name='${x}', value=('y',), scope='TEST', separator='-', + lineno=1, error='err') + def test_error(self): self._verify(Error(), type='ERROR', values=(), error='') self._verify(Error(('bad', 'things'), error='Bad things!'), @@ -387,13 +393,15 @@ def test_test_structure(self): test = TestCase('TC') test.setup.config(name='Setup') test.teardown.config(name='Teardown', args='a') - test.body.create_keyword('K1', 'a') + test.body.create_var('${x}', 'a') + test.body.create_keyword('K1', ['${x}']) test.body.create_if().body.create_branch('IF', '$c').body.create_keyword('K2') self._verify(test, name='TC', setup={'name': 'Setup'}, teardown={'name': 'Teardown', 'args': ('a',)}, - body=[{'name': 'K1', 'args': ('a',)}, + body=[{'type': 'VAR', 'name': '${x}', 'value': ('a',)}, + {'name': 'K1', 'args': ('${x}',)}, {'type': 'IF/ELSE ROOT', 'body': [{'type': 'IF', 'condition': '$c', 'body': [{'name': 'K2'}]}]}]) @@ -499,7 +507,7 @@ def _create_suite_structure(self, obj): suite = obj elif isinstance(obj, TestCase): suite.tests = [obj] - elif isinstance(obj, (Keyword, For, While, If, Try, Error)): + elif isinstance(obj, (Keyword, For, While, If, Try, Var, Error)): test.body.append(obj) elif isinstance(obj, (IfBranch, TryBranch)): item = If() if isinstance(obj, IfBranch) else Try() From 8df4ad15244e31f04b6f7fd8e498420332e83faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 13 Oct 2023 00:07:14 +0300 Subject: [PATCH 0771/1592] whitespace --- .../lineno_and_source.robot | 274 +++++++++--------- 1 file changed, 137 insertions(+), 137 deletions(-) diff --git a/atest/robot/output/listener_interface/lineno_and_source.robot b/atest/robot/output/listener_interface/lineno_and_source.robot index ec4b4fbbb42..2bf1152636d 100644 --- a/atest/robot/output/listener_interface/lineno_and_source.robot +++ b/atest/robot/output/listener_interface/lineno_and_source.robot @@ -12,254 +12,254 @@ ${RESOURCE FILE} ${LISTENER DIR}/lineno_and_source.resource *** Test Cases *** Keyword START KEYWORD No Operation 6 NOT SET - \END KEYWORD No Operation 6 PASS + \END KEYWORD No Operation 6 PASS User keyword START KEYWORD User Keyword 9 NOT SET START KEYWORD No Operation 101 NOT SET - \END KEYWORD No Operation 101 PASS + \END KEYWORD No Operation 101 PASS START RETURN ${EMPTY} 102 NOT SET - \END RETURN ${EMPTY} 102 PASS - \END KEYWORD User Keyword 9 PASS + \END RETURN ${EMPTY} 102 PASS + \END KEYWORD User Keyword 9 PASS User keyword in resource START KEYWORD User Keyword In Resource 12 NOT SET START KEYWORD No Operation 3 NOT SET source=${RESOURCE FILE} - \END KEYWORD No Operation 3 PASS source=${RESOURCE FILE} - \END KEYWORD User Keyword In Resource 12 PASS + \END KEYWORD No Operation 3 PASS source=${RESOURCE FILE} + \END KEYWORD User Keyword In Resource 12 PASS Not run keyword START KEYWORD Fail 16 NOT SET - \END KEYWORD Fail 16 FAIL + \END KEYWORD Fail 16 FAIL START KEYWORD Fail 17 NOT RUN - \END KEYWORD Fail 17 NOT RUN + \END KEYWORD Fail 17 NOT RUN START KEYWORD Non-existing 18 NOT RUN - \END KEYWORD Non-existing 18 NOT RUN + \END KEYWORD Non-existing 18 NOT RUN FOR START FOR \${x} IN [ first | second ] 21 NOT SET START ITERATION \${x} = first 21 NOT SET START KEYWORD No Operation 22 NOT SET - \END KEYWORD No Operation 22 PASS - \END ITERATION \${x} = first 21 PASS + \END KEYWORD No Operation 22 PASS + \END ITERATION \${x} = first 21 PASS START ITERATION \${x} = second 21 NOT SET START KEYWORD No Operation 22 NOT SET - \END KEYWORD No Operation 22 PASS - \END ITERATION \${x} = second 21 PASS - \END FOR \${x} IN [ first | second ] 21 PASS + \END KEYWORD No Operation 22 PASS + \END ITERATION \${x} = second 21 PASS + \END FOR \${x} IN [ first | second ] 21 PASS FOR in keyword START KEYWORD FOR In Keyword 26 NOT SET START FOR \${x} IN [ once ] 105 NOT SET START ITERATION \${x} = once 105 NOT SET START KEYWORD No Operation 106 NOT SET - \END KEYWORD No Operation 106 PASS - \END ITERATION \${x} = once 105 PASS - \END FOR \${x} IN [ once ] 105 PASS - \END KEYWORD FOR In Keyword 26 PASS + \END KEYWORD No Operation 106 PASS + \END ITERATION \${x} = once 105 PASS + \END FOR \${x} IN [ once ] 105 PASS + \END KEYWORD FOR In Keyword 26 PASS FOR in IF - START IF True 29 NOT SET + START IF True 29 NOT SET START FOR \${x} | \${y} IN [ x | y ] 30 NOT SET START ITERATION \${x} = x, \${y} = y 30 NOT SET START KEYWORD No Operation 31 NOT SET - \END KEYWORD No Operation 31 PASS - \END ITERATION \${x} = x, \${y} = y 30 PASS - \END FOR \${x} | \${y} IN [ x | y ] 30 PASS - \END IF True 29 PASS + \END KEYWORD No Operation 31 PASS + \END ITERATION \${x} = x, \${y} = y 30 PASS + \END FOR \${x} | \${y} IN [ x | y ] 30 PASS + \END IF True 29 PASS FOR in resource START KEYWORD FOR In Resource 36 NOT SET START FOR \${x} IN [ once ] 6 NOT SET source=${RESOURCE FILE} START ITERATION \${x} = once 6 NOT SET source=${RESOURCE FILE} START KEYWORD Log 7 NOT SET source=${RESOURCE FILE} - \END KEYWORD Log 7 PASS source=${RESOURCE FILE} - \END ITERATION \${x} = once 6 PASS source=${RESOURCE FILE} - \END FOR \${x} IN [ once ] 6 PASS source=${RESOURCE FILE} - \END KEYWORD FOR In Resource 36 PASS + \END KEYWORD Log 7 PASS source=${RESOURCE FILE} + \END ITERATION \${x} = once 6 PASS source=${RESOURCE FILE} + \END FOR \${x} IN [ once ] 6 PASS source=${RESOURCE FILE} + \END KEYWORD FOR In Resource 36 PASS IF - START IF 1 > 2 39 NOT RUN - START KEYWORD Fail 40 NOT RUN - \END KEYWORD Fail 40 NOT RUN - \END IF 1 > 2 39 NOT RUN - START ELSE IF 1 < 2 41 NOT SET - START KEYWORD No Operation 42 NOT SET - \END KEYWORD No Operation 42 PASS + START IF 1 > 2 39 NOT RUN + START KEYWORD Fail 40 NOT RUN + \END KEYWORD Fail 40 NOT RUN + \END IF 1 > 2 39 NOT RUN + START ELSE IF 1 < 2 41 NOT SET + START KEYWORD No Operation 42 NOT SET + \END KEYWORD No Operation 42 PASS \END ELSE IF 1 < 2 41 PASS - START ELSE \ 43 NOT RUN - START KEYWORD Fail 44 NOT RUN - \END KEYWORD Fail 44 NOT RUN + START ELSE \ 43 NOT RUN + START KEYWORD Fail 44 NOT RUN + \END KEYWORD Fail 44 NOT RUN \END ELSE \ 43 NOT RUN IF in keyword - START KEYWORD IF In Keyword 48 NOT SET - START IF True 110 NOT SET - START KEYWORD No Operation 111 NOT SET - \END KEYWORD No Operation 111 PASS - START RETURN ${EMPTY} 112 NOT SET - \END RETURN ${EMPTY} 112 PASS - \END IF True 110 PASS - \END KEYWORD IF In Keyword 48 PASS + START KEYWORD IF In Keyword 48 NOT SET + START IF True 110 NOT SET + START KEYWORD No Operation 111 NOT SET + \END KEYWORD No Operation 111 PASS + START RETURN ${EMPTY} 112 NOT SET + \END RETURN ${EMPTY} 112 PASS + \END IF True 110 PASS + \END KEYWORD IF In Keyword 48 PASS IF in FOR - START FOR \${x} IN [ 1 | 2 ] 52 NOT SET - START ITERATION \${x} = 1 52 NOT SET - START IF \${x} == 1 53 NOT SET - START KEYWORD Log 54 NOT SET - \END KEYWORD Log 54 PASS + START FOR \${x} IN [ 1 | 2 ] 52 NOT SET + START ITERATION \${x} = 1 52 NOT SET + START IF \${x} == 1 53 NOT SET + START KEYWORD Log 54 NOT SET + \END KEYWORD Log 54 PASS \END IF \${x} == 1 53 PASS - START ELSE \ 55 NOT RUN - START KEYWORD Fail 56 NOT RUN - \END KEYWORD Fail 56 NOT RUN - \END ELSE \ 55 NOT RUN - \END ITERATION \${x} = 1 52 PASS - START ITERATION \${x} = 2 52 NOT SET - START IF \${x} == 1 53 NOT RUN - START KEYWORD Log 54 NOT RUN - \END KEYWORD Log 54 NOT RUN + START ELSE \ 55 NOT RUN + START KEYWORD Fail 56 NOT RUN + \END KEYWORD Fail 56 NOT RUN + \END ELSE \ 55 NOT RUN + \END ITERATION \${x} = 1 52 PASS + START ITERATION \${x} = 2 52 NOT SET + START IF \${x} == 1 53 NOT RUN + START KEYWORD Log 54 NOT RUN + \END KEYWORD Log 54 NOT RUN \END IF \${x} == 1 53 NOT RUN - START ELSE \ 55 NOT SET - START KEYWORD Fail 56 NOT SET - \END KEYWORD Fail 56 FAIL - \END ELSE \ 55 FAIL - \END ITERATION \${x} = 2 52 FAIL - \END FOR \${x} IN [ 1 | 2 ] 52 FAIL + START ELSE \ 55 NOT SET + START KEYWORD Fail 56 NOT SET + \END KEYWORD Fail 56 FAIL + \END ELSE \ 55 FAIL + \END ITERATION \${x} = 2 52 FAIL + \END FOR \${x} IN [ 1 | 2 ] 52 FAIL IF in resource - START KEYWORD IF In Resource 61 NOT SET - START IF True 11 NOT SET source=${RESOURCE FILE} - START KEYWORD No Operation 12 NOT SET source=${RESOURCE FILE} - \END KEYWORD No Operation 12 PASS source=${RESOURCE FILE} - \END IF True 11 PASS source=${RESOURCE FILE} - \END KEYWORD IF In Resource 61 PASS + START KEYWORD IF In Resource 61 NOT SET + START IF True 11 NOT SET source=${RESOURCE FILE} + START KEYWORD No Operation 12 NOT SET source=${RESOURCE FILE} + \END KEYWORD No Operation 12 PASS source=${RESOURCE FILE} + \END IF True 11 PASS source=${RESOURCE FILE} + \END KEYWORD IF In Resource 61 PASS TRY START TRY ${EMPTY} 65 NOT SET START KEYWORD Fail 66 NOT SET - \END KEYWORD Fail 66 FAIL - \END TRY ${EMPTY} 65 FAIL + \END KEYWORD Fail 66 FAIL + \END TRY ${EMPTY} 65 FAIL START EXCEPT AS \${name} 67 NOT SET START TRY ${EMPTY} 68 NOT SET START KEYWORD Fail 69 NOT SET - \END KEYWORD Fail 69 FAIL - \END TRY ${EMPTY} 68 FAIL + \END KEYWORD Fail 69 FAIL + \END TRY ${EMPTY} 68 FAIL START FINALLY ${EMPTY} 70 NOT SET START KEYWORD Should Be Equal 71 NOT SET - \END KEYWORD Should Be Equal 71 PASS - \END FINALLY ${EMPTY} 70 PASS - \END EXCEPT AS \${name} 67 FAIL + \END KEYWORD Should Be Equal 71 PASS + \END FINALLY ${EMPTY} 70 PASS + \END EXCEPT AS \${name} 67 FAIL START ELSE ${EMPTY} 73 NOT RUN START KEYWORD Fail 74 NOT RUN - \END KEYWORD Fail 74 NOT RUN - \END ELSE ${EMPTY} 73 NOT RUN + \END KEYWORD Fail 74 NOT RUN + \END ELSE ${EMPTY} 73 NOT RUN TRY in keyword START KEYWORD TRY In Keyword 78 NOT SET START TRY ${EMPTY} 116 NOT SET START RETURN ${EMPTY} 117 NOT SET - \END RETURN ${EMPTY} 117 PASS + \END RETURN ${EMPTY} 117 PASS START KEYWORD Fail 118 NOT RUN - \END KEYWORD Fail 118 NOT RUN - \END TRY ${EMPTY} 116 PASS + \END KEYWORD Fail 118 NOT RUN + \END TRY ${EMPTY} 116 PASS START EXCEPT No match AS \${var} 119 NOT RUN START KEYWORD Fail 120 NOT RUN - \END KEYWORD Fail 120 NOT RUN - \END EXCEPT No match AS \${var} 119 NOT RUN + \END KEYWORD Fail 120 NOT RUN + \END EXCEPT No match AS \${var} 119 NOT RUN START EXCEPT No | Match | 2 AS \${x} 121 NOT RUN START KEYWORD Fail 122 NOT RUN - \END KEYWORD Fail 122 NOT RUN - \END EXCEPT No | Match | 2 AS \${x} 121 NOT RUN + \END KEYWORD Fail 122 NOT RUN + \END EXCEPT No | Match | 2 AS \${x} 121 NOT RUN START EXCEPT ${EMPTY} 123 NOT RUN START KEYWORD Fail 124 NOT RUN - \END KEYWORD Fail 124 NOT RUN - \END EXCEPT ${EMPTY} 123 NOT RUN - \END KEYWORD TRY In Keyword 78 PASS + \END KEYWORD Fail 124 NOT RUN + \END EXCEPT ${EMPTY} 123 NOT RUN + \END KEYWORD TRY In Keyword 78 PASS TRY in resource START KEYWORD TRY In Resource 81 NOT SET START TRY ${EMPTY} 16 NOT SET source=${RESOURCE FILE} START KEYWORD Log 17 NOT SET source=${RESOURCE FILE} - \END KEYWORD Log 17 PASS source=${RESOURCE FILE} - \END TRY ${EMPTY} 16 PASS source=${RESOURCE FILE} + \END KEYWORD Log 17 PASS source=${RESOURCE FILE} + \END TRY ${EMPTY} 16 PASS source=${RESOURCE FILE} START FINALLY ${EMPTY} 18 NOT SET source=${RESOURCE FILE} START KEYWORD Log 19 NOT SET source=${RESOURCE FILE} - \END KEYWORD Log 19 PASS source=${RESOURCE FILE} - \END FINALLY ${EMPTY} 18 PASS source=${RESOURCE FILE} - \END KEYWORD TRY In Resource 81 PASS + \END KEYWORD Log 19 PASS source=${RESOURCE FILE} + \END FINALLY ${EMPTY} 18 PASS source=${RESOURCE FILE} + \END KEYWORD TRY In Resource 81 PASS Run Keyword START KEYWORD Run Keyword 84 NOT SET START KEYWORD Log 84 NOT SET - \END KEYWORD Log 84 PASS - \END KEYWORD Run Keyword 84 PASS + \END KEYWORD Log 84 PASS + \END KEYWORD Run Keyword 84 PASS START KEYWORD Run Keyword If 85 NOT SET START KEYWORD User Keyword 85 NOT SET START KEYWORD No Operation 101 NOT SET - \END KEYWORD No Operation 101 PASS + \END KEYWORD No Operation 101 PASS START RETURN ${EMPTY} 102 NOT SET - \END RETURN ${EMPTY} 102 PASS - \END KEYWORD User Keyword 85 PASS - \END KEYWORD Run Keyword If 85 PASS + \END RETURN ${EMPTY} 102 PASS + \END KEYWORD User Keyword 85 PASS + \END KEYWORD Run Keyword If 85 PASS Run Keyword in keyword START KEYWORD Run Keyword in keyword 89 NOT SET START KEYWORD Run Keyword 128 NOT SET START KEYWORD No Operation 128 NOT SET - \END KEYWORD No Operation 128 PASS - \END KEYWORD Run Keyword 128 PASS - \END KEYWORD Run Keyword in keyword 89 PASS + \END KEYWORD No Operation 128 PASS + \END KEYWORD Run Keyword 128 PASS + \END KEYWORD Run Keyword in keyword 89 PASS Run Keyword in resource START KEYWORD Run Keyword in resource 92 NOT SET START KEYWORD Run Keyword 23 NOT SET source=${RESOURCE FILE} START KEYWORD Log 23 NOT SET source=${RESOURCE FILE} - \END KEYWORD Log 23 PASS source=${RESOURCE FILE} - \END KEYWORD Run Keyword 23 PASS source=${RESOURCE FILE} - \END KEYWORD Run Keyword in resource 92 PASS + \END KEYWORD Log 23 PASS source=${RESOURCE FILE} + \END KEYWORD Run Keyword 23 PASS source=${RESOURCE FILE} + \END KEYWORD Run Keyword in resource 92 PASS In setup and teardown START SETUP User Keyword 95 NOT SET START KEYWORD No Operation 101 NOT SET - \END KEYWORD No Operation 101 PASS + \END KEYWORD No Operation 101 PASS START RETURN ${EMPTY} 102 NOT SET - \END RETURN ${EMPTY} 102 PASS - \END SETUP User Keyword 95 PASS + \END RETURN ${EMPTY} 102 PASS + \END SETUP User Keyword 95 PASS START KEYWORD No Operation 96 NOT SET - \END KEYWORD No Operation 96 PASS + \END KEYWORD No Operation 96 PASS START TEARDOWN Run Keyword 97 NOT SET START KEYWORD Log 97 NOT SET - \END KEYWORD Log 97 PASS - \END TEARDOWN Run Keyword 97 PASS + \END KEYWORD Log 97 PASS + \END TEARDOWN Run Keyword 97 PASS + +Suite + START SUITE Lineno And Source + \END SUITE Lineno And Source status=FAIL + [Teardown] Validate suite Test [Template] Expect test - Keyword 5 - User keyword 8 - User keyword in resource 11 - Not run keyword 14 FAIL - \FOR 20 - FOR in keyword 25 - FOR in IF 28 - FOR in resource 35 - \IF 38 - IF in keyword 47 - IF in FOR 50 FAIL - IF in resource 60 - \TRY 63 FAIL - TRY in keyword 77 - TRY in resource 80 - Run Keyword 83 - Run Keyword in keyword 88 - Run Keyword in resource 91 - In setup and teardown 94 + Keyword 5 + User keyword 8 + User keyword in resource 11 + Not run keyword 14 FAIL + \FOR 20 + FOR in keyword 25 + FOR in IF 28 + FOR in resource 35 + \IF 38 + IF in keyword 47 + IF in FOR 50 FAIL + IF in resource 60 + \TRY 63 FAIL + TRY in keyword 77 + TRY in resource 80 + Run Keyword 83 + Run Keyword in keyword 88 + Run Keyword in resource 91 + In setup and teardown 94 [Teardown] Validate tests -Suite - START SUITE Lineno And Source - \END SUITE Lineno And Source status=FAIL - [Teardown] Validate suite - *** Keywords *** Expect [Arguments] ${event} ${type} ${name} ${lineno}=-1 ${status}= ${source}=${TEST CASE FILE} From 4dc5a9892f42c4849fbbb3a0ca63fe3ded8d8007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 13 Oct 2023 10:54:22 +0300 Subject: [PATCH 0772/1592] Make keywords and controls in log look more like data In practice ` | ` and other special markers have been replaced with the normal four space separator. Fixes #4900. Also make sure that all control structures have `str()`. That includes the new `Var` object (#3761). Includes also some cleanup to related code. Most importantly, use terms "body" and "item" instead of "keywords" and "keyword" when talking about body items. --- .../remove_keywords/for_loop_keywords.robot | 14 +- .../lineno_and_source.robot | 24 +-- atest/robot/variables/return_values.robot | 12 +- atest/testdata/misc/for_loops.robot | 3 +- atest/testdata/misc/try_except.robot | 2 +- atest/testdata/misc/while.robot | 2 +- .../listener_interface/LinenoAndSource.py | 2 +- doc/schema/running.json | 3 - doc/schema/running_json_schema.py | 2 +- src/robot/htmldata/rebot/log.css | 4 + src/robot/htmldata/rebot/log.html | 3 +- src/robot/htmldata/rebot/model.js | 3 +- src/robot/model/control.py | 120 +++++++++------ src/robot/output/debugfile.py | 4 +- src/robot/output/filelogger.py | 6 +- src/robot/output/listenerarguments.py | 5 +- src/robot/reporting/jsmodelbuilders.py | 102 +++++++------ src/robot/result/model.py | 50 ++---- src/robot/result/modeldeprecation.py | 6 +- utest/model/test_control.py | 143 +++++++++++------- utest/reporting/test_jsmodelbuilders.py | 89 +++++------ utest/result/test_resultmodel.py | 53 +++++-- utest/running/test_run_model.py | 6 +- 23 files changed, 364 insertions(+), 294 deletions(-) diff --git a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot index 75e611e9bfe..7e97b8a486d 100644 --- a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot @@ -18,13 +18,13 @@ Passed Steps Are Removed Except The Last One Failed Steps Are Not Removed ${tc}= Check Test Case Failure inside FOR 2 - Length Should Be ${tc.kws[0].kws} 1 - Should Be Equal ${tc.kws[0].message} *HTML* Failure with <4>


${3 REMOVED} - Should Be Equal ${tc.kws[0].kws[0]._name} \${num} = 4 - Should Be Equal ${tc.kws[0].kws[0].type} ITERATION - Should Be Equal ${tc.kws[0].kws[0].status} FAIL - Length Should Be ${tc.kws[0].kws[0].kws} 3 - Should Be Equal ${tc.kws[0].kws[0].kws[-1].status} NOT RUN + Length Should Be ${tc.body[0].body} 1 + Should Be Equal ${tc.body[0].message} *HTML* Failure with <4>
${3 REMOVED} + Should Be Equal ${tc.body[0].body[0].type} ITERATION + Should Be Equal ${tc.body[0].body[0].assign['\${num}']} 4 + Should Be Equal ${tc.body[0].body[0].status} FAIL + Length Should Be ${tc.body[0].body[0].body} 3 + Should Be Equal ${tc.body[0].body[0].body[-1].status} NOT RUN Steps With Warning Are Not Removed ${tc}= Check Test Case Variables in values diff --git a/atest/robot/output/listener_interface/lineno_and_source.robot b/atest/robot/output/listener_interface/lineno_and_source.robot index 2bf1152636d..cc61cbe667d 100644 --- a/atest/robot/output/listener_interface/lineno_and_source.robot +++ b/atest/robot/output/listener_interface/lineno_and_source.robot @@ -37,7 +37,7 @@ Not run keyword \END KEYWORD Non-existing 18 NOT RUN FOR - START FOR \${x} IN [ first | second ] 21 NOT SET + START FOR \${x} IN first second 21 NOT SET START ITERATION \${x} = first 21 NOT SET START KEYWORD No Operation 22 NOT SET \END KEYWORD No Operation 22 PASS @@ -46,36 +46,36 @@ FOR START KEYWORD No Operation 22 NOT SET \END KEYWORD No Operation 22 PASS \END ITERATION \${x} = second 21 PASS - \END FOR \${x} IN [ first | second ] 21 PASS + \END FOR \${x} IN first second 21 PASS FOR in keyword START KEYWORD FOR In Keyword 26 NOT SET - START FOR \${x} IN [ once ] 105 NOT SET + START FOR \${x} IN once 105 NOT SET START ITERATION \${x} = once 105 NOT SET START KEYWORD No Operation 106 NOT SET \END KEYWORD No Operation 106 PASS \END ITERATION \${x} = once 105 PASS - \END FOR \${x} IN [ once ] 105 PASS + \END FOR \${x} IN once 105 PASS \END KEYWORD FOR In Keyword 26 PASS FOR in IF START IF True 29 NOT SET - START FOR \${x} | \${y} IN [ x | y ] 30 NOT SET + START FOR \${x} \${y} IN x y 30 NOT SET START ITERATION \${x} = x, \${y} = y 30 NOT SET START KEYWORD No Operation 31 NOT SET \END KEYWORD No Operation 31 PASS \END ITERATION \${x} = x, \${y} = y 30 PASS - \END FOR \${x} | \${y} IN [ x | y ] 30 PASS + \END FOR \${x} \${y} IN x y 30 PASS \END IF True 29 PASS FOR in resource START KEYWORD FOR In Resource 36 NOT SET - START FOR \${x} IN [ once ] 6 NOT SET source=${RESOURCE FILE} + START FOR \${x} IN once 6 NOT SET source=${RESOURCE FILE} START ITERATION \${x} = once 6 NOT SET source=${RESOURCE FILE} START KEYWORD Log 7 NOT SET source=${RESOURCE FILE} \END KEYWORD Log 7 PASS source=${RESOURCE FILE} \END ITERATION \${x} = once 6 PASS source=${RESOURCE FILE} - \END FOR \${x} IN [ once ] 6 PASS source=${RESOURCE FILE} + \END FOR \${x} IN once 6 PASS source=${RESOURCE FILE} \END KEYWORD FOR In Resource 36 PASS IF @@ -103,7 +103,7 @@ IF in keyword \END KEYWORD IF In Keyword 48 PASS IF in FOR - START FOR \${x} IN [ 1 | 2 ] 52 NOT SET + START FOR \${x} IN 1 2 52 NOT SET START ITERATION \${x} = 1 52 NOT SET START IF \${x} == 1 53 NOT SET START KEYWORD Log 54 NOT SET @@ -124,7 +124,7 @@ IF in FOR \END KEYWORD Fail 56 FAIL \END ELSE \ 55 FAIL \END ITERATION \${x} = 2 52 FAIL - \END FOR \${x} IN [ 1 | 2 ] 52 FAIL + \END FOR \${x} IN 1 2 52 FAIL IF in resource START KEYWORD IF In Resource 61 NOT SET @@ -166,10 +166,10 @@ TRY in keyword START KEYWORD Fail 120 NOT RUN \END KEYWORD Fail 120 NOT RUN \END EXCEPT No match AS \${var} 119 NOT RUN - START EXCEPT No | Match | 2 AS \${x} 121 NOT RUN + START EXCEPT No Match 2 AS \${x} 121 NOT RUN START KEYWORD Fail 122 NOT RUN \END KEYWORD Fail 122 NOT RUN - \END EXCEPT No | Match | 2 AS \${x} 121 NOT RUN + \END EXCEPT No Match 2 AS \${x} 121 NOT RUN START EXCEPT ${EMPTY} 123 NOT RUN START KEYWORD Fail 124 NOT RUN \END KEYWORD Fail 124 NOT RUN diff --git a/atest/robot/variables/return_values.robot b/atest/robot/variables/return_values.robot index 39f67d61583..78606e2a089 100644 --- a/atest/robot/variables/return_values.robot +++ b/atest/robot/variables/return_values.robot @@ -81,12 +81,12 @@ List Variable From Dictionary Unrepresentable objects to list variables ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} \@{unrepr} = ? ${UNREPR} | ${UNREPR} ? pattern=yes - Check Log Message ${tc.kws[0].msgs[0]} \@{unrepr} = ? ${UNREPR} | ${UNREPR} ? pattern=yes - Should Match ${tc.kws[2].kws[0]._name} \${obj} = ${UNREPR} - Check Log Message ${tc.kws[2].kws[0].kws[1].msgs[0]} $\{var} = ${UNREPR} pattern=yes - Should Match ${tc.kws[2].kws[1]._name} \${obj} = ${UNREPR} - Check Log Message ${tc.kws[2].kws[1].kws[1].msgs[0]} $\{var} = ${UNREPR} pattern=yes + Check Log Message ${tc.body[0].msgs[0]} \@{unrepr} = ? ${UNREPR} | ${UNREPR} ? pattern=yes + Check Log Message ${tc.body[0].msgs[0]} \@{unrepr} = ? ${UNREPR} | ${UNREPR} ? pattern=yes + Should Match ${tc.body[2].body[0].assign['\${obj}']} ${UNREPR} + Check Log Message ${tc.body[2].body[0].body[1].msgs[0]} $\{var} = ${UNREPR} pattern=yes + Should Match ${tc.body[2].body[1].assign['\${obj}']} ${UNREPR} + Check Log Message ${tc.body[2].body[1].body[1].msgs[0]} $\{var} = ${UNREPR} pattern=yes None To List Variable ${tc} = Check Test Case ${TEST NAME} diff --git a/atest/testdata/misc/for_loops.robot b/atest/testdata/misc/for_loops.robot index 76bf4139e12..12dbc86b1ca 100644 --- a/atest/testdata/misc/for_loops.robot +++ b/atest/testdata/misc/for_loops.robot @@ -22,7 +22,6 @@ FOR IN ENUMERATE END FOR IN ZIP - FOR ${en} ${fi} IN ZIP ${ANIMALS} ${FINNISH} + FOR ${en} ${fi} IN ZIP ${ANIMALS} ${FINNISH} mode=LONGEST fill=- Log ${en} is ${fi} in Finnish - END diff --git a/atest/testdata/misc/try_except.robot b/atest/testdata/misc/try_except.robot index c93a0262950..136b57aafb1 100644 --- a/atest/testdata/misc/try_except.robot +++ b/atest/testdata/misc/try_except.robot @@ -2,7 +2,7 @@ Everything TRY Keyword - EXCEPT No match + EXCEPT No match type=glob Fail Not executed Fail Not executed either EXCEPT Ooops! AS ${err} diff --git a/atest/testdata/misc/while.robot b/atest/testdata/misc/while.robot index 96f06b7aa93..20d7e0c8f8d 100644 --- a/atest/testdata/misc/while.robot +++ b/atest/testdata/misc/while.robot @@ -12,7 +12,7 @@ WHILE loop in keyword *** Keywords *** WHILE loop executed multiple times ${variable}= Set variable ${1} - WHILE True + WHILE True limit=10 on_limit_message=xxx Log ${variable} ${variable}= Evaluate $variable + 1 IF $variable == 5 CONTINUE diff --git a/atest/testdata/output/listener_interface/LinenoAndSource.py b/atest/testdata/output/listener_interface/LinenoAndSource.py index bacc82738cb..8f93e904c91 100644 --- a/atest/testdata/output/listener_interface/LinenoAndSource.py +++ b/atest/testdata/output/listener_interface/LinenoAndSource.py @@ -44,7 +44,7 @@ def close(self): def report(self, event, type, source, lineno=-1, name=None, kwname=None, status=None, **ignore): - info = [event, type, name or kwname, lineno, source] + info = [event, type, (name or kwname).replace(' ', ' '), lineno, source] if status: info.append(status) self.output.write('\t'.join(str(i) for i in info) + '\n') diff --git a/doc/schema/running.json b/doc/schema/running.json index 5c69ff283ac..e88928fb8a6 100644 --- a/doc/schema/running.json +++ b/doc/schema/running.json @@ -149,9 +149,6 @@ "type": "string" } }, - "required": [ - "values" - ], "additionalProperties": false }, "Error": { diff --git a/doc/schema/running_json_schema.py b/doc/schema/running_json_schema.py index 0076a366026..76980eabe04 100755 --- a/doc/schema/running_json_schema.py +++ b/doc/schema/running_json_schema.py @@ -37,7 +37,7 @@ class Var(BodyItem): class Return(BodyItem): type = Field('RETURN', const=True) - values: Sequence[str] + values: Sequence[str] | None class Continue(BodyItem): diff --git a/src/robot/htmldata/rebot/log.css b/src/robot/htmldata/rebot/log.css index 7dcbf409ee7..11969f1b3a9 100644 --- a/src/robot/htmldata/rebot/log.css +++ b/src/robot/htmldata/rebot/log.css @@ -73,6 +73,10 @@ } .name { font-weight: bold; + white-space: pre-wrap; +} +.arg, .assign { + white-space: pre-wrap; } .elapsed { float: right; diff --git a/src/robot/htmldata/rebot/log.html b/src/robot/htmldata/rebot/log.html index 2d320efe15d..392d613885e 100644 --- a/src/robot/htmldata/rebot/log.html +++ b/src/robot/htmldata/rebot/log.html @@ -322,8 +322,9 @@

{{= testOrTask('{Test}')}} Execution Errors

${times.elapsedTime} ${type} - {{html assign}} + {{html assign}} {{html libname}}{{if libname}} . {{/if}}{{html name}} +   {{html arguments}}
diff --git a/src/robot/htmldata/rebot/model.js b/src/robot/htmldata/rebot/model.js index 9164b29188f..502cb52244d 100644 --- a/src/robot/htmldata/rebot/model.js +++ b/src/robot/htmldata/rebot/model.js @@ -142,13 +142,12 @@ window.model = (function () { function Keyword(data) { var kw = createModelObject(data); - var flatTypes = ['RETURN', 'BREAK', 'CONTINUE']; kw.libname = data.libname; kw.fullName = (kw.libname ? kw.libname + '.' : '') + kw.name; kw.type = data.type; kw.template = 'keywordTemplate'; kw.arguments = data.args; - kw.assign = data.assign + (data.assign ? ' =' : ''); + kw.assign = data.assign + (data.assign ? ' = ' : ''); kw.tags = data.tags; kw.timeout = data.timeout; kw.populateKeywords = createIterablePopulator('Keyword'); diff --git a/src/robot/model/control.py b/src/robot/model/control.py index a8a570b900f..3eba804ccc2 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -77,18 +77,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def visit(self, visitor: SuiteVisitor): visitor.visit_for(self) - def __str__(self): - parts = ['FOR', *self.assign, self.flavor, *self.values] - for name, value in [('start', self.start), - ('mode', self.mode), - ('fill', self.fill)]: - if value is not None: - parts.append(f'{name}={value}') - return ' '.join(parts) - - def _include_in_repr(self, name: str, value: Any) -> bool: - return name not in ('start', 'mode', 'fill') or value is not None - def to_dict(self) -> DataDict: data = {'type': self.type, 'assign': self.assign, @@ -102,6 +90,18 @@ def to_dict(self) -> DataDict: data['body'] = self.body.to_dicts() return data + def __str__(self): + parts = ['FOR', *self.assign, self.flavor, *self.values] + for name, value in [('start', self.start), + ('mode', self.mode), + ('fill', self.fill)]: + if value is not None: + parts.append(f'{name}={value}') + return ' '.join(parts) + + def _include_in_repr(self, name: str, value: Any) -> bool: + return value is not None or name in ('assign', 'flavor', 'values') + @Body.register class While(BodyItem): @@ -130,18 +130,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: def visit(self, visitor: SuiteVisitor): visitor.visit_while(self) - def __str__(self) -> str: - parts = ['WHILE'] - if self.condition is not None: - parts.append(self.condition) - if self.limit is not None: - parts.append(f'limit={self.limit}') - if self.on_limit is not None: - parts.append(f'limit={self.on_limit}') - if self.on_limit_message is not None: - parts.append(f'on_limit_message={self.on_limit_message}') - return ' '.join(parts) - def _include_in_repr(self, name: str, value: Any) -> bool: return name == 'condition' or value is not None @@ -156,6 +144,18 @@ def to_dict(self) -> DataDict: data['body'] = self.body.to_dicts() return data + def __str__(self) -> str: + parts = ['WHILE'] + if self.condition is not None: + parts.append(self.condition) + if self.limit is not None: + parts.append(f'limit={self.limit}') + if self.on_limit is not None: + parts.append(f'on_limit={self.on_limit}') + if self.on_limit_message is not None: + parts.append(f'on_limit_message={self.on_limit_message}') + return ' '.join(parts) + class IfBranch(BodyItem): """Represents individual ``IF``, ``ELSE IF`` or ``ELSE`` branch.""" @@ -184,13 +184,6 @@ def id(self) -> str: return self._get_id(self.parent) return self._get_id(self.parent.parent) - def __str__(self) -> str: - if self.type == self.IF: - return f'IF {self.condition}' - if self.type == self.ELSE_IF: - return f'ELSE IF {self.condition}' - return 'ELSE' - def visit(self, visitor: SuiteVisitor): visitor.visit_if_branch(self) @@ -201,6 +194,13 @@ def to_dict(self) -> DataDict: data['body'] = self.body.to_dicts() return data + def __str__(self) -> str: + if self.type == self.IF: + return f'IF {self.condition}' + if self.type == self.ELSE_IF: + return f'ELSE IF {self.condition}' + return 'ELSE' + @Body.register class If(BodyItem): @@ -277,19 +277,6 @@ def id(self) -> str: return self._get_id(self.parent) return self._get_id(self.parent.parent) - def __str__(self) -> str: - if self.type != BodyItem.EXCEPT: - return self.type - parts = ['EXCEPT', *self.patterns] - if self.pattern_type: - parts.append(f'type={self.pattern_type}') - if self.assign: - parts.extend(['AS', self.assign]) - return ' '.join(parts) - - def _include_in_repr(self, name: str, value: Any) -> bool: - return bool(value) - def visit(self, visitor: SuiteVisitor): visitor.visit_try_branch(self) @@ -304,6 +291,19 @@ def to_dict(self) -> DataDict: data['body'] = self.body.to_dicts() return data + def __str__(self) -> str: + if self.type != BodyItem.EXCEPT: + return self.type + parts = ['EXCEPT', *self.patterns] + if self.pattern_type: + parts.append(f'type={self.pattern_type}') + if self.assign: + parts.extend(['AS', self.assign]) + return ' '.join(parts) + + def _include_in_repr(self, name: str, value: Any) -> bool: + return bool(value) + @Body.register class Try(BodyItem): @@ -389,6 +389,17 @@ def to_dict(self) -> DataDict: data['separator'] = self.separator return data + def __str__(self): + parts = ['VAR', self.name, *self.value] + if self.separator is not None: + parts.append(f'separator={self.separator}') + if self.scope is not None: + parts.append(f'scope={self.scope}') + return ' '.join(parts) + + def _include_in_repr(self, name: str, value: Any) -> bool: + return value is not None or name in ('name', 'value') + @Body.register class Return(BodyItem): @@ -406,8 +417,16 @@ def visit(self, visitor: SuiteVisitor): visitor.visit_return(self) def to_dict(self) -> DataDict: - return {'type': self.type, - 'values': self.values} + data = {'type': self.type} + if self.values: + data['values'] = self.values + return data + + def __str__(self): + return ' '.join(['RETURN', *self.values]) + + def _include_in_repr(self, name: str, value: Any) -> bool: + return bool(value) @Body.register @@ -425,6 +444,9 @@ def visit(self, visitor: SuiteVisitor): def to_dict(self) -> DataDict: return {'type': self.type} + def __str__(self): + return 'CONTINUE' + @Body.register class Break(BodyItem): @@ -441,6 +463,9 @@ def visit(self, visitor: SuiteVisitor): def to_dict(self) -> DataDict: return {'type': self.type} + def __str__(self): + return 'BREAK' + @Body.register class Error(BodyItem): @@ -463,3 +488,6 @@ def visit(self, visitor: SuiteVisitor): def to_dict(self) -> DataDict: return {'type': self.type, 'values': self.values} + + def __str__(self): + return ' '.join(['ERROR', *self.values]) diff --git a/src/robot/output/debugfile.py b/src/robot/output/debugfile.py index 6f79ce515b3..09617e4e58f 100644 --- a/src/robot/output/debugfile.py +++ b/src/robot/output/debugfile.py @@ -81,11 +81,11 @@ def end_keyword(self, data, result): def start_body_item(self, data, result): if self._kw_level == 0: self._separator('KEYWORD') - self._start(result.type, result._name, result.start_time) + self._start(result.type, result._log_name, result.start_time) self._kw_level += 1 def end_body_item(self, data, result): - self._end(result.type, result._name, result.end_time, result.elapsed_time) + self._end(result.type, result._log_name, result.end_time, result.elapsed_time) self._kw_level -= 1 def log_message(self, msg): diff --git a/src/robot/output/filelogger.py b/src/robot/output/filelogger.py index 1b5d6bf1718..4e9907ec5d6 100644 --- a/src/robot/output/filelogger.py +++ b/src/robot/output/filelogger.py @@ -47,10 +47,12 @@ def end_test(self, data, result): self.info("Ended test '%s'." % result.name) def start_body_item(self, data, result): - self.debug(lambda: "Started keyword '%s'." % result.name if result.type in result.KEYWORD_TYPES else result._name) + self.debug(lambda: "Started keyword '%s'." % result.name + if result.type in result.KEYWORD_TYPES else result._log_name) def end_body_item(self, data, result): - self.debug(lambda: "Ended keyword '%s'." % result.name if result.type in result.KEYWORD_TYPES else result._name) + self.debug(lambda: "Ended keyword '%s'." % result.name + if result.type in result.KEYWORD_TYPES else result._log_name) def output_file(self, name, path): self.info('%s: %s' % (name, path)) diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py index 12bb3750217..954dc8824d4 100644 --- a/src/robot/output/listenerarguments.py +++ b/src/robot/output/listenerarguments.py @@ -152,10 +152,9 @@ class StartKeywordArguments(_ListenerArgumentsFromItem): } def _get_name(self, kw): - return kw.full_name if kw.type in kw.KEYWORD_TYPES else kw._name + return kw.full_name if kw.type in kw.KEYWORD_TYPES else kw._log_name def _get_extra_attributes(self, kw): - # FOR and TRY model objects use `assign` starting from RF 7.0, but for # backwards compatibility reasons we pass them as `variable(s)`. if kw.type in kw.KEYWORD_TYPES: assign = list(kw.assign) @@ -164,7 +163,7 @@ def _get_extra_attributes(self, kw): args = [a if is_string(a) else safe_str(a) for a in kw.args] else: assign = [] - name = kw._name + name = kw._log_name owner = '' args = [] attrs = {'kwname': name, diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index 4c8c518a428..07e75f461e3 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -14,7 +14,7 @@ # limitations under the License. from robot.output import LEVELS -from robot.result import Error, Keyword, Return +from robot.result import Error, Keyword, Message, Return from .jsbuildingcontext import JsBuildingContext from .jsexecutionresult import JsExecutionResult @@ -47,7 +47,7 @@ def build_from(self, result_from_xml): ) -class _Builder: +class Builder: def __init__(self, context: JsBuildingContext): self._context = context @@ -68,28 +68,32 @@ def _get_status(self, item): msg = self._string(msg) return model + (msg,) - def _build_body(self, steps, split=False): + def _build_body(self, body, split=False): splitting = self._context.start_splitting_if_needed(split) # tuple([]) is faster than tuple() with short lists. - model = tuple([self._build_keyword(step) for step in steps]) + model = tuple([self._build_body_item(item) for item in body]) return model if not splitting else self._context.end_splitting(model) - def _build_keyword(self, step): + def _build_body_item(self, item): raise NotImplementedError -class SuiteBuilder(_Builder): +class SuiteBuilder(Builder): def __init__(self, context): - _Builder.__init__(self, context) + super().__init__(context) self._build_suite = self.build self._build_test = TestBuilder(context).build - self._build_keyword = KeywordBuilder(context).build + self._build_body_item = BodyItemBuilder(context).build def build(self, suite): with self._context.prune_input(suite.tests, suite.suites): stats = self._get_statistics(suite) # Must be done before pruning - kws = [kw for kw in (suite.setup, suite.teardown) if kw] + fixture = [] + if suite.has_setup: + fixture.append(suite.setup) + if suite.has_teardown: + fixture.append(suite.teardown) return (self._string(suite.name, attr=True), self._string(suite.source), self._context.relative_source(suite.source), @@ -98,7 +102,7 @@ def build(self, suite): self._get_status(suite), tuple(self._build_suite(s) for s in suite.suites), tuple(self._build_test(t) for t in suite.tests), - tuple(self._build_keyword(k, split=True) for k in kws), + tuple(self._build_body_item(kw, split=True) for kw in fixture), stats) def _yield_metadata(self, suite): @@ -111,63 +115,61 @@ def _get_statistics(self, suite): return (stats.total, stats.passed, stats.failed, stats.skipped) -class TestBuilder(_Builder): +class TestBuilder(Builder): def __init__(self, context): - _Builder.__init__(self, context) - self._build_keyword = KeywordBuilder(context).build + super().__init__(context) + self._build_body_item = BodyItemBuilder(context).build def build(self, test): - items = self._get_body_items(test) + body = self._get_body_items(test) with self._context.prune_input(test.body): return (self._string(test.name, attr=True), self._string(test.timeout), self._html(test.doc), tuple(self._string(t) for t in test.tags), self._get_status(test), - self._build_body(items, split=True)) + self._build_body(body, split=True)) def _get_body_items(self, test): - kws = [] - if test.setup: - kws.append(test.setup) - kws.extend(test.body.flatten()) - if test.teardown: - kws.append(test.teardown) - return kws + body = test.body.flatten() + if test.has_setup: + body.insert(0, test.setup) + if test.has_teardown: + body.append(test.teardown) + return body -class KeywordBuilder(_Builder): +class BodyItemBuilder(Builder): def __init__(self, context): - _Builder.__init__(self, context) - self._build_keyword = self.build + super().__init__(context) + self._build_body_item = self.build self._build_message = MessageBuilder(context).build def build(self, item, split=False): - if item.type == item.MESSAGE: + if isinstance(item, Message): return self._build_message(item) - return self.build_body_item(item, split) - - def build_body_item(self, item, split=False): with self._context.prune_input(item.body): if isinstance (item, Keyword): - self._context.check_expansion(item) - body = item.body.flatten() - if item.has_setup: - body.insert(0, item.setup) - if item.has_teardown: - body.append(item.teardown) - return self._build(item, item.name, item.owner, item.timeout, item.doc, item.args, - item.assign, item.tags, body, split=split) - if isinstance(item, Return): + return self._build_keyword(item, split) + if isinstance(item, (Return, Error)): return self._build(item, args=item.values, split=split) - if isinstance(item, Error): - return self._build(item, item._name, args=item.values[1:], split=split) - return self._build(item, item._name, split=split) - - def _build(self, item, name='', owner='', timeout='', doc='', args=(), assign=(), - tags=(), body=None, split =False): + return self._build(item, item._log_name, split=split) + + def _build_keyword(self, kw: Keyword, split): + self._context.check_expansion(kw) + body = kw.body.flatten() + if kw.has_setup: + body.insert(0, kw.setup) + if kw.has_teardown: + body.append(kw.teardown) + return self._build(kw, kw.name, kw.owner, kw.timeout, kw.doc, + ' '.join(kw.args), ' '.join(kw.assign), + ', '.join(kw.tags), body, split=split) + + def _build(self, item, name='', owner='', timeout='', doc='', args='', assign='', + tags='', body=None, split=False): if body is None: body = item.body.flatten() return (KEYWORD_TYPES[item.type], @@ -175,14 +177,14 @@ def _build(self, item, name='', owner='', timeout='', doc='', args=(), assign=() self._string(owner, attr=True), self._string(timeout), self._html(doc), - self._string(', '.join(args)), - self._string(', '.join(assign)), - self._string(', '.join(tags)), + self._string(args), + self._string(assign), + self._string(tags), self._get_status(item), self._build_body(body, split)) -class MessageBuilder(_Builder): +class MessageBuilder(Builder): def build(self, msg): if msg.level in ('WARN', 'ERROR'): @@ -211,10 +213,10 @@ def _build_stats(self, stats, exclude_empty=True): for stat in stats) -class ErrorsBuilder(_Builder): +class ErrorsBuilder(Builder): def __init__(self, context): - _Builder.__init__(self, context) + super().__init__(context) self._build_message = ErrorMessageBuilder(context).build def build(self, errors): diff --git a/src/robot/result/model.py b/src/robot/result/model.py index d026b0e2b66..28edb706c4a 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -326,8 +326,8 @@ def visit(self, visitor: SuiteVisitor): visitor.visit_for_iteration(self) @property - def _name(self): - return ', '.join('%s = %s' % item for item in self.assign.items()) + def _log_name(self): + return ', '.join(f'{name} = {value}' for name, value in self.assign.items()) @Body.register @@ -360,15 +360,8 @@ def body(self, iterations: 'Sequence[ForIteration|DataDict]') -> iterations_clas return self.iterations_class(self.iteration_class, self, iterations) @property - def _name(self): - assign = ' | '.join(self.assign) - values = ' | '.join(self.values) - for name, value in [('start', self.start), - ('mode', self.mode), - ('fill', self.fill)]: - if value is not None: - values += f' | {name}={value}' - return f'{assign} {self.flavor} [ {values} ]' + def _log_name(self): + return str(self)[7:] # Drop 'FOR ' prefix. class WhileIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin): @@ -427,17 +420,8 @@ def body(self, iterations: 'Sequence[WhileIteration|DataDict]') -> iterations_cl return self.iterations_class(self.iteration_class, self, iterations) @property - def _name(self): - parts = [] - if self.condition: - parts.append(self.condition) - if self.limit: - parts.append(f'limit={self.limit}') - if self.on_limit: - parts.append(f'on_limit={self.on_limit}') - if self.on_limit_message: - parts.append(f'on_limit_message={self.on_limit_message}') - return ' | '.join(parts) + def _log_name(self): + return str(self)[9:] # Drop 'WHILE ' prefix. class IfBranch(model.IfBranch, StatusMixin, DeprecatedAttributesMixin): @@ -460,7 +444,7 @@ def __init__(self, type: str = BodyItem.IF, self.elapsed_time = elapsed_time @property - def _name(self): + def _log_name(self): return self.condition or '' @@ -506,16 +490,8 @@ def __init__(self, type: str = BodyItem.TRY, self.elapsed_time = elapsed_time @property - def _name(self): - patterns = list(self.patterns) - if self.pattern_type: - patterns.append(f'type={self.pattern_type}') - parts = [] - if patterns: - parts.append(' | '.join(patterns)) - if self.assign: - parts.append(f'AS {self.assign}') - return ' '.join(parts) + def _log_name(self): + return str(self)[len(self.type)+4:] # Drop ' ' prefix. @Body.register @@ -571,6 +547,10 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) + @property + def _log_name(self): + return str(self)[7:] # Drop 'VAR ' prefix. + @Body.register class Return(model.Return, StatusMixin, DeprecatedAttributesMixin): @@ -691,10 +671,6 @@ def body(self, body: 'Sequence[BodyItem|DataDict]') -> Body: """ return self.body_class(self, body) - @property - def _name(self): - return self.values[0] - @Body.register @Branches.register diff --git a/src/robot/result/modeldeprecation.py b/src/robot/result/modeldeprecation.py index 12d9b37629e..a5656ad99c6 100644 --- a/src/robot/result/modeldeprecation.py +++ b/src/robot/result/modeldeprecation.py @@ -27,17 +27,17 @@ def wrapper(self, *args, **kws): class DeprecatedAttributesMixin: __slots__ = [] - _name = '' + _log_name = '' @property @deprecated def name(self): - return self._name + return self._log_name @property @deprecated def kwname(self): - return self._name + return self._log_name @property @deprecated diff --git a/utest/model/test_control.py b/utest/model/test_control.py index c5b67300339..d8c284261a5 100644 --- a/utest/model/test_control.py +++ b/utest/model/test_control.py @@ -1,6 +1,7 @@ import unittest -from robot.model import For, If, IfBranch, TestCase, Try, TryBranch, While +from robot.model import (Break, Continue, Error, For, If, IfBranch, Return, TestCase, + Try, TryBranch, Var, While) from robot.utils.asserts import assert_equal @@ -12,9 +13,9 @@ FINALLY = Try.FINALLY -class TestFor(unittest.TestCase): +class TestStringRepresentations(unittest.TestCase): - def test_string_reprs(self): + def test_for(self): for for_, exp_str, exp_repr in [ (For(), 'FOR IN', @@ -38,10 +39,7 @@ def test_string_reprs(self): assert_equal(str(for_), exp_str) assert_equal(repr(for_), 'robot.model.' + exp_repr) - -class TestWhile(unittest.TestCase): - - def test_string_reprs(self): + def test_while(self): for while_, exp_str, exp_repr in [ (While(), 'WHILE', @@ -53,6 +51,86 @@ def test_string_reprs(self): assert_equal(str(while_), exp_str) assert_equal(repr(while_), 'robot.model.' + exp_repr) + def test_if(self): + for if_, exp_str, exp_repr in [ + (IfBranch(), + 'IF None', + "IfBranch(type='IF', condition=None)"), + (IfBranch(condition='$x > 1'), + 'IF $x > 1', + "IfBranch(type='IF', condition='$x > 1')"), + (IfBranch(ELSE_IF, condition='$x > 2'), + 'ELSE IF $x > 2', + "IfBranch(type='ELSE IF', condition='$x > 2')"), + (IfBranch(ELSE), + 'ELSE', + "IfBranch(type='ELSE', condition=None)"), + (IfBranch(condition='$x == "äiti"'), + 'IF $x == "äiti"', + "IfBranch(type='IF', condition='$x == \"äiti\"')"), + ]: + assert_equal(str(if_), exp_str) + assert_equal(repr(if_), 'robot.model.' + exp_repr) + + def test_try(self): + for try_, exp_str, exp_repr in [ + (TryBranch(), + 'TRY', + "TryBranch(type='TRY')"), + (TryBranch(EXCEPT), + 'EXCEPT', + "TryBranch(type='EXCEPT')"), + (TryBranch(EXCEPT, ('Message',)), + 'EXCEPT Message', + "TryBranch(type='EXCEPT', patterns=('Message',))"), + (TryBranch(EXCEPT, ('M', 'S', 'G', 'S')), + 'EXCEPT M S G S', + "TryBranch(type='EXCEPT', patterns=('M', 'S', 'G', 'S'))"), + (TryBranch(EXCEPT, (), None, '${x}'), + 'EXCEPT AS ${x}', + "TryBranch(type='EXCEPT', assign='${x}')"), + (TryBranch(EXCEPT, ('Message',), 'glob', '${x}'), + 'EXCEPT Message type=glob AS ${x}', + "TryBranch(type='EXCEPT', patterns=('Message',), pattern_type='glob', assign='${x}')"), + (TryBranch(ELSE), + 'ELSE', + "TryBranch(type='ELSE')"), + (TryBranch(FINALLY), + 'FINALLY', + "TryBranch(type='FINALLY')"), + ]: + assert_equal(str(try_), exp_str) + assert_equal(repr(try_), 'robot.model.' + exp_repr) + + def test_var(self): + for var, exp_str, exp_repr in [ + (Var(), + 'VAR ', + "Var(name='', value=())"), + (Var('${name}', 'value'), + 'VAR ${name} value', + "Var(name='${name}', value=('value',))"), + (Var('${name}', ['v1', 'v2'], separator=''), + 'VAR ${name} v1 v2 separator=', + "Var(name='${name}', value=('v1', 'v2'), separator='')"), + (Var('@{list}', ['x', 'y'], scope='SUITE'), + 'VAR @{list} x y scope=SUITE', + "Var(name='@{list}', value=('x', 'y'), scope='SUITE')") + ]: + assert_equal(str(var), exp_str) + assert_equal(repr(var), 'robot.model.' + exp_repr) + + def test_return_continue_break(self): + for cls in Return, Continue, Break: + assert_equal(str(cls()), cls.__name__.upper()) + assert_equal(repr(cls()), f'robot.model.{cls.__name__}()') + assert_equal(str(Return(['x', 'y'])), 'RETURN x y') + assert_equal(repr(Return(['x', 'y'])), f"robot.model.Return(values=('x', 'y'))") + + def test_error(self): + assert_equal(str(Error(['x', 'y'])), 'ERROR x y') + assert_equal(repr(Error(['x', 'y'])), f"robot.model.Error(values=('x', 'y'))") + class TestIf(unittest.TestCase): @@ -96,27 +174,6 @@ def test_branch_id_when_parent_has_setup(self): assert_equal(tc.body.create_keyword().id, 't1-k4') assert_equal(tc.body.create_if().body.create_branch().id, 't1-k5') - def test_string_reprs(self): - for if_, exp_str, exp_repr in [ - (IfBranch(), - 'IF None', - "IfBranch(type='IF', condition=None)"), - (IfBranch(condition='$x > 1'), - 'IF $x > 1', - "IfBranch(type='IF', condition='$x > 1')"), - (IfBranch(ELSE_IF, condition='$x > 2'), - 'ELSE IF $x > 2', - "IfBranch(type='ELSE IF', condition='$x > 2')"), - (IfBranch(ELSE), - 'ELSE', - "IfBranch(type='ELSE', condition=None)"), - (IfBranch(condition='$x == "äiti"'), - 'IF $x == "äiti"', - "IfBranch(type='IF', condition='$x == \"äiti\"')"), - ]: - assert_equal(str(if_), exp_str) - assert_equal(repr(if_), 'robot.model.' + exp_repr) - class TestTry(unittest.TestCase): @@ -162,36 +219,6 @@ def test_branch_id_when_parent_has_setup(self): assert_equal(tc.body.create_keyword().id, 't1-k4') assert_equal(tc.body.create_try().body.create_branch().id, 't1-k5') - def test_string_reprs(self): - for try_, exp_str, exp_repr in [ - (TryBranch(), - 'TRY', - "TryBranch(type='TRY')"), - (TryBranch(EXCEPT), - 'EXCEPT', - "TryBranch(type='EXCEPT')"), - (TryBranch(EXCEPT, ('Message',)), - 'EXCEPT Message', - "TryBranch(type='EXCEPT', patterns=('Message',))"), - (TryBranch(EXCEPT, ('M', 'S', 'G', 'S')), - 'EXCEPT M S G S', - "TryBranch(type='EXCEPT', patterns=('M', 'S', 'G', 'S'))"), - (TryBranch(EXCEPT, (), None, '${x}'), - 'EXCEPT AS ${x}', - "TryBranch(type='EXCEPT', assign='${x}')"), - (TryBranch(EXCEPT, ('Message',), 'glob', '${x}'), - 'EXCEPT Message type=glob AS ${x}', - "TryBranch(type='EXCEPT', patterns=('Message',), pattern_type='glob', assign='${x}')"), - (TryBranch(ELSE), - 'ELSE', - "TryBranch(type='ELSE')"), - (TryBranch(FINALLY), - 'FINALLY', - "TryBranch(type='FINALLY')"), - ]: - assert_equal(str(try_), exp_str) - assert_equal(repr(try_), 'robot.model.' + exp_repr) - if __name__ == '__main__': unittest.main() diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index 20b4f41091f..d799f33fe2c 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -8,7 +8,7 @@ from robot.result.executionerrors import ExecutionErrors from robot.model import Statistics, BodyItem from robot.reporting.jsmodelbuilders import ( - ErrorsBuilder, JsBuildingContext, KeywordBuilder, MessageBuilder, + ErrorsBuilder, JsBuildingContext, BodyItemBuilder, MessageBuilder, StatisticsBuilder, SuiteBuilder, TestBuilder ) from robot.reporting.stringcache import StringIndex @@ -43,8 +43,8 @@ def test_default_suite(self): def test_suite_with_values(self): suite = TestSuite('Name', 'Doc', {'m1': 'v1', 'M2': 'V2'}, None, False, 'Message', '2011-12-04 19:00:00.000', '2011-12-04 19:00:42.001') - s = self._verify_keyword(suite.setup.config(name='S'), type=1, name='S') - t = self._verify_keyword(suite.teardown.config(name='T'), type=2, name='T') + s = self._verify_body_item(suite.setup.config(name='S'), type=1, name='S') + t = self._verify_body_item(suite.teardown.config(name='T'), type=2, name='T') self._verify_suite(suite, 'Name', 'Doc', ('m1', '

v1

', 'M2', '

V2

'), message='Message', start=0, elapsed=42001, keywords=(s, t)) @@ -67,51 +67,51 @@ def test_default_test(self): def test_test_with_values(self): test = TestCase('Name', '*Doc*', ['t1', 't2'], '1 minute', 42, 'PASS', 'Msg', '2011-12-04 19:22:22.222', '2011-12-04 19:22:22.333') - k = self._verify_keyword(test.body.create_keyword('K'), name='K') - s = self._verify_keyword(test.setup.config(name='S'), type=1, name='S') - t = self._verify_keyword(test.teardown.config(name='T'), type=2, name='T') + k = self._verify_body_item(test.body.create_keyword('K'), name='K') + s = self._verify_body_item(test.setup.config(name='S'), type=1, name='S') + t = self._verify_body_item(test.teardown.config(name='T'), type=2, name='T') self._verify_test(test, 'Name', 'Doc', ('t1', 't2'), '1 minute', 1, 'Msg', 0, 111, (s, k, t)) def test_name_escaping(self): kw = Keyword('quote:"', 'and *url* https://url.com', doc='*"Doc"*',) - self._verify_keyword(kw, 0, 'quote:"', 'and *url* https://url.com', '"Doc"') + self._verify_body_item(kw, 0, 'quote:"', 'and *url* https://url.com', '"Doc"') test = TestCase('quote:" and *url* https://url.com', '*"Doc"*',) self._verify_test(test, 'quote:" and *url* https://url.com', '"Doc"') suite = TestSuite('quote:" and *url* https://url.com', '*"Doc"*',) self._verify_suite(suite, 'quote:" and *url* https://url.com', '"Doc"') def test_default_keyword(self): - self._verify_keyword(Keyword()) + self._verify_body_item(Keyword()) def test_keyword_with_values(self): kw = Keyword('KW Name', 'libname', '', 'http://doc', ('arg1', 'arg2'), ('${v1}', '${v2}'), ('tag1', 'tag2'), '1 second', 'SETUP', 'FAIL', 'message', '2011-12-04 19:42:42.000', '2011-12-04 19:42:42.042') - self._verify_keyword(kw, 1, 'KW Name', 'libname', - 'http://doc', - 'arg1, arg2', '${v1}, ${v2}', 'tag1, tag2', - '1 second', 0, 0, 42, 'message') + self._verify_body_item(kw, 1, 'KW Name', 'libname', + 'http://doc', + 'arg1 arg2', '${v1} ${v2}', 'tag1, tag2', + '1 second', 0, 0, 42, 'message') def test_keyword_with_body(self): root = Keyword('Root') - exp1 = self._verify_keyword(root.body.create_keyword('C1'), name='C1') - exp2 = self._verify_keyword(root.body.create_keyword('C2'), name='C2') - self._verify_keyword(root, name='Root', body=(exp1, exp2)) + exp1 = self._verify_body_item(root.body.create_keyword('C1'), name='C1') + exp2 = self._verify_body_item(root.body.create_keyword('C2'), name='C2') + self._verify_body_item(root, name='Root', body=(exp1, exp2)) def test_keyword_with_setup(self): root = Keyword('Root') - s = self._verify_keyword(root.setup.config(name='S'), type=1, name='S') - self._verify_keyword(root, name='Root', body=(s,)) - k = self._verify_keyword(root.body.create_keyword('K'), name='K') - self._verify_keyword(root, name='Root', body=(s, k)) + s = self._verify_body_item(root.setup.config(name='S'), type=1, name='S') + self._verify_body_item(root, name='Root', body=(s,)) + k = self._verify_body_item(root.body.create_keyword('K'), name='K') + self._verify_body_item(root, name='Root', body=(s, k)) def test_keyword_with_teardown(self): root = Keyword('Root') - t = self._verify_keyword(root.teardown.config(name='T'), type=2, name='T') - self._verify_keyword(root, name='Root', body=(t,)) - k = self._verify_keyword(root.body.create_keyword('K'), name='K') - self._verify_keyword(root, name='Root', body=(k, t)) + t = self._verify_body_item(root.teardown.config(name='T'), type=2, name='T') + self._verify_body_item(root, name='Root', body=(t,)) + k = self._verify_body_item(root.body.create_keyword('K'), name='K') + self._verify_body_item(root, name='Root', body=(k, t)) def test_default_message(self): self._verify_message(Message()) @@ -148,27 +148,29 @@ def test_nested_structure(self): suite = TestSuite() suite.setup.config(name='setup') suite.teardown.config(name='td') - K1 = self._verify_keyword(suite.setup, type=1, name='setup') - K2 = self._verify_keyword(suite.teardown, type=2, name='td') + ss = self._verify_body_item(suite.setup, type=1, name='setup') + st = self._verify_body_item(suite.teardown, type=2, name='td') suite.suites = [TestSuite()] suite.suites[0].tests = [TestCase(tags=['crit', 'xxx'])] t = self._verify_test(suite.suites[0].tests[0], tags=('crit', 'xxx')) suite.tests = [TestCase(), TestCase(status='PASS')] - S1 = self._verify_suite(suite.suites[0], + s1 = self._verify_suite(suite.suites[0], status=0, tests=(t,), stats=(1, 0, 1, 0)) - suite.tests[0].body = [For(assign=['${x}'], values=['1', '2'], message='x'), Keyword()] + suite.tests[0].body = [For(assign=['${x}'], values=['1', '2'], message='x'), + Keyword()] suite.tests[0].body[0].body = [ForIteration(), Message()] - k = self._verify_keyword(suite.tests[0].body[0].body[0], type=4) + i = self._verify_body_item(suite.tests[0].body[0].body[0], type=4) m = self._verify_message(suite.tests[0].body[0].body[1]) - k1 = self._verify_keyword(suite.tests[0].body[0], type=3, body=(k, m), name='${x} IN [ 1 | 2 ]', message='x') + f = self._verify_body_item(suite.tests[0].body[0], type=3, + name='${x} IN 1 2', message='x', body=(i, m)) suite.tests[0].body[1].body = [Message(), Message('msg', level='TRACE')] m1 = self._verify_message(suite.tests[0].body[1].messages[0]) m2 = self._verify_message(suite.tests[0].body[1].messages[1], 'msg', level=0) - k2 = self._verify_keyword(suite.tests[0].body[1], body=(m1, m2)) - T1 = self._verify_test(suite.tests[0], body=(k1, k2)) - T2 = self._verify_test(suite.tests[1], status=1) - self._verify_suite(suite, status=0, keywords=(K1, K2), suites=(S1,), - tests=(T1, T2), stats=(3, 1, 2, 0)) + k = self._verify_body_item(suite.tests[0].body[1], body=(m1, m2)) + t1 = self._verify_test(suite.tests[0], body=(f, k)) + t2 = self._verify_test(suite.tests[1], status=1) + self._verify_suite(suite, status=0, keywords=(ss, st), suites=(s1,), + tests=(t1, t2), stats=(3, 1, 2, 0)) self._verify_min_message_level('TRACE') def test_timestamps(self): @@ -208,10 +210,11 @@ def test_for(self): test = TestSuite().tests.create() test.body.create_for(assign=['${x}'], values=['a', 'b']) test.body.create_for(['${x}'], 'IN ENUMERATE', ['a', 'b'], start='1') - end = ('', '', '', '', '', '', (0, None, 0), ()) - exp_f1 = (3, '${x} IN [ a | b ]', *end) - exp_f2 = (3, '${x} IN ENUMERATE [ a | b | start=1 ]', *end) - self._verify_test(test, body=(exp_f1, exp_f2)) + f1 = self._verify_body_item(test.body[0], type=3, + name='${x} IN a b') + f2 = self._verify_body_item(test.body[1], type=3, + name='${x} IN ENUMERATE a b start=1') + self._verify_test(test, body=(f1, f2)) def test_message_directly_under_test(self): test = TestSuite().tests.create() @@ -248,13 +251,13 @@ def _verify_test(self, test, name='', doc='', tags=(), timeout='', return self._build_and_verify(TestBuilder, test, name, timeout, doc, tags, status, body) - def _verify_keyword(self, keyword, type=0, name='', owner='', doc='', - args='', assign='', tags='', timeout='', status=0, - start=None, elapsed=0, message='', body=()): + def _verify_body_item(self, keyword, type=0, name='', owner='', doc='', + args='', assign='', tags='', timeout='', status=0, + start=None, elapsed=0, message='', body=()): status = (status, start, elapsed, message) \ if message else (status, start, elapsed) doc = f'

{doc}

' if doc else '' - return self._build_and_verify(KeywordBuilder, keyword, type, name, owner, + return self._build_and_verify(BodyItemBuilder, keyword, type, name, owner, timeout, doc, args, assign, tags, status, body) def _verify_message(self, msg, message='', level=2, timestamp=None): @@ -433,7 +436,7 @@ def test_prune_keyword(self): kw = self.suite.suites[0].tests[0].body[0] assert_equal(len(kw.body), 5) assert_equal(len(kw.messages), 3) - KeywordBuilder(JsBuildingContext(prune_input=True)).build(kw) + BodyItemBuilder(JsBuildingContext(prune_input=True)).build(kw) assert_equal(len(kw.body), 0) assert_equal(len(kw.messages), 0) diff --git a/utest/result/test_resultmodel.py b/utest/result/test_resultmodel.py index 7274524ce6d..074ce213572 100644 --- a/utest/result/test_resultmodel.py +++ b/utest/result/test_resultmodel.py @@ -4,7 +4,7 @@ from robot.model import Tags from robot.result import (Break, Continue, Error, For, If, IfBranch, Keyword, Message, - Return, TestCase, TestSuite, Try, TryBranch, While) + Return, TestCase, TestSuite, Try, TryBranch, Var, While) from robot.utils.asserts import (assert_equal, assert_false, assert_raises, assert_raises_with_msg, assert_true) @@ -455,17 +455,50 @@ def test_if_parents(self): kw = branch.body.create_keyword() assert_equal(kw.parent, branch) - def test_while_name(self): - assert_equal(While()._name, '') - assert_equal(While('$x > 0')._name, '$x > 0') - assert_equal(While('True', '1 minute')._name, 'True | limit=1 minute') - assert_equal(While(limit='1 minute')._name, 'limit=1 minute') - assert_equal(While('True', '1 s', on_limit_message='Error message')._name, - 'True | limit=1 s | on_limit_message=Error message') - assert_equal(While(on_limit='pass')._name, 'on_limit=pass') - assert_equal(While(on_limit_message='Error message')._name, + def test_while_log_name(self): + assert_equal(While()._log_name, '') + assert_equal(While('$x > 0')._log_name, '$x > 0') + assert_equal(While('True', '1 minute')._log_name, + 'True limit=1 minute') + assert_equal(While(limit='1 minute')._log_name, + 'limit=1 minute') + assert_equal(While('True', '1 s', on_limit_message='x')._log_name, + 'True limit=1 s on_limit_message=x') + assert_equal(While(on_limit='pass', limit='100')._log_name, + 'limit=100 on_limit=pass') + assert_equal(While(on_limit_message='Error message')._log_name, 'on_limit_message=Error message') + def test_for_log_name(self): + assert_equal(For(assign=['${x}'], values=['a', 'b'])._log_name, + '${x} IN a b') + assert_equal(For(['${x}'], 'IN ENUMERATE', ['a', 'b'], start='1')._log_name, + '${x} IN ENUMERATE a b start=1') + assert_equal(For(['${x}', '${y}'], 'IN ZIP', ['${xs}', '${ys}'], + mode='STRICT', fill='-')._log_name, + '${x} ${y} IN ZIP ${xs} ${ys} mode=STRICT fill=-') + + def test_try_log_name(self): + for typ in TryBranch.TRY, TryBranch.EXCEPT, TryBranch.ELSE, TryBranch.FINALLY: + assert_equal(TryBranch(typ)._log_name, '') + branch = TryBranch(TryBranch.EXCEPT) + assert_equal(branch.config(patterns=['p1', 'p2'])._log_name, + 'p1 p2') + assert_equal(branch.config(pattern_type='glob')._log_name, + 'p1 p2 type=glob') + assert_equal(branch.config(assign='${err}')._log_name, + 'p1 p2 type=glob AS ${err}') + + def test_var_log_name(self): + assert_equal(Var('${x}', 'y')._log_name, + '${x} y') + assert_equal(Var('${x}', ('y', 'z'))._log_name, + '${x} y z') + assert_equal(Var('${x}', ('y', 'z'), separator='')._log_name, + '${x} y z separator=') + assert_equal(Var('@{x}', ('y',), scope='test')._log_name, + '@{x} y scope=test') + class TestBody(unittest.TestCase): diff --git a/utest/running/test_run_model.py b/utest/running/test_run_model.py index 5f9a2f2cd92..4869827f8ec 100644 --- a/utest/running/test_run_model.py +++ b/utest/running/test_run_model.py @@ -362,7 +362,7 @@ def test_try_structure(self): {'type': 'FINALLY', 'body': [{'name': 'K4'}]}]) def test_return_continue_break(self): - self._verify(Return(), type='RETURN', values=()) + self._verify(Return(), type='RETURN') self._verify(Return(('x', 'y'), lineno=9, error='E'), type='RETURN', values=('x', 'y'), lineno=9, error='E') self._verify(Continue(), type='CONTINUE') @@ -380,8 +380,8 @@ def test_var(self): def test_error(self): self._verify(Error(), type='ERROR', values=(), error='') - self._verify(Error(('bad', 'things'), error='Bad things!'), - type='ERROR', values=('bad', 'things'), error='Bad things!') + self._verify(Error(('x', 'y'), error='Bad things happened!'), + type='ERROR', values=('x', 'y'), error='Bad things happened!') def test_test(self): self._verify(TestCase(), name='', body=[]) From 1f182536fc1d26198107f590582b8d32193e2b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 13 Oct 2023 16:41:49 +0300 Subject: [PATCH 0773/1592] Integrate VAR with results and reporting. #3761 --- atest/resources/TestCheckerLibrary.py | 12 +++++-- .../listener_interface/listener_logging.robot | 25 +++++++++----- .../listener_interface/listener_methods.robot | 18 ++++++++-- .../listener_interface/log_levels.robot | 9 +++++ atest/robot/running/flatten.robot | 20 ++++++----- atest/robot/variables/var_syntax.robot | 31 ++++++++++++++--- atest/testdata/misc/pass_and_fail.robot | 4 ++- atest/testdata/misc/while.robot | 6 ++-- atest/testdata/running/flatten.robot | 20 +++++------ atest/testdata/variables/var_syntax.robot | 12 +++++-- doc/schema/robot.xsd | 17 ++++++++++ src/robot/htmldata/rebot/testdata.js | 5 +-- src/robot/model/visitor.py | 24 +++++++++++++- src/robot/output/listeners.py | 6 ++++ src/robot/output/logger.py | 13 ++++++-- src/robot/output/loggerapi.py | 6 ++++ src/robot/output/output.py | 6 ++++ src/robot/output/xmllogger.py | 26 +++++++++++++++ src/robot/reporting/jsmodelbuilders.py | 4 +-- src/robot/result/xmlelementhandlers.py | 24 +++++++++++--- src/robot/running/context.py | 2 ++ src/robot/running/statusreporter.py | 6 ++-- src/robot/testdoc.py | 21 +++++++----- src/robot/utils/markupwriters.py | 33 +++++++++++-------- utest/reporting/test_jsmodelbuilders.py | 13 ++++++++ 25 files changed, 282 insertions(+), 81 deletions(-) diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index d62f661b020..875a7abd102 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -8,7 +8,7 @@ from robot.libraries.BuiltIn import BuiltIn from robot.result import (Break, Continue, Error, ExecutionResultBuilder, For, ForIteration, If, IfBranch, Keyword, Result, ResultVisitor, - Return, TestCase, TestSuite, Try, TryBranch, While, + Return, TestCase, TestSuite, Try, TryBranch, Var, While, WhileIteration) from robot.result.model import Body, Iterations from robot.utils.asserts import assert_equal @@ -34,6 +34,10 @@ class NoSlotsTry(Try): pass +class NoSlotsVar(Var): + pass + + class NoSlotsReturn(Return): pass @@ -56,6 +60,7 @@ class NoSlotsBody(Body): if_class = NoSlotsIf try_class = NoSlotsTry while_class = NoSlotsWhile + var_class = NoSlotsVar return_class = NoSlotsReturn break_class = NoSlotsBreak continue_class = NoSlotsContinue @@ -82,8 +87,9 @@ class NoSlotsIterations(Iterations): keyword_class = NoSlotsKeyword -NoSlotsKeyword.body_class = NoSlotsReturn.body_class = NoSlotsBreak.body_class \ - = NoSlotsContinue.body_class = NoSlotsError.body_class = NoSlotsBody +NoSlotsKeyword.body_class = NoSlotsVar.body_class = NoSlotsReturn.body_class \ + = NoSlotsBreak.body_class = NoSlotsContinue.body_class \ + = NoSlotsError.body_class = NoSlotsBody NoSlotsFor.iterations_class = NoSlotsWhile.iterations_class = NoSlotsIterations NoSlotsFor.iteration_class = NoSlotsForIteration NoSlotsWhile.iteration_class = NoSlotsWhileIteration diff --git a/atest/robot/output/listener_interface/listener_logging.robot b/atest/robot/output/listener_interface/listener_logging.robot index a6acb42e6df..9a4a4978cea 100644 --- a/atest/robot/output/listener_interface/listener_logging.robot +++ b/atest/robot/output/listener_interface/listener_logging.robot @@ -40,9 +40,10 @@ Execution errors should have messages from message and log_message methods Correct start/end warnings should be shown in execution errors ${msgs} = Get start/end messages ${ERRORS} @{kw} = Create List start keyword end keyword + @{var} = Create List start var end var @{return} = Create List start return end return - @{setup} = Create List start setup @{kw} @{kw} @{kw} @{return} end setup - @{uk} = Create List start keyword @{kw} @{kw} @{kw} @{return} end keyword + @{setup} = Create List start setup @{kw} @{kw} @{kw} @{var} @{kw} @{return} end setup + @{uk} = Create List start keyword @{kw} @{kw} @{kw} @{var} @{kw} @{return} end keyword FOR ${index} ${method} IN ENUMERATE ... start_suite ... @{setup} @@ -102,12 +103,20 @@ Correct messages should be logged to normal log Check Log Message ${kw.body[4].body[4]} \${assign} = JUST TESTING... INFO Check Log Message ${kw.body[4].body[5]} end keyword INFO Check Log Message ${kw.body[4].body[6]} end keyword WARN - Check Log Message ${kw.body[5].body[0]} start return INFO - Check Log Message ${kw.body[5].body[1]} start return WARN - Check Log Message ${kw.body[5].body[2]} end return INFO - Check Log Message ${kw.body[5].body[3]} end return WARN - Check Log Message ${kw.body[6]} end ${type} INFO - Check Log Message ${kw.body[7]} end ${type} WARN + Check Log Message ${kw.body[5].body[0]} start var INFO + Check Log Message ${kw.body[5].body[1]} start var WARN + Check Log Message ${kw.body[5].body[2]} end var INFO + Check Log Message ${kw.body[5].body[3]} end var WARN + Check Log Message ${kw.body[6].body[0]} start keyword INFO + Check Log Message ${kw.body[6].body[1]} start keyword WARN + Check Log Message ${kw.body[6].body[2]} end keyword INFO + Check Log Message ${kw.body[6].body[3]} end keyword WARN + Check Log Message ${kw.body[7].body[0]} start return INFO + Check Log Message ${kw.body[7].body[1]} start return WARN + Check Log Message ${kw.body[7].body[2]} end return INFO + Check Log Message ${kw.body[7].body[3]} end return WARN + Check Log Message ${kw.body[8]} end ${type} INFO + Check Log Message ${kw.body[9]} end ${type} WARN 'Fail' has correct messages [Arguments] ${kw} diff --git a/atest/robot/output/listener_interface/listener_methods.robot b/atest/robot/output/listener_interface/listener_methods.robot index f52d7a5a54a..f9ad98177f9 100644 --- a/atest/robot/output/listener_interface/listener_methods.robot +++ b/atest/robot/output/listener_interface/listener_methods.robot @@ -102,7 +102,11 @@ Check Listen All File ... KEYWORD START: \${assign} = String.Convert To Upper Case ['Just testing...'] (line 29) ... LOG MESSAGE: [INFO] \${assign} = JUST TESTING... ... KEYWORD END: PASS - ... RETURN START: (line 30) + ... VAR START: \${expected}${SPACE*4}JUST TESTING... (line 30) + ... VAR END: PASS + ... KEYWORD START: BuiltIn.Should Be Equal ['\${assign}', '\${expected}'] (line 31) + ... KEYWORD END: PASS + ... RETURN START: (line 32) ... RETURN END: PASS ... SETUP END: PASS ... TEST START: Pass (s1-t1, line 12) '' ['force', 'pass'] @@ -115,7 +119,11 @@ Check Listen All File ... KEYWORD START: \${assign} = String.Convert To Upper Case ['Just testing...'] (line 29) ... LOG MESSAGE: [INFO] \${assign} = JUST TESTING... ... KEYWORD END: PASS - ... RETURN START: (line 30) + ... VAR START: \${expected}${SPACE*4}JUST TESTING... (line 30) + ... VAR END: PASS + ... KEYWORD START: BuiltIn.Should Be Equal ['\${assign}', '\${expected}'] (line 31) + ... KEYWORD END: PASS + ... RETURN START: (line 32) ... RETURN END: PASS ... KEYWORD END: PASS ... TEST END: PASS @@ -129,7 +137,11 @@ Check Listen All File ... KEYWORD START: \${assign} = String.Convert To Upper Case ['Just testing...'] (line 29) ... LOG MESSAGE: [INFO] \${assign} = JUST TESTING... ... KEYWORD END: PASS - ... RETURN START: (line 30) + ... VAR START: \${expected}${SPACE*4}JUST TESTING... (line 30) + ... VAR END: PASS + ... KEYWORD START: BuiltIn.Should Be Equal ['\${assign}', '\${expected}'] (line 31) + ... KEYWORD END: PASS + ... RETURN START: (line 32) ... RETURN END: PASS ... KEYWORD END: PASS ... KEYWORD START: BuiltIn.Fail ['Expected failure'] (line 21) diff --git a/atest/robot/output/listener_interface/log_levels.robot b/atest/robot/output/listener_interface/log_levels.robot index e48ad12f0ac..56c3a12f050 100644 --- a/atest/robot/output/listener_interface/log_levels.robot +++ b/atest/robot/output/listener_interface/log_levels.robot @@ -23,12 +23,21 @@ Log messages are collected on specified level ... INFO: Hello says "Suite Setup"! ... DEBUG: Debug message ... INFO: \${assign} = JUST TESTING... + ... DEBUG: Argument types are: + ... + ... ... INFO: Hello says "Pass"! ... DEBUG: Debug message ... INFO: \${assign} = JUST TESTING... + ... DEBUG: Argument types are: + ... + ... ... INFO: Hello says "Fail"! ... DEBUG: Debug message ... INFO: \${assign} = JUST TESTING... + ... DEBUG: Argument types are: + ... + ... ... FAIL: Expected failure ... DEBUG: Traceback (most recent call last): ... ${SPACE*2}None diff --git a/atest/robot/running/flatten.robot b/atest/robot/running/flatten.robot index 028ab0276bd..d3b037c5d42 100644 --- a/atest/robot/running/flatten.robot +++ b/atest/robot/running/flatten.robot @@ -13,12 +13,17 @@ Nested UK Check Log Message ${tc.body[0].messages[1]} from nested kw Loops and stuff - ${tc}= User keyword content should be flattened 19 + ${tc}= User keyword content should be flattened 10 Check Log Message ${tc.body[0].messages[0]} inside for 0 - Check Log Message ${tc.body[0].messages[5]} inside while 0 - Check Log Message ${tc.body[0].messages[15]} inside if - Check Log Message ${tc.body[0].messages[16]} fail inside try FAIL - Check Log Message ${tc.body[0].messages[18]} inside except + Check Log Message ${tc.body[0].messages[1]} inside for 1 + Check Log Message ${tc.body[0].messages[2]} inside for 2 + Check Log Message ${tc.body[0].messages[3]} inside while 0 + Check Log Message ${tc.body[0].messages[4]} inside while 1 + Check Log Message ${tc.body[0].messages[5]} inside while 2 + Check Log Message ${tc.body[0].messages[6]} inside if + Check Log Message ${tc.body[0].messages[7]} fail inside try FAIL + Check Log Message ${tc.body[0].messages[8]} Traceback (most recent call last):* DEBUG pattern=True + Check Log Message ${tc.body[0].messages[9]} inside except Recursion User keyword content should be flattened 8 @@ -30,7 +35,6 @@ Listener methods start and end keyword are called User keyword content should be flattened [Arguments] ${expected_message_count}=0 ${tc}= Check Test Case ${TESTNAME} - ${kw}= set variable ${tc.body[0]} - Length Should Be ${kw.body} ${expected_message_count} - Length Should Be ${kw.messages} ${expected_message_count} + Length Should Be ${tc.body[0].body} ${expected_message_count} + Length Should Be ${tc.body[0].messages} ${expected_message_count} RETURN ${tc} diff --git a/atest/robot/variables/var_syntax.robot b/atest/robot/variables/var_syntax.robot index fec3eb1ecc8..1c2827a84e8 100644 --- a/atest/robot/variables/var_syntax.robot +++ b/atest/robot/variables/var_syntax.robot @@ -4,19 +4,31 @@ Resource atest_resource.robot *** Test Cases *** Scalar - Check Test Case ${TESTNAME} + ${tc} = Check Test Case ${TESTNAME} + Validate VAR ${tc.body}[0] \${name} value Scalar with separator - Check Test Case ${TESTNAME} + ${tc} = Check Test Case ${TESTNAME} + Validate VAR ${tc.body}[0] \${a} \${1} 2 3 separator=\\n + Validate VAR ${tc.body}[1] \${b} 1 \${2} 3 separator==== + Validate VAR ${tc.body}[2] \${c} 1 2 \${3} separator= + Validate VAR ${tc.body}[3] \${d} \${a} \${b} \${c} separator=\${0} List - Check Test Case ${TESTNAME} + ${tc} = Check Test Case ${TESTNAME} + Validate VAR ${tc.body}[0] \@{name} v1 v2 v3 Dict - Check Test Case ${TESTNAME} + ${tc} = Check Test Case ${TESTNAME} + Validate VAR ${tc.body}[0] \&{name} k1=v1 k2=v2 Scopes - Check Test Case ${TESTNAME} 1 + ${tc} = Check Test Case ${TESTNAME} 1 + Validate VAR ${tc.body}[0] \${local1} local1 + Validate VAR ${tc.body}[1] \${local2} local2 scope=LOCAL + Validate VAR ${tc.body}[2] \${test} test scope=test + Validate VAR ${tc.body}[3] \${suite} suite scope=\${{'suite'}} + Validate VAR ${tc.body}[4] \${global} global scope=GLOBAL Check Test Case ${TESTNAME} 2 Invalid scope @@ -48,3 +60,12 @@ With inline IF With TRY Check Test Case ${TESTNAME} + +*** Keywords *** +Validate VAR + [Arguments] ${var} ${name} @{value} ${scope}=${None} ${separator}=${None} + Should Be Equal ${var.type} VAR + Should Be Equal ${var.name} ${name} + Should Be Equal ${var.value} ${{tuple($value)}} + Should Be Equal ${var.scope} ${scope} + Should Be Equal ${var.separator} ${separator} diff --git a/atest/testdata/misc/pass_and_fail.robot b/atest/testdata/misc/pass_and_fail.robot index 332467edc6d..a167233d026 100644 --- a/atest/testdata/misc/pass_and_fail.robot +++ b/atest/testdata/misc/pass_and_fail.robot @@ -1,7 +1,7 @@ *** Settings *** Documentation Some tests here Suite Setup My Keyword Suite Setup -Force Tags force +Test Tags force Library String *** Variables *** @@ -27,4 +27,6 @@ My Keyword Log Hello says "${who}"! ${LEVEL1} Log Debug message ${LEVEL2} ${assign} = Convert to Uppercase Just testing... + VAR ${expected} JUST TESTING... + Should Be Equal ${assign} ${expected} RETURN diff --git a/atest/testdata/misc/while.robot b/atest/testdata/misc/while.robot index 20d7e0c8f8d..68ae535ee28 100644 --- a/atest/testdata/misc/while.robot +++ b/atest/testdata/misc/while.robot @@ -1,6 +1,6 @@ *** Test cases *** WHILE loop executed multiple times - ${variable}= Set variable ${1} + VAR ${variable} ${1} WHILE $variable < 6 Log ${variable} ${variable}= Evaluate $variable + 1 @@ -11,10 +11,10 @@ WHILE loop in keyword *** Keywords *** WHILE loop executed multiple times - ${variable}= Set variable ${1} + VAR ${variable} ${1} WHILE True limit=10 on_limit_message=xxx Log ${variable} - ${variable}= Evaluate $variable + 1 + VAR ${variable} ${variable + 1} IF $variable == 5 CONTINUE IF $variable == 6 BREAK END diff --git a/atest/testdata/running/flatten.robot b/atest/testdata/running/flatten.robot index 5d606a9efda..66bb6e3cbad 100644 --- a/atest/testdata/running/flatten.robot +++ b/atest/testdata/running/flatten.robot @@ -1,5 +1,5 @@ *** Variables *** -${while limit} ${0} +${LIMIT} ${0} *** Test Cases *** A single user keyword @@ -35,22 +35,22 @@ Loops and stuff [Tags] robot:flatten FOR ${i} IN RANGE 5 Log inside for ${i} - IF ${i} > 3 + IF ${i} > 1 BREAK ELSE CONTINUE END END - WHILE ${while limit} < 5 - Log inside while ${while limit} - ${while limit}= Set Variable ${while limit + 1} - END - IF True - Log inside if - ELSE - Fail not run + WHILE ${LIMIT} < 3 + Log inside while ${LIMIT} + VAR ${LIMIT} ${LIMIT + 1} END TRY + IF True + Log inside if + ELSE + Fail not run + END Fail fail inside try EXCEPT Log inside except diff --git a/atest/testdata/variables/var_syntax.robot b/atest/testdata/variables/var_syntax.robot index e4d97944d89..07ef2bba560 100644 --- a/atest/testdata/variables/var_syntax.robot +++ b/atest/testdata/variables/var_syntax.robot @@ -4,8 +4,14 @@ Scalar Should Be Equal ${name} value Scalar with separator - VAR ${name} a b c separator=- - Should Be Equal ${name} a-b-c + VAR ${a} ${1} 2 3 separator=\n + VAR ${b} 1 ${2} 3 separator==== + VAR ${c} 1 2 ${3} separator= + VAR ${d} ${a} ${b} ${c} separator=${0} + Should Be Equal ${a} 1\n2\n3 + Should Be Equal ${b} 1===2===3 + Should Be Equal ${c} 123 + Should Be Equal ${d} ${a}0${b}0${c} List VAR @{name} v1 v2 v3 @@ -102,6 +108,6 @@ Scopes Should Be Equal ${suite} suite Should Be Equal ${global} global VAR ${local3} local3 - VAR ${test} new-${test} scope=test + VAR ${test} new ${test} scope=${test} separator=${{'-'}} Should Be Equal ${local3} local3 Should Be Equal ${test} new-test diff --git a/doc/schema/robot.xsd b/doc/schema/robot.xsd index fca11940804..d4bc84e0153 100644 --- a/doc/schema/robot.xsd +++ b/doc/schema/robot.xsd @@ -72,6 +72,7 @@ + @@ -92,6 +93,7 @@ + @@ -145,6 +147,7 @@ + @@ -174,6 +177,7 @@ + @@ -206,6 +210,7 @@ + @@ -244,6 +249,7 @@ + @@ -252,6 +258,17 @@ + + + + + + + + + + + diff --git a/src/robot/htmldata/rebot/testdata.js b/src/robot/htmldata/rebot/testdata.js index 7ca4adcf1dc..06375b5899a 100644 --- a/src/robot/htmldata/rebot/testdata.js +++ b/src/robot/htmldata/rebot/testdata.js @@ -5,8 +5,9 @@ window.testdata = function () { var _statistics = null; var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FAIL', 'SKIP']; var STATUSES = ['FAIL', 'PASS', 'SKIP', 'NOT RUN']; - var KEYWORD_TYPES = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'ITERATION', 'IF', 'ELSE IF', 'ELSE', 'RETURN', - 'TRY', 'EXCEPT', 'FINALLY', 'WHILE', 'CONTINUE', 'BREAK', 'ERROR']; + var KEYWORD_TYPES = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'ITERATION', 'IF', + 'ELSE IF', 'ELSE', 'RETURN', 'VAR', 'TRY', 'EXCEPT', 'FINALLY', + 'WHILE', 'CONTINUE', 'BREAK', 'ERROR']; function addElement(elem) { if (!elem.id) diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index cf8f10275be..5cd574b638e 100644 --- a/src/robot/model/visitor.py +++ b/src/robot/model/visitor.py @@ -107,7 +107,7 @@ def visit_test(self, test: TestCase): if TYPE_CHECKING: from robot.model import (Break, BodyItem, Continue, Error, For, If, IfBranch, Keyword, Message, Return, TestCase, TestSuite, Try, - TryBranch, While) + TryBranch, Var, While) from robot.result import ForIteration, WhileIteration @@ -427,6 +427,28 @@ def end_while_iteration(self, iteration: 'WhileIteration'): """ self.end_body_item(iteration) + def visit_var(self, var: 'Var'): + """Visits a VAR elements.""" + if self.start_var(var) is not False: + self._possible_body(var) + self.end_var(var) + + def start_var(self, var: 'Var') -> 'bool|None': + """Called when a VAR element starts. + + By default, calls :meth:`start_body_item` which, by default, does nothing. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(var) + + def end_var(self, var: 'Var'): + """Called when a VAR element ends. + + By default, calls :meth:`end_body_item` which, by default, does nothing. + """ + self.end_body_item(var) + def visit_return(self, return_: 'Return'): """Visits a RETURN elements.""" if self.start_return(return_) is not False: diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index ab1c5f87552..7bd719ff755 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -272,6 +272,12 @@ def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): self.listener.end_keyword(ModelCombiner(data, result)) + def start_var(self, data, result): + self.listener.start_keyword(ModelCombiner(data, result)) + + def end_var(self, data, result): + self.listener.end_keyword(ModelCombiner(data, result)) + def start_break(self, data, result): self.listener.start_keyword(ModelCombiner(data, result)) diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index f3e9db3fea2..19a2efadc58 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -144,8 +144,7 @@ def register_logger(self, *loggers): def unregister_logger(self, *loggers): for logger in loggers: - self._other_loggers = [l for l in self._other_loggers - if l is not logger] + self._other_loggers = [l for l in self._other_loggers if l is not logger] def disable_message_cache(self): self._message_cache = None @@ -319,6 +318,16 @@ def end_try_branch(self, data, result): for logger in self.end_loggers: logger.end_try_branch(data, result) + @start_body_item + def start_var(self, data, result): + for logger in self.start_loggers: + logger.start_var(data, result) + + @end_body_item + def end_var(self, data, result): + for logger in self.end_loggers: + logger.end_var(data, result) + @start_body_item def start_break(self, data, result): for logger in self.start_loggers: diff --git a/src/robot/output/loggerapi.py b/src/robot/output/loggerapi.py index 8ca6d3b1433..2de050c70b6 100644 --- a/src/robot/output/loggerapi.py +++ b/src/robot/output/loggerapi.py @@ -68,6 +68,12 @@ def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): self.end_body_item(data, result) + def start_var(self, data: 'running.Var', result: 'result.Var'): + self.start_body_item(data, result) + + def end_var(self, data: 'running.Var', result: 'result.Var'): + self.end_body_item(data, result) + def start_break(self, data: 'running.Break', result: 'result.Break'): self.start_body_item(data, result) diff --git a/src/robot/output/output.py b/src/robot/output/output.py index ffe17767079..86718fda9e1 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -123,6 +123,12 @@ def start_try_branch(self, data, result): def end_try_branch(self, data, result): LOGGER.end_try_branch(data, result) + def start_var(self, data, result): + LOGGER.start_var(data, result) + + def end_var(self, data, result): + LOGGER.end_var(data, result) + def start_break(self, data, result): LOGGER.start_break(data, result) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 04b85ca506b..af5a6f315de 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -114,6 +114,12 @@ def start_try_branch(self, data, result): def end_try_branch(self, data, result): self.logger.end_try_branch(result) + def start_var(self, data, result): + self.logger.start_var(result) + + def end_var(self, data, result): + self.logger.end_var(result) + def start_break(self, data, result): self.logger.start_break(result) @@ -287,6 +293,20 @@ def end_while_iteration(self, iteration): self._write_status(iteration) self._writer.end('iter') + def start_var(self, var): + attr = {'name': var.name} + if var.scope is not None: + attr['scope'] = var.scope + if var.separator is not None: + attr['separator'] = var.separator + self._writer.start('variable', attr, write_empty=True) + for val in var.value: + self._writer.element('var', val) + + def end_var(self, var): + self._write_status(var) + self._writer.end('variable') + def start_return(self, return_): self._writer.start('return') for value in return_.values: @@ -449,6 +469,12 @@ def start_while_iteration(self, iteration): def end_while_iteration(self, iteration): pass + def start_var(self, var): + pass + + def end_var(self, var): + pass + def start_break(self, break_): pass diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index 07e75f461e3..a5b0572edd4 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -22,8 +22,8 @@ STATUSES = {'FAIL': 0, 'PASS': 1, 'SKIP': 2, 'NOT RUN': 3} KEYWORD_TYPES = {'KEYWORD': 0, 'SETUP': 1, 'TEARDOWN': 2, 'FOR': 3, 'ITERATION': 4, 'IF': 5, 'ELSE IF': 6, 'ELSE': 7, - 'RETURN': 8, 'TRY': 9, 'EXCEPT': 10, 'FINALLY': 11, 'WHILE': 12, - 'CONTINUE': 13, 'BREAK': 14, 'ERROR': 15} + 'RETURN': 8, 'VAR': 9, 'TRY': 10, 'EXCEPT': 11, 'FINALLY': 12, + 'WHILE': 13, 'CONTINUE': 14, 'BREAK': 15, 'ERROR': 16} class JsModelBuilder: diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 31642636c35..4b4295695d7 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -112,7 +112,8 @@ class TestHandler(ElementHandler): tag = 'test' # 'tags' is for RF < 4 compatibility. children = frozenset(('doc', 'tags', 'tag', 'timeout', 'status', 'kw', 'if', 'for', - 'try', 'while', 'return', 'break', 'continue', 'error', 'msg')) + 'try', 'while', 'variable', 'return', 'break', 'continue', + 'error', 'msg')) def start(self, elem, result): lineno = elem.get('line') @@ -127,7 +128,7 @@ class KeywordHandler(ElementHandler): # 'arguments', 'assign' and 'tags' are for RF < 4 compatibility. children = frozenset(('doc', 'arguments', 'arg', 'assign', 'var', 'tags', 'tag', 'timeout', 'status', 'msg', 'kw', 'if', 'for', 'try', - 'while', 'return', 'break', 'continue', 'error')) + 'while', 'variable', 'return', 'break', 'continue', 'error')) def start(self, elem, result): elem_type = elem.get('type') @@ -211,7 +212,7 @@ def start(self, elem, result): class IterationHandler(ElementHandler): tag = 'iter' children = frozenset(('var', 'doc', 'status', 'kw', 'if', 'for', 'msg', 'try', - 'while', 'return', 'break', 'continue', 'error')) + 'while', 'variable', 'return', 'break', 'continue', 'error')) def start(self, elem, result): return result.body.create_iteration() @@ -230,7 +231,7 @@ def start(self, elem, result): class BranchHandler(ElementHandler): tag = 'branch' children = frozenset(('status', 'kw', 'if', 'for', 'try', 'while', 'msg', 'doc', - 'return', 'pattern', 'break', 'continue', 'error')) + 'variable', 'return', 'pattern', 'break', 'continue', 'error')) def start(self, elem, result): if 'variable' in elem.attrib: # RF < 7.0 compatibility. @@ -256,10 +257,21 @@ def end(self, elem, result): result.patterns += (elem.text or '',) +@ElementHandler.register +class VariableHandler(ElementHandler): + tag = 'variable' + children = frozenset(('var', 'status', 'msg', 'kw')) + + def start(self, elem, result): + return result.body.create_var(name=elem.get('name', ''), + scope=elem.get('scope'), + separator=elem.get('separator')) + + @ElementHandler.register class ReturnHandler(ElementHandler): tag = 'return' - children = frozenset(('status', 'value', 'msg', 'kw')) + children = frozenset(('value', 'status', 'msg', 'kw')) def start(self, elem, result): return result.body.create_return() @@ -410,6 +422,8 @@ def end(self, elem, result): result.assign += (value,) elif result.type == result.ITERATION: result.assign[elem.get('name')] = value + elif result.type == result.VAR: + result.value += (value,) else: raise DataError(f"Invalid element '{elem}' for result '{result!r}'.") diff --git a/src/robot/running/context.py b/src/robot/running/context.py index b820ad2e884..650f01526c9 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -263,6 +263,7 @@ def start_body_item(self, data, result): result.TRY: self.output.start_try_branch, result.EXCEPT: self.output.start_try_branch, result.FINALLY: self.output.start_try_branch, + result.VAR: self.output.start_var, result.BREAK: self.output.start_break, result.CONTINUE: self.output.start_continue, result.RETURN: self.output.start_return, @@ -296,6 +297,7 @@ def end_body_item(self, data, result): result.TRY: self.output.end_try_branch, result.EXCEPT: self.output.end_try_branch, result.FINALLY: self.output.end_try_branch, + result.VAR: self.output.end_var, result.BREAK: self.output.end_break, result.CONTINUE: self.output.end_continue, result.RETURN: self.output.end_return, diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index c4d62761b93..473674e10b4 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -40,8 +40,7 @@ def __enter__(self): self.initial_test_status = context.test.status if context.test else None if not result.start_time: result.start_time = datetime.now() - if result.type != result.VAR: - context.start_body_item(self.data, result) + context.start_body_item(self.data, result) if result.type in result.KEYWORD_TYPES: self._warn_if_deprecated(result.doc, result.full_name) return self @@ -64,8 +63,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self.initial_test_status == 'PASS': context.test.status = result.status result.elapsed_time = datetime.now() - result.start_time - if result.type != result.VAR: - context.end_body_item(self.data, result) + context.end_body_item(self.data, result) if failure is not exc_val and not self.suppress: raise failure return self.suppress diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index 5a4dce5aac8..4b41e36aea2 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -217,10 +217,8 @@ def _convert_keywords(self, keywords): for kw in keywords: if not kw: continue - if kw.type == kw.SETUP: - yield self._convert_keyword(kw, 'SETUP') - elif kw.type == kw.TEARDOWN: - yield self._convert_keyword(kw, 'TEARDOWN') + if kw.type in kw.KEYWORD_TYPES: + yield self._convert_keyword(kw) elif kw.type == kw.FOR: yield self._convert_for(kw) elif kw.type == kw.WHILE: @@ -229,8 +227,8 @@ def _convert_keywords(self, keywords): yield from self._convert_if(kw) elif kw.type == kw.TRY_EXCEPT_ROOT: yield from self._convert_try(kw) - else: - yield self._convert_keyword(kw, 'KEYWORD') + elif kw.type == kw.VAR: + yield self._convert_var(kw) def _convert_for(self, data): name = '%s %s %s' % (', '.join(data.assign), data.flavor, @@ -256,9 +254,16 @@ def _convert_try(self, data): name = '' yield {'type': branch.type, 'name': name, 'arguments': ''} - def _convert_keyword(self, kw, kw_type): + def _convert_var(self, data): + if data.name[0] == '$' and len(data.value) == 1: + value = data.value[0] + else: + value = '[' + ', '.join(data.value) + ']' + return {'type': 'VAR', 'name': f'{data.name} = {value}'} + + def _convert_keyword(self, kw): return { - 'type': kw_type, + 'type': kw.type, 'name': self._escape(self._get_kw_name(kw)), 'arguments': self._escape(', '.join(kw.args)) } diff --git a/src/robot/utils/markupwriters.py b/src/robot/utils/markupwriters.py index 30c329b877d..cea7d321510 100644 --- a/src/robot/utils/markupwriters.py +++ b/src/robot/utils/markupwriters.py @@ -36,17 +36,18 @@ def __init__(self, output, write_empty=True, usage=None): def _preamble(self): pass - def start(self, name, attrs=None, newline=True): - attrs = self._format_attrs(attrs) + def start(self, name, attrs=None, newline=True, write_empty=None): + attrs = self._format_attrs(attrs, write_empty) self._start(name, attrs, newline) def _start(self, name, attrs, newline): self._write(f'<{name} {attrs}>' if attrs else f'<{name}>', newline) - def _format_attrs(self, attrs): + def _format_attrs(self, attrs, write_empty): if not attrs: return '' - write_empty = self._write_empty + if write_empty is None: + write_empty = self._write_empty return ' '.join(f"{name}=\"{attribute_escape(value or '')}\"" for name, value in self._order_attrs(attrs) if write_empty or value) @@ -64,9 +65,12 @@ def _escape(self, content): def end(self, name, newline=True): self._write(f'', newline) - def element(self, name, content=None, attrs=None, escape=True, newline=True): - attrs = self._format_attrs(attrs) - if self._write_empty or content or attrs: + def element(self, name, content=None, attrs=None, escape=True, newline=True, + write_empty=None): + attrs = self._format_attrs(attrs, write_empty) + if write_empty is None: + write_empty = self._write_empty + if write_empty or content or attrs: self._start(name, attrs, newline=False) self.content(content, escape) self.end(name, newline) @@ -98,15 +102,18 @@ def _preamble(self): def _escape(self, text): return xml_escape(text) - def element(self, name, content=None, attrs=None, escape=True, newline=True): + def element(self, name, content=None, attrs=None, escape=True, newline=True, + write_empty=None): if content: - super().element(name, content, attrs, escape, newline) + super().element(name, content, attrs, escape, newline, write_empty) else: - self._self_closing_element(name, attrs, newline) + self._self_closing_element(name, attrs, newline, write_empty) - def _self_closing_element(self, name, attrs, newline): - attrs = self._format_attrs(attrs) - if self._write_empty or attrs: + def _self_closing_element(self, name, attrs, newline, write_empty): + attrs = self._format_attrs(attrs, write_empty) + if write_empty is None: + write_empty = self._write_empty + if write_empty or attrs: self._write(f'<{name} {attrs}/>' if attrs else f'<{name}/>', newline) diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index d799f33fe2c..3485982a2d6 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -216,6 +216,19 @@ def test_for(self): name='${x} IN ENUMERATE a b start=1') self._verify_test(test, body=(f1, f2)) + def test_var(self): + test = TestSuite().tests.create() + test.body.create_var('${x}', value='x') + test.body.create_var('${y}', value=('x', 'y'), separator='', scope='test') + test.body.create_var('@{z}', value=('x', 'y'), scope='SUITE') + v1 = self._verify_body_item(test.body[0], type=9, + name='${x} x') + v2 = self._verify_body_item(test.body[1], type=9, + name='${y} x y separator= scope=test') + v3 = self._verify_body_item(test.body[2], type=9, + name='@{z} x y scope=SUITE') + self._verify_test(test, body=(v1, v2, v3)) + def test_message_directly_under_test(self): test = TestSuite().tests.create() test.body.create_message('Hi from test') From d9241815d70d10b47617d2d71f809436e66cdf5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 13 Oct 2023 16:47:40 +0300 Subject: [PATCH 0774/1592] refactor --- src/robot/running/context.py | 100 +++++++++++++++++------------------ 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 650f01526c9..8bee85012ad 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -238,70 +238,66 @@ def start_body_item(self, data, result): if len(self.steps) > self._started_keywords_threshold: raise DataError('Maximum limit of started keywords and control ' 'structures exceeded.') - if result.type == result.ELSE: + output = self.output + if result.type in (result.ELSE, result.ITERATION): method = { - result.IF_ELSE_ROOT: self.output.start_if_branch, - result.TRY_EXCEPT_ROOT: self.output.start_try_branch, - }[result.parent.type] - elif result.type == result.ITERATION: - method = { - result.FOR: self.output.start_for_iteration, - result.WHILE: self.output.start_while_iteration, + result.IF_ELSE_ROOT: output.start_if_branch, + result.TRY_EXCEPT_ROOT: output.start_try_branch, + result.FOR: output.start_for_iteration, + result.WHILE: output.start_while_iteration, }[result.parent.type] else: method = { - result.KEYWORD: self.output.start_keyword, - result.SETUP: self.output.start_keyword, - result.TEARDOWN: self.output.start_keyword, - result.FOR: self.output.start_for, - result.WHILE: self.output.start_while, - result.IF_ELSE_ROOT: self.output.start_if, - result.IF: self.output.start_if_branch, - result.ELSE: self.output.start_if_branch, - result.ELSE_IF: self.output.start_if_branch, - result.TRY_EXCEPT_ROOT: self.output.start_try, - result.TRY: self.output.start_try_branch, - result.EXCEPT: self.output.start_try_branch, - result.FINALLY: self.output.start_try_branch, - result.VAR: self.output.start_var, - result.BREAK: self.output.start_break, - result.CONTINUE: self.output.start_continue, - result.RETURN: self.output.start_return, - result.ERROR: self.output.start_error, + result.KEYWORD: output.start_keyword, + result.SETUP: output.start_keyword, + result.TEARDOWN: output.start_keyword, + result.FOR: output.start_for, + result.WHILE: output.start_while, + result.IF_ELSE_ROOT: output.start_if, + result.IF: output.start_if_branch, + result.ELSE: output.start_if_branch, + result.ELSE_IF: output.start_if_branch, + result.TRY_EXCEPT_ROOT: output.start_try, + result.TRY: output.start_try_branch, + result.EXCEPT: output.start_try_branch, + result.FINALLY: output.start_try_branch, + result.VAR: output.start_var, + result.BREAK: output.start_break, + result.CONTINUE: output.start_continue, + result.RETURN: output.start_return, + result.ERROR: output.start_error, }[result.type] method(data, result) def end_body_item(self, data, result): - if result.type == result.ELSE: - method = { - result.IF_ELSE_ROOT: self.output.end_if_branch, - result.TRY_EXCEPT_ROOT: self.output.end_try_branch, - }[result.parent.type] - elif result.type == result.ITERATION: + output = self.output + if result.type in (result.ELSE, result.ITERATION): method = { - result.FOR: self.output.end_for_iteration, - result.WHILE: self.output.end_while_iteration, + result.IF_ELSE_ROOT: output.end_if_branch, + result.TRY_EXCEPT_ROOT: output.end_try_branch, + result.FOR: output.end_for_iteration, + result.WHILE: output.end_while_iteration, }[result.parent.type] else: method = { - result.KEYWORD: self.output.end_keyword, - result.SETUP: self.output.end_keyword, - result.TEARDOWN: self.output.end_keyword, - result.FOR: self.output.end_for, - result.WHILE: self.output.end_while, - result.IF_ELSE_ROOT: self.output.end_if, - result.IF: self.output.end_if_branch, - result.ELSE: self.output.end_if_branch, - result.ELSE_IF: self.output.end_if_branch, - result.TRY_EXCEPT_ROOT: self.output.end_try, - result.TRY: self.output.end_try_branch, - result.EXCEPT: self.output.end_try_branch, - result.FINALLY: self.output.end_try_branch, - result.VAR: self.output.end_var, - result.BREAK: self.output.end_break, - result.CONTINUE: self.output.end_continue, - result.RETURN: self.output.end_return, - result.ERROR: self.output.end_error, + result.KEYWORD: output.end_keyword, + result.SETUP: output.end_keyword, + result.TEARDOWN: output.end_keyword, + result.FOR: output.end_for, + result.WHILE: output.end_while, + result.IF_ELSE_ROOT: output.end_if, + result.IF: output.end_if_branch, + result.ELSE: output.end_if_branch, + result.ELSE_IF: output.end_if_branch, + result.TRY_EXCEPT_ROOT: output.end_try, + result.TRY: output.end_try_branch, + result.EXCEPT: output.end_try_branch, + result.FINALLY: output.end_try_branch, + result.VAR: output.end_var, + result.BREAK: output.end_break, + result.CONTINUE: output.end_continue, + result.RETURN: output.end_return, + result.ERROR: output.end_error, }[result.type] method(data, result) self.steps.pop() From 5a17791e08a458f1ffb630906e5ee77326412e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 13 Oct 2023 17:55:47 +0300 Subject: [PATCH 0775/1592] Refactor buiding tests, keywords and controls. Introduce a base class that handles common body items. --- src/robot/running/builder/transformers.py | 426 +++++++--------------- 1 file changed, 122 insertions(+), 304 deletions(-) diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index b8b323cecdb..be2170f8b1a 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -21,7 +21,8 @@ from robot.variables import VariableIterator from .settings import FileSettings -from ..model import ResourceFile, TestSuite +from ..model import (For, If, IfBranch, ResourceFile, TestSuite, TestCase, Try, + TryBranch, UserKeyword, While) class SettingsBuilder(NodeVisitor): @@ -121,10 +122,10 @@ def visit_TestCaseSection(self, node): self.generic_visit(node) def visit_TestCase(self, node): - TestCaseBuilder(self.suite, self.settings).visit(node) + TestCaseBuilder(self.suite, self.settings).build(node) def visit_Keyword(self, node): - KeywordBuilder(self.suite.resource, self.settings).visit(node) + KeywordBuilder(self.suite.resource, self.settings).build(node) class ResourceBuilder(NodeVisitor): @@ -160,37 +161,81 @@ def visit_Variable(self, node): error=format_error(node.errors)) def visit_Keyword(self, node): - KeywordBuilder(self.resource, self.settings).visit(node) + KeywordBuilder(self.resource, self.settings).build(node) -class TestCaseBuilder(NodeVisitor): +class BodyBuilder(NodeVisitor): + + def __init__(self, model: 'TestCase|UserKeyword|For|If|Try|While|None' = None): + self.model = model + + def visit_For(self, node): + ForBuilder(self.model).build(node) + + def visit_While(self, node): + WhileBuilder(self.model).build(node) + + def visit_If(self, node): + IfBuilder(self.model).build(node) + + def visit_Try(self, node): + TryBuilder(self.model).build(node) + + def visit_KeywordCall(self, node): + self.model.body.create_keyword(name=node.keyword, args=node.args, + assign=node.assign, lineno=node.lineno) + + def visit_TemplateArguments(self, node): + self.model.body.create_keyword(args=node.args, lineno=node.lineno) + + def visit_Var(self, node): + self.model.body.create_var(node.name, node.value, node.scope, node.separator, + lineno=node.lineno, error=format_error(node.errors)) + + def visit_ReturnStatement(self, node): + self.model.body.create_return(node.values, lineno=node.lineno, + error=format_error(node.errors)) + + def visit_Continue(self, node): + self.model.body.create_continue(lineno=node.lineno, + error=format_error(node.errors)) + + def visit_Break(self, node): + self.model.body.create_break(lineno=node.lineno, + error=format_error(node.errors)) + + def visit_Error(self, node): + self.model.body.create_error(lineno=node.lineno, + values=node.values, error=format_error(node.errors)) + + +class TestCaseBuilder(BodyBuilder): + model: TestCase def __init__(self, suite: TestSuite, settings: FileSettings): - self.suite = suite + super().__init__(suite.tests.create()) self.settings = settings - self.test = None self._test_has_tags = False - def visit_TestCase(self, node): + def build(self, node): settings = self.settings # Possible parsing errors aren't reported further with tests because: # - We only validate that test body or name isn't empty. # - That is validated again during execution. # - This way e.g. model modifiers can add content to body. - self.test = self.suite.tests.create(name=node.name, - lineno=node.lineno, - tags=settings.test_tags, - timeout=settings.test_timeout, - template=settings.test_template) + self.model.config(name=node.name, tags=settings.test_tags, + timeout=settings.test_timeout, + template=settings.test_template, + lineno=node.lineno) if settings.test_setup: - self.test.setup.config(**settings.test_setup) + self.model.setup.config(**settings.test_setup) if settings.test_teardown: - self.test.teardown.config(**settings.test_teardown) + self.model.teardown.config(**settings.test_teardown) self.generic_visit(node) if not self._test_has_tags: - self.test.tags.add(settings.default_tags) - if self.test.template: - self._set_template(self.test, self.test.template) + self.model.tags.add(settings.default_tags) + if self.model.template: + self._set_template(self.model, self.model.template) def _set_template(self, parent, template): for item in parent.body: @@ -215,164 +260,89 @@ def _format_template(self, template, arguments): temp.append(after) return ''.join(temp), () - def visit_For(self, node): - ForBuilder(self.test).build(node) - - def visit_While(self, node): - WhileBuilder(self.test).build(node) - - def visit_If(self, node): - IfBuilder(self.test).build(node) - - def visit_Try(self, node): - TryBuilder(self.test).build(node) - - def visit_TemplateArguments(self, node): - self.test.body.create_keyword(args=node.args, lineno=node.lineno) - def visit_Documentation(self, node): - self.test.doc = node.value + self.model.doc = node.value def visit_Setup(self, node): - self.test.setup.config(name=node.name, args=node.args, lineno=node.lineno) + self.model.setup.config(name=node.name, args=node.args, lineno=node.lineno) def visit_Teardown(self, node): - self.test.teardown.config(name=node.name, args=node.args, lineno=node.lineno) + self.model.teardown.config(name=node.name, args=node.args, lineno=node.lineno) def visit_Timeout(self, node): - self.test.timeout = node.value + self.model.timeout = node.value def visit_Tags(self, node): for tag in node.values: if tag.startswith('-'): - self.test.tags.remove(tag[1:]) + self.model.tags.remove(tag[1:]) else: - self.test.tags.add(tag) + self.model.tags.add(tag) self._test_has_tags = True def visit_Template(self, node): - self.test.template = node.value - - def visit_KeywordCall(self, node): - self.test.body.create_keyword(name=node.keyword, args=node.args, - assign=node.assign, lineno=node.lineno) - - def visit_Var(self, node): - self.test.body.create_var(node.name, node.value, node.scope, node.separator, - lineno=node.lineno, error=format_error(node.errors)) - - def visit_ReturnStatement(self, node): - self.test.body.create_return(node.values, lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Continue(self, node): - self.test.body.create_continue(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Break(self, node): - self.test.body.create_break(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Error(self, node): - self.test.body.create_error(lineno=node.lineno, - values=node.values, error=format_error(node.errors)) + self.model.template = node.value -class KeywordBuilder(NodeVisitor): +class KeywordBuilder(BodyBuilder): + model: UserKeyword def __init__(self, resource: ResourceFile, settings: FileSettings): - self.resource = resource - self.settings = settings - self.kw = None + super().__init__(resource.keywords.create(tags=settings.keyword_tags)) - def visit_Keyword(self, node): + def build(self, node): # Possible parsing errors aren't reported further because: # - We only validate that keyword body or name isn't empty. # - That is validated again during execution. # - This way e.g. model modifiers can add content to body. - self.kw = self.resource.keywords.create(name=node.name, - tags=self.settings.keyword_tags, - lineno=node.lineno) + self.model.config(name=node.name, lineno=node.lineno) self.generic_visit(node) def visit_Documentation(self, node): - self.kw.doc = node.value + self.model.doc = node.value def visit_Arguments(self, node): - self.kw.args = node.values + self.model.args = node.values if node.errors: error = format_error(node.errors) - self.kw.error = f'Invalid argument specification: {error}' + self.model.error = f'Invalid argument specification: {error}' def visit_Tags(self, node): for tag in node.values: if tag.startswith('-'): - self.kw.tags.remove(tag[1:]) + self.model.tags.remove(tag[1:]) else: - self.kw.tags.add(tag) + self.model.tags.add(tag) def visit_Return(self, node): - ErrorReporter(self.resource.source).visit(node) - self.kw.return_ = node.values + ErrorReporter(self.model.source).visit(node) + self.model.return_ = node.values def visit_Timeout(self, node): - self.kw.timeout = node.value + self.model.timeout = node.value def visit_Setup(self, node): - self.kw.setup.config(name=node.name, args=node.args, lineno=node.lineno) + self.model.setup.config(name=node.name, args=node.args, lineno=node.lineno) def visit_Teardown(self, node): - self.kw.teardown.config(name=node.name, args=node.args, lineno=node.lineno) + self.model.teardown.config(name=node.name, args=node.args, lineno=node.lineno) def visit_KeywordCall(self, node): - self.kw.body.create_keyword(name=node.keyword, args=node.args, - assign=node.assign, lineno=node.lineno) - - def visit_Var(self, node): - self.kw.body.create_var(node.name, node.value, node.scope, node.separator, - lineno=node.lineno, error=format_error(node.errors)) - - def visit_ReturnStatement(self, node): - self.kw.body.create_return(node.values, lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Continue(self, node): - self.kw.body.create_continue(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Break(self, node): - self.kw.body.create_break(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_For(self, node): - ForBuilder(self.kw).build(node) - - def visit_While(self, node): - WhileBuilder(self.kw).build(node) - - def visit_If(self, node): - IfBuilder(self.kw).build(node) - - def visit_Try(self, node): - TryBuilder(self.kw).build(node) - - def visit_Error(self, node): - self.kw.body.create_error(lineno=node.lineno, - values=node.values, error=format_error(node.errors)) + self.model.body.create_keyword(name=node.keyword, args=node.args, + assign=node.assign, lineno=node.lineno) -class ForBuilder(NodeVisitor): +class ForBuilder(BodyBuilder): + model: For - def __init__(self, parent): - self.parent = parent - self.model = None + def __init__(self, parent: 'TestCase|UserKeyword|For|If|Try|While'): + super().__init__(parent.body.create_for()) def build(self, node): error = format_error(self._get_errors(node)) - self.model = self.parent.body.create_for( - node.assign, node.flavor, node.values, node.start, node.mode, node.fill, - lineno=node.lineno, error=error - ) + self.model.config(assign=node.assign, flavor=node.flavor, values=node.values, + start=node.start, mode=node.mode, fill=node.fill, + lineno=node.lineno, error=error) for step in node.body: self.visit(step) return self.model @@ -383,62 +353,22 @@ def _get_errors(self, node): errors += node.end.errors return errors - def visit_KeywordCall(self, node): - self.model.body.create_keyword(name=node.keyword, args=node.args, - assign=node.assign, lineno=node.lineno) - - def visit_TemplateArguments(self, node): - self.model.body.create_keyword(args=node.args, lineno=node.lineno) - - def visit_Var(self, node): - self.model.body.create_var(node.name, node.value, node.scope, node.separator, - lineno=node.lineno, error=format_error(node.errors)) - - def visit_For(self, node): - ForBuilder(self.model).build(node) - - def visit_While(self, node): - WhileBuilder(self.model).build(node) - - def visit_If(self, node): - IfBuilder(self.model).build(node) - def visit_Try(self, node): - TryBuilder(self.model).build(node) +class IfBuilder(BodyBuilder): + model: 'IfBranch|None' - def visit_ReturnStatement(self, node): - self.model.body.create_return(node.values, lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Continue(self, node): - self.model.body.create_continue(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Break(self, node): - self.model.body.create_break(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Error(self, node): - self.model.body.create_error(lineno=node.lineno, - values=node.values, - error=format_error(node.errors)) - - -class IfBuilder(NodeVisitor): - - def __init__(self, parent): - self.parent = parent - self.model = None + def __init__(self, parent: 'TestCase|UserKeyword|For|If|Try|While'): + super().__init__() + self.root = parent.body.create_if() def build(self, node): - root = self.parent.body.create_if(lineno=node.lineno, - error=format_error(self._get_errors(node))) + self.root.config(lineno=node.lineno, error=format_error(self._get_errors(node))) assign = node.assign node_type = None while node: node_type = node.type if node.type != 'INLINE IF' else 'IF' - self.model = root.body.create_branch(node_type, node.condition, - lineno=node.lineno) + self.model = self.root.body.create_branch(node_type, node.condition, + lineno=node.lineno) for step in node.body: self.visit(step) if assign: @@ -450,10 +380,10 @@ def build(self, node): node = node.orelse # Smallish hack to make sure assignment is always run. if assign and node_type != 'ELSE': - root.body.create_branch('ELSE').body.create_keyword( + self.root.body.create_branch('ELSE').body.create_keyword( assign=assign, name='BuiltIn.Set Variable', args=['${NONE}'] ) - return root + return self.root def _get_errors(self, node): errors = node.header.errors + node.errors @@ -463,68 +393,30 @@ def _get_errors(self, node): errors += node.end.errors return errors - def visit_KeywordCall(self, node): - self.model.body.create_keyword(name=node.keyword, args=node.args, - assign=node.assign, lineno=node.lineno) - def visit_TemplateArguments(self, node): - self.model.body.create_keyword(args=node.args, lineno=node.lineno) +class TryBuilder(BodyBuilder): + model: 'TryBranch|None' - def visit_Var(self, node): - self.model.body.create_var(node.name, node.value, node.scope, node.separator, - lineno=node.lineno, error=format_error(node.errors)) - - def visit_For(self, node): - ForBuilder(self.model).build(node) - - def visit_While(self, node): - WhileBuilder(self.model).build(node) - - def visit_If(self, node): - IfBuilder(self.model).build(node) - - def visit_Try(self, node): - TryBuilder(self.model).build(node) - - def visit_ReturnStatement(self, node): - self.model.body.create_return(node.values, lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Continue(self, node): - self.model.body.create_continue(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Break(self, node): - self.model.body.create_break(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Error(self, node): - self.model.body.create_error(lineno=node.lineno, - values=node.values, error=format_error(node.errors)) - - -class TryBuilder(NodeVisitor): - - def __init__(self, parent): - self.parent = parent - self.model = None + def __init__(self, parent: 'TestCase|UserKeyword|For|If|Try|While'): + super().__init__() + self.root = parent.body.create_try() self.template_error = None def build(self, node): - root = self.parent.body.create_try(lineno=node.lineno) + self.root.config(lineno=node.lineno) errors = self._get_errors(node) while node: - self.model = root.body.create_branch(node.type, node.patterns, - node.pattern_type, node.assign, - lineno=node.lineno) + self.model = self.root.body.create_branch(node.type, node.patterns, + node.pattern_type, node.assign, + lineno=node.lineno) for step in node.body: self.visit(step) node = node.next if self.template_error: errors += (self.template_error,) if errors: - root.error = format_error(errors) - return root + self.root.error = format_error(errors) + return self.root def _get_errors(self, node): errors = node.header.errors + node.errors @@ -534,58 +426,21 @@ def _get_errors(self, node): errors += node.end.errors return errors - def visit_For(self, node): - ForBuilder(self.model).build(node) - - def visit_While(self, node): - WhileBuilder(self.model).build(node) - - def visit_If(self, node): - IfBuilder(self.model).build(node) - - def visit_Try(self, node): - TryBuilder(self.model).build(node) - - def visit_ReturnStatement(self, node): - self.model.body.create_return(node.values, lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Continue(self, node): - self.model.body.create_continue(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Break(self, node): - self.model.body.create_break(lineno=node.lineno, - error=format_error(node.errors)) - - def visit_KeywordCall(self, node): - self.model.body.create_keyword(name=node.keyword, args=node.args, - assign=node.assign, lineno=node.lineno) - - def visit_Var(self, node): - self.model.body.create_var(node.name, node.value, node.scope, node.separator, - lineno=node.lineno, error=format_error(node.errors)) - def visit_TemplateArguments(self, node): self.template_error = 'Templates cannot be used with TRY.' - def visit_Error(self, node): - self.model.body.create_error(lineno=node.lineno, - values=node.values, error=format_error(node.errors)) - -class WhileBuilder(NodeVisitor): +class WhileBuilder(BodyBuilder): + model: While - def __init__(self, parent): - self.parent = parent - self.model = None + def __init__(self, parent: 'TestCase|UserKeyword|For|If|Try|While'): + super().__init__(parent.body.create_while()) def build(self, node): error = format_error(self._get_errors(node)) - self.model = self.parent.body.create_while( - node.condition, node.limit, node.on_limit, - node.on_limit_message, lineno=node.lineno, error=error - ) + self.model.config(condition=node.condition, limit=node.limit, + on_limit=node.on_limit, on_limit_message=node.on_limit_message, + lineno=node.lineno, error=error) for step in node.body: self.visit(step) return self.model @@ -596,43 +451,6 @@ def _get_errors(self, node): errors += node.end.errors return errors - def visit_KeywordCall(self, node): - self.model.body.create_keyword(name=node.keyword, args=node.args, - assign=node.assign, lineno=node.lineno) - - def visit_Var(self, node): - self.model.body.create_var(node.name, node.value, node.scope, node.separator, - lineno=node.lineno, error=format_error(node.errors)) - - def visit_TemplateArguments(self, node): - self.model.body.create_keyword(args=node.args, lineno=node.lineno) - - def visit_For(self, node): - ForBuilder(self.model).build(node) - - def visit_While(self, node): - WhileBuilder(self.model).build(node) - - def visit_If(self, node): - IfBuilder(self.model).build(node) - - def visit_Try(self, node): - TryBuilder(self.model).build(node) - - def visit_ReturnStatement(self, node): - self.model.body.create_return(node.values, lineno=node.lineno, - error=format_error(node.errors)) - - def visit_Break(self, node): - self.model.body.create_break(error=format_error(node.errors)) - - def visit_Continue(self, node): - self.model.body.create_continue(error=format_error(node.errors)) - - def visit_Error(self, node): - self.model.body.create_error(lineno=node.lineno, - values=node.values, error=format_error(node.errors)) - def format_error(errors): if not errors: From ae888028bfa05b92bc0e81b3af9c6fa41677a63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 13 Oct 2023 18:41:46 +0300 Subject: [PATCH 0776/1592] Add copyrights, formatting --- src/robot/output/loggerapi.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/robot/output/loggerapi.py b/src/robot/output/loggerapi.py index 2de050c70b6..9c4bb69ec45 100644 --- a/src/robot/output/loggerapi.py +++ b/src/robot/output/loggerapi.py @@ -1,3 +1,18 @@ +# 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 typing import TYPE_CHECKING if TYPE_CHECKING: @@ -6,13 +21,17 @@ class LoggerApi: - def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): pass + def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + pass - def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): pass + def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + pass - def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): pass + def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + pass - def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): pass + def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + pass def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): self.start_body_item(data, result) From 8f90e9614561b1cf9920194bd3460d99bf82216d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 13 Oct 2023 18:45:31 +0300 Subject: [PATCH 0777/1592] Enhance utils. Support `allow_nested` also with higher level `is_assign` methods, not only with `VariableMatch.is_assign`. --- src/robot/variables/search.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/robot/variables/search.py b/src/robot/variables/search.py index 6453c435f19..1bc722a717e 100644 --- a/src/robot/variables/search.py +++ b/src/robot/variables/search.py @@ -47,21 +47,25 @@ def is_dict_variable(string): return is_variable(string, '&') -def is_assign(string, identifiers='$@&', allow_assign_mark=False, allow_items=False): +def is_assign(string, identifiers='$@&', allow_assign_mark=False, + allow_nested=False, allow_items=False): match = search_variable(string, identifiers, ignore_errors=True) - return match.is_assign(allow_assign_mark, allow_items=allow_items) + return match.is_assign(allow_assign_mark, allow_nested, allow_items) -def is_scalar_assign(string, allow_assign_mark=False, allow_items=False): - return is_assign(string, '$', allow_assign_mark, allow_items) +def is_scalar_assign(string, allow_assign_mark=False, allow_nested=False, + allow_items=False): + return is_assign(string, '$', allow_assign_mark, allow_nested, allow_items) -def is_list_assign(string, allow_assign_mark=False, allow_items=False): - return is_assign(string, '@', allow_assign_mark, allow_items) +def is_list_assign(string, allow_assign_mark=False, allow_nested=False, + allow_items=False): + return is_assign(string, '@', allow_assign_mark, allow_nested, allow_items) -def is_dict_assign(string, allow_assign_mark=False, allow_items=False): - return is_assign(string, '&', allow_assign_mark, allow_items) +def is_dict_assign(string, allow_assign_mark=False, allow_nested=False, + allow_items=False): + return is_assign(string, '&', allow_assign_mark, allow_nested, allow_items) class VariableMatch: @@ -114,8 +118,7 @@ def is_list_variable(self): def is_dict_variable(self): return self.identifier == '&' and self.is_variable() - def is_assign(self, - allow_assign_mark=False, allow_nested=False, allow_items=False): + def is_assign(self, allow_assign_mark=False, allow_nested=False, allow_items=False): if allow_assign_mark and self.string.endswith('='): match = search_variable(self.string[:-1].rstrip(), ignore_errors=True) return match.is_assign(allow_items=allow_items) From 530d6fbc0e8d6f61b9364d5039b9b90a3f01b991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 13 Oct 2023 18:57:59 +0300 Subject: [PATCH 0778/1592] Add `Var` to remaining generic body classes. Interestingly the issue only caused problems with Python 3.8. --- src/robot/model/control.py | 4 ++-- src/robot/result/model.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/model/control.py b/src/robot/model/control.py index 3eba804ccc2..f843b91d2c1 100644 --- a/src/robot/model/control.py +++ b/src/robot/model/control.py @@ -28,8 +28,8 @@ IT = TypeVar('IT', bound='IfBranch|TryBranch') -class Branches(BaseBranches['Keyword', 'For', 'While', 'If', 'Try', 'Return', 'Continue', - 'Break', 'Message', 'Error', IT]): +class Branches(BaseBranches['Keyword', 'For', 'While', 'If', 'Try', 'Var', 'Return', + 'Continue', 'Break', 'Message', 'Error', IT]): pass diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 28edb706c4a..174220de579 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -74,7 +74,7 @@ class IterationType(Generic[FW]): pass -class Iterations(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Return', +class Iterations(model.BaseBody['Keyword', 'For', 'While', 'If', 'Try', 'Var', 'Return', 'Continue', 'Break', 'Message', 'Error'], IterationType[FW]): __slots__ = ['iteration_class'] iteration_type: Type[FW] = KnownAtRuntime From e2e74db8233ed35f04f6ab7cba97d36db919593e Mon Sep 17 00:00:00 2001 From: Topi 'top1' Tuulensuu Date: Sat, 14 Oct 2023 02:37:42 +0300 Subject: [PATCH 0779/1592] Fix performance regression with `Run Keyword` We are not using this runner for anything here, so let's not gather recommendations on failure. This is a pretty big performance hit for no apparent benefit. We have to introduce `recommend_on_failure` variable to _ExecutionContext's get_runner(), but we can keep the default behaviour the same. Fixes #4659. --- src/robot/libraries/BuiltIn.py | 2 +- src/robot/running/context.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index f71ccf38a1d..f86cc7049e9 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1877,7 +1877,7 @@ def run_keyword(self, name, *args): def _accepts_embedded_arguments(self, name, ctx): if '{' in name: - runner = ctx.get_runner(name) + runner = ctx.get_runner(name, recommend_on_failure=False) return runner and hasattr(runner, 'embedded_args') return False diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 8bee85012ad..ae4adf6b13a 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -302,8 +302,8 @@ def end_body_item(self, data, result): method(data, result) self.steps.pop() - def get_runner(self, name): - return self.namespace.get_runner(name) + def get_runner(self, name, recommend_on_failure=True): + return self.namespace.get_runner(name, recommend_on_failure) def trace(self, message): self.output.trace(message) From ba83819c0bda7ea8db38afb91a754a994631429d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 16 Oct 2023 21:58:09 +0300 Subject: [PATCH 0780/1592] whitespace --- atest/robot/variables/variable_section.robot | 4 +- .../testdata/variables/variable_section.robot | 60 +++++++++---------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/atest/robot/variables/variable_section.robot b/atest/robot/variables/variable_section.robot index b15c8f42f86..49e60467a28 100644 --- a/atest/robot/variables/variable_section.robot +++ b/atest/robot/variables/variable_section.robot @@ -1,6 +1,6 @@ *** Settings *** -Suite Setup Run tests ${EMPTY} variables/variable_section.robot -Resource atest_resource.robot +Suite Setup Run tests ${EMPTY} variables/variable_section.robot +Resource atest_resource.robot *** Test Cases *** Scalar String diff --git a/atest/testdata/variables/variable_section.robot b/atest/testdata/variables/variable_section.robot index 233274ae985..af3084a7a22 100644 --- a/atest/testdata/variables/variable_section.robot +++ b/atest/testdata/variables/variable_section.robot @@ -1,45 +1,45 @@ *** Variables *** -${STRING} Hello world! -${INTEGER} ${42} -${FLOAT} ${-1.2} -${BOOLEAN} ${True} -${NONE VALUE} ${None} -${ESCAPES} one \\ two \\\\ \${non_existing} -${SPACE ESC} \ 1 leading, \ 2 middle, 3 trailing \ \ \ -${NO VALUE} ${EMPTY} -@{ONE ITEM} Hello again? -@{LIST} Hello again ? +${STRING} Hello world! +${INTEGER} ${42} +${FLOAT} ${-1.2} +${BOOLEAN} ${True} +${NONE VALUE} ${None} +${ESCAPES} one \\ two \\\\ \${non_existing} +${SPACE ESC} \ 1 leading, \ 2 middle, 3 trailing \ \ \ +${NO VALUE} ${EMPTY} +@{ONE ITEM} Hello again? +@{LIST} Hello again ? @{LIST WITH ESCAPES} one \\ two \\\\ three \\\\\\ \${non_existing} @{LIST CREATED FROM LIST WITH ESCAPES} @{LIST WITH ESCAPES} -@{SPACE ESC LIST} \ lead trail \ \ \ 2 \ \ \ \ \ 3 \ \ \ +@{SPACE ESC LIST} \ lead trail \ \ \ 2 \ \ \ \ \ 3 \ \ \ @{EMPTY LIST} -Invalid Name Decoration missing -${} Body missing -${not closed -${not}[ok] This is variable but not valid assign -${not ${ok}} This is variable but not valid assign -${lowercase} Variable name in lower case -@{lowercaselist} Variable name in lower case -${S P a c e s } Variable name with spaces +Invalid Name Decoration missing +${} Body missing +${not closed +${not}[ok] This is variable but not valid assign +${not ${ok}} This is variable but not valid assign +${lowercase} Variable name in lower case +@{lowercaselist} Variable name in lower case +${S P a c e s } Variable name with spaces @{s P a c es li S T} Variable name with spaces -${UNDER_scores} Variable name with under scores +${UNDER_scores} Variable name with under scores @{_u_n_d_e_r___s_c_o_r_e_s__li_ST} Variable name with under scores -${ASSING MARK} = This syntax works starting from 1.8 -@{ASSIGN MARK LIST}= This syntax works starting from ${1.8} -${THREE DOTS} ... -@{3DOTS LIST} ... ... +${ASSING MARK} = This syntax works starting from 1.8 +@{ASSIGN MARK LIST}= This syntax works starting from ${1.8} +${THREE DOTS} ... +@{3DOTS LIST} ... ... ${CATENATED} By default values are joined with a space ${SEPARATOR VALUE} SEPARATOR=- Special SEPARATOR marker as ${1} st value ${SEPARATOR OPTION} Explicit separator option works since RF ${7.0} separator=- ${BOTH SEPARATORS} SEPARATOR=marker has lower precedence than option separator=: -${NONEX 1} Creating variable based on ${NON EXISTING} variable fails. -${NONEX 2A} This ${NON EX} is used for creating another variable. -${NONEX 2B} ${NONEX 2A} -${NONEX 3} This ${NON EXISTING VARIABLE} is used in imports. +${NONEX 1} Creating variable based on ${NON EXISTING} variable fails. +${NONEX 2A} This ${NON EX} is used for creating another variable. +${NONEX 2B} ${NONEX 2A} +${NONEX 3} This ${NON EXISTING VARIABLE} is used in imports. *** Settings *** -Resource ${NONEX 3} -Library ${NONEX 3} +Resource ${NONEX 3} +Library ${NONEX 3} *** Test Cases *** Scalar String From 306534424ef3ed719709f21b822d49bf0230bb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 16 Oct 2023 22:24:34 +0300 Subject: [PATCH 0781/1592] Cleanup - f-strings - typo fix - super - whitespace --- src/robot/variables/assigner.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/robot/variables/assigner.py b/src/robot/variables/assigner.py index 395f9a1edcc..2b62ecc90f9 100644 --- a/src/robot/variables/assigner.py +++ b/src/robot/variables/assigner.py @@ -105,7 +105,7 @@ def __exit__(self, etype, error, tb): def assign(self, return_value): context = self._context - context.output.trace(lambda: 'Return: %s' % prepr(return_value), + context.output.trace(lambda: f'Return: {prepr(return_value)}', write_if_flat=False) resolver = ReturnValueResolver(self._assignment) for name, items, value in resolver.resolve(return_value): @@ -120,7 +120,7 @@ def _extended_assign(self, name, value, variables): return False base, attr = [token.strip() for token in name[2:-1].rsplit('.', 1)] try: - var = variables.replace_scalar('${%s}' % base) + var = variables.replace_scalar(f'${{{base}}}') except VariableError: return False if not (self._variable_supports_extended_assign(var) and @@ -128,9 +128,9 @@ def _extended_assign(self, name, value, variables): return False try: setattr(var, attr, value) - except: - raise VariableError("Setting attribute '%s' to variable '${%s}' failed: %s" - % (attr, base, get_error_message())) + except Exception: + raise VariableError(f"Setting attribute '{attr}' to variable '${{{base}}}' " + f"failed: {get_error_message()}") return True def _variable_supports_extended_assign(self, var): @@ -172,14 +172,12 @@ def _item_assign(self, name, items, value, variables): *nested, item = items decorated_nested_items = ''.join(f'[{item}]' for item in nested) var = variables.replace_scalar(f'${name[1:]}{decorated_nested_items}') - if not self._variable_type_supports_item_assign(var): var_type = type_name(var) raise VariableError( f"Variable '{name}{decorated_nested_items}' is {var_type} " f"and does not support item assignment." ) - selector = variables.replace_scalar(item) if isinstance(var, MutableSequence): try: @@ -239,8 +237,8 @@ class _MultiReturnValueResolver: def __init__(self, assignments): self._names = [] self._items = [] - for assigment in assignments: - match: VariableMatch = search_variable(assigment) + for assign in assignments: + match: VariableMatch = search_variable(assign) self._names.append(match.name) self._items.append(match.items) self._min_count = len(assignments) @@ -261,10 +259,10 @@ def _convert_to_list(self, return_value): self._raise_expected_list(return_value) def _raise_expected_list(self, ret): - self._raise('Expected list-like value, got %s.' % type_name(ret)) + self._raise(f'Expected list-like value, got {type_name(ret)}.') def _raise(self, error): - raise VariableError('Cannot set variables: %s' % error) + raise VariableError(f'Cannot set variables: {error}') def _validate(self, return_count): raise NotImplementedError @@ -277,8 +275,7 @@ class ScalarsOnlyReturnValueResolver(_MultiReturnValueResolver): def _validate(self, return_count): if return_count != self._min_count: - self._raise('Expected %d return values, got %d.' - % (self._min_count, return_count)) + self._raise(f'Expected {self._min_count} return values, got {return_count}.') def _resolve(self, return_value): return list(zip(self._names, self._items, return_value)) @@ -287,18 +284,17 @@ def _resolve(self, return_value): class ScalarsAndListReturnValueResolver(_MultiReturnValueResolver): def __init__(self, assignments): - _MultiReturnValueResolver.__init__(self, assignments) + super().__init__(assignments) self._min_count -= 1 def _validate(self, return_count): if return_count < self._min_count: - self._raise('Expected %d or more return values, got %d.' - % (self._min_count, return_count)) + self._raise(f'Expected {self._min_count} or more return values, ' + f'got {return_count}.') def _resolve(self, return_value): list_index = [a[0][0] for a in self._names].index('@') list_len = len(return_value) - len(self._names) + 1 - elements_before_list = list(zip( self._names[:list_index], self._items[:list_index], @@ -314,5 +310,4 @@ def _resolve(self, return_value): self._items[list_index], return_value[list_index:list_index+list_len], )] - return elements_before_list + list_elements + elements_after_list From c7e7a484c479a9eb0361072d91744c0756f2bdcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 18 Oct 2023 00:47:21 +0300 Subject: [PATCH 0782/1592] Fix showing RETURN values in the log file. This was broken recently in a refactor so that RETURN value was shows a string representation of a tuple. Before that values were joined together with a comma. After this fix the separator is four spaces to be in line with #4900. --- src/robot/reporting/jsmodelbuilders.py | 2 +- utest/reporting/test_jsmodelbuilders.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index a5b0572edd4..0f0226ef553 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -154,7 +154,7 @@ def build(self, item, split=False): if isinstance (item, Keyword): return self._build_keyword(item, split) if isinstance(item, (Return, Error)): - return self._build(item, args=item.values, split=split) + return self._build(item, args=' '.join(item.values), split=split) return self._build(item, item._log_name, split=split) def _build_keyword(self, kw: Keyword, split): diff --git a/utest/reporting/test_jsmodelbuilders.py b/utest/reporting/test_jsmodelbuilders.py index 3485982a2d6..62ee199ec41 100644 --- a/utest/reporting/test_jsmodelbuilders.py +++ b/utest/reporting/test_jsmodelbuilders.py @@ -216,6 +216,13 @@ def test_for(self): name='${x} IN ENUMERATE a b start=1') self._verify_test(test, body=(f1, f2)) + def test_return(self): + self._verify_body_item(Keyword().body.create_return(), type=8) + self._verify_body_item(Keyword().body.create_return(('only one value',)), + type=8, args='only one value') + self._verify_body_item(Keyword().body.create_return(('more', 'than', 'one')), + type=8, args='more than one') + def test_var(self): test = TestSuite().tests.create() test.body.create_var('${x}', value='x') @@ -264,13 +271,13 @@ def _verify_test(self, test, name='', doc='', tags=(), timeout='', return self._build_and_verify(TestBuilder, test, name, timeout, doc, tags, status, body) - def _verify_body_item(self, keyword, type=0, name='', owner='', doc='', + def _verify_body_item(self, item, type=0, name='', owner='', doc='', args='', assign='', tags='', timeout='', status=0, start=None, elapsed=0, message='', body=()): status = (status, start, elapsed, message) \ if message else (status, start, elapsed) doc = f'

{doc}

' if doc else '' - return self._build_and_verify(BodyItemBuilder, keyword, type, name, owner, + return self._build_and_verify(BodyItemBuilder, item, type, name, owner, timeout, doc, args, assign, tags, status, body) def _verify_message(self, msg, message='', level=2, timestamp=None): From cb7cc9f6232379fbdcd50ece4333fd2b31c652e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 18 Oct 2023 14:43:48 +0300 Subject: [PATCH 0783/1592] Refactor and cleanup --- .../core/resource_and_variable_imports.robot | 4 +- .../getting_vars_from_dynamic_var_file.robot | 12 +- .../list_and_dict_from_variable_file.robot | 8 +- .../commandline_variable_files.robot | 35 ++--- .../dynamic_variable_files/dyn_vars.py | 6 +- .../getting_vars_from_dynamic_var_file.robot | 30 ++-- .../variables/resvarfiles/variables.py | 5 +- .../variables_from_variable_files.robot | 3 +- src/robot/variables/filesetter.py | 132 ++++++++---------- 9 files changed, 115 insertions(+), 120 deletions(-) diff --git a/atest/robot/core/resource_and_variable_imports.robot b/atest/robot/core/resource_and_variable_imports.robot index fa1157ff8bc..89200c238e0 100644 --- a/atest/robot/core/resource_and_variable_imports.robot +++ b/atest/robot/core/resource_and_variable_imports.robot @@ -39,7 +39,7 @@ Invalid List Variable ${path} = Normalize Path ${RESDIR}/invalid_list_variable.py Error in file 14 ${DATAFILE} 43 ... Processing variable file '${path}' failed: - ... Invalid variable '\@{invalid_list}': Expected list-like value, got string. + ... Invalid variable 'LIST__invalid_list': Expected a list-like value, got string. Dynamic Variable File Check Test Case ${TEST NAME} With No Args @@ -52,7 +52,7 @@ Invalid return value from dynamic variable file ${path} = Normalize Path ${RESDIR}/dynamic_variables.py Error in file 4 ${DATAFILE} 10 ... Processing variable file '${path}' with arguments [ Two args | returns invalid ] failed: - ... Expected 'get_variables' to return dict-like value, got None. + ... Expected 'get_variables' to return a dictionary-like value, got None. ... pattern=False Dynamic variable file raises exception diff --git a/atest/robot/variables/getting_vars_from_dynamic_var_file.robot b/atest/robot/variables/getting_vars_from_dynamic_var_file.robot index 82c57a4dec9..6a8a500c0cf 100644 --- a/atest/robot/variables/getting_vars_from_dynamic_var_file.robot +++ b/atest/robot/variables/getting_vars_from_dynamic_var_file.robot @@ -1,19 +1,19 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot +Suite Setup Run Tests ${EMPTY} variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot Resource atest_resource.robot *** Test Cases *** Variables From Dict Should Be Loaded - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Variables From My Dict Should Be Loaded - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Variables From Mapping Should Be Loaded - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Variables From UserDict Should Be Loaded - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Variables From My UserDict Should Be Loaded - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} diff --git a/atest/robot/variables/list_and_dict_from_variable_file.robot b/atest/robot/variables/list_and_dict_from_variable_file.robot index db79be51706..eb522a4e1e5 100644 --- a/atest/robot/variables/list_and_dict_from_variable_file.robot +++ b/atest/robot/variables/list_and_dict_from_variable_file.robot @@ -22,15 +22,15 @@ Invalid list Check Test Case ${TESTNAME} Verify Error 0 3 ... [ LIST__inv_list | not a list ] - ... \@{inv_list} - ... Expected list-like value, got string. + ... LIST__inv_list + ... Expected a list-like value, got string. Invalid dict Check Test Case ${TESTNAME} Verify Error 1 4 ... [ DICT__inv_dict | ['1', '2', 3] ] - ... \&{inv_dict} - ... Expected dict-like value, got list. + ... DICT__inv_dict + ... Expected a dictionary-like value, got list. Scalar list likes can be used as list Check Test Case ${TESTNAME} diff --git a/atest/testdata/variables/commandline_variable_files.robot b/atest/testdata/variables/commandline_variable_files.robot index ea83473aaec..a33c3005fbb 100644 --- a/atest/testdata/variables/commandline_variable_files.robot +++ b/atest/testdata/variables/commandline_variable_files.robot @@ -1,34 +1,35 @@ *** Variables *** -@{EXPECTED LIST} List variable value +@{EXPECTED LIST} List variable value +@{ANOTHER EXPECTED LIST} List variable from CLI var file with get_variables *** Test Cases *** Variables From Variable File - Should Be Equal ${SCALAR} Scalar from variable file from CLI - Should Be Equal ${SCALAR WITH ESCAPES} 1 \\ 2\\\\ \${inv} - Should Be Equal ${SCALAR LIST} ${EXPECTED LIST} - Should Be True @{LIST} == ${EXPECTED LIST} + Should Be Equal ${SCALAR} Scalar from variable file from CLI + Should Be Equal ${SCALAR WITH ESCAPES} 1 \\ 2\\\\ \${inv} + Should Be Equal ${SCALAR LIST} ${EXPECTED LIST} + Should Be Equal ${LIST} ${EXPECTED LIST} Arguments To Variable Files - Should Be Equal ${ANOTHER SCALAR} Variable from CLI var file with get_variables - Should Be True @{ANOTHER LIST} == ['List variable from CLI var file', 'with get_variables'] - Should Be Equal ${ARG} default value - Should Be Equal ${ARG 2} value;with;semi;colons + Should Be Equal ${ANOTHER SCALAR} Variable from CLI var file with get_variables + Should Be True ${ANOTHER LIST} ${ANOTHER EXPECTED LIST} + Should Be Equal ${ARG} default value + Should Be Equal ${ARG 2} value;with;semi;colons Arguments To Variable Files Using Semicolon Separator - Should Be Equal ${SEMICOLON} separator - Should Be Equal ${SEMI:COLON} separator:with:colons + Should Be Equal ${SEMICOLON} separator + Should Be Equal ${SEMI:COLON} separator:with:colons Variable File From PYTHONPATH - Should Be Equal ${PYTHONPATH VAR 0} Varfile found from PYTHONPATH - Should Be Equal ${PYTHONPATH ARGS 0} ${EMPTY} + Should Be Equal ${PYTHONPATH VAR 0} Varfile found from PYTHONPATH + Should Be Equal ${PYTHONPATH ARGS 0} ${EMPTY} Variable File From PYTHONPATH with arguments - Should Be Equal ${PYTHONPATH VAR 3} Varfile found from PYTHONPATH - Should Be Equal ${PYTHONPATH ARGS 3} 1-2-3 + Should Be Equal ${PYTHONPATH VAR 3} Varfile found from PYTHONPATH + Should Be Equal ${PYTHONPATH ARGS 3} 1-2-3 Variable File From PYTHONPATH as module - Should Be Equal ${PYTHONPATH VAR 2} Varfile found from PYTHONPATH - Should Be Equal ${PYTHONPATH ARGS 2} as-module + Should Be Equal ${PYTHONPATH VAR 2} Varfile found from PYTHONPATH + Should Be Equal ${PYTHONPATH ARGS 2} as-module Variable File From PYTHONPATH as submodule Should be Equal ${VARIABLE IN SUBMODULE} VALUE IN SUBMODULE diff --git a/atest/testdata/variables/dynamic_variable_files/dyn_vars.py b/atest/testdata/variables/dynamic_variable_files/dyn_vars.py index a248c8ee612..c44bafaa8dc 100644 --- a/atest/testdata/variables/dynamic_variable_files/dyn_vars.py +++ b/atest/testdata/variables/dynamic_variable_files/dyn_vars.py @@ -17,7 +17,7 @@ def get_dict(): class MyDict(dict): def __init__(self): - dict.__init__(self, from_my_dict='This From My Dict', from_my_dict2=2) + super().__init__(from_my_dict='This From My Dict', from_my_dict2=2) def get_MyMapping(): @@ -47,5 +47,5 @@ def get_UserDict(): class MyUserDict(UserDict): def __init__(self): - UserDict.__init__(self, {'from MyUserDict': 'This From MyUserDict', - 'from MyUserDict2': 2}) + super().__init__({'from MyUserDict': 'This From MyUserDict', + 'from MyUserDict2': 2}) diff --git a/atest/testdata/variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot b/atest/testdata/variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot index 4e55198a3cf..49c846e08ea 100644 --- a/atest/testdata/variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot +++ b/atest/testdata/variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot @@ -1,27 +1,27 @@ *** Settings *** -Variables dyn_vars.py dict -Variables dyn_vars.py mydict -Variables dyn_vars.py Mapping -Variables dyn_vars.py UserDict -Variables dyn_vars.py MyUserDict +Variables dyn_vars.py dict +Variables dyn_vars.py mydict +Variables dyn_vars.py Mapping +Variables dyn_vars.py UserDict +Variables dyn_vars.py MyUserDict *** Test Cases *** Variables From Dict Should Be Loaded - Should Be Equal ${from dict} This From Dict - Should Be Equal ${from dict2} ${2} + Should Be Equal ${from dict} This From Dict + Should Be Equal ${from dict2} ${2} Variables From My Dict Should Be Loaded - Should Be Equal ${from my dict} This From My Dict - Should Be Equal ${from my dict2} ${2} + Should Be Equal ${from my dict} This From My Dict + Should Be Equal ${from my dict2} ${2} Variables From Mapping Should Be Loaded - Should Be Equal ${from Mapping} This From Mapping - Should Be Equal ${from Mapping2} ${2} + Should Be Equal ${from Mapping} This From Mapping + Should Be Equal ${from Mapping2} ${2} Variables From UserDict Should Be Loaded - Should Be Equal ${from userdict} This From UserDict - Should Be Equal ${from userdict2} ${2} + Should Be Equal ${from userdict} This From UserDict + Should Be Equal ${from userdict2} ${2} Variables From My UserDict Should Be Loaded - Should Be Equal ${from my userdict} This From MyUserDict - Should Be Equal ${from my userdict2} ${2} + Should Be Equal ${from my userdict} This From MyUserDict + Should Be Equal ${from my userdict2} ${2} diff --git a/atest/testdata/variables/resvarfiles/variables.py b/atest/testdata/variables/resvarfiles/variables.py index 24092ae3814..e6a05672f69 100644 --- a/atest/testdata/variables/resvarfiles/variables.py +++ b/atest/testdata/variables/resvarfiles/variables.py @@ -1,10 +1,13 @@ class _Object: def __init__(self, name): self.name = name + def __str__(self): return self.name + def __repr__(self): - return "'%s'" % self.name + return repr(self.name) + STRING = 'Hello world!' INTEGER = 42 diff --git a/atest/testdata/variables/variables_from_variable_files.robot b/atest/testdata/variables/variables_from_variable_files.robot index 1bb31ac8d0a..d98c9deac31 100644 --- a/atest/testdata/variables/variables_from_variable_files.robot +++ b/atest/testdata/variables/variables_from_variable_files.robot @@ -44,7 +44,8 @@ Scalar List With Escapes ... ${LIST WITH ESCAPES [2]} ${LIST WITH ESCAPES [3]} ${exp} = Create List one \\ two \\\\ three \\\\\\ \${non_existing} Should Be Equal ${LIST WITH ESCAPES} ${exp} - Should Be True ${LIST WITH ESCAPES} == ['one \\\\', 'two \\\\\\\\', 'three \\\\\\\\\\\\', '\${non_existing}'] Backslashes are doubled here because 'Should Be True' uses 'eval' internally + # Backslashes are doubled because 'Should Be True' uses 'eval' internally. + Should Be True ${LIST WITH ESCAPES} == ['one \\\\', 'two \\\\\\\\', 'three \\\\\\\\\\\\', '\${non_existing}'] Scalar Object Should Not Be Equal ${OBJECT} dude Comparing object to string diff --git a/src/robot/variables/filesetter.py b/src/robot/variables/filesetter.py index f7f2bd25aab..0988473e659 100644 --- a/src/robot/variables/filesetter.py +++ b/src/robot/variables/filesetter.py @@ -40,8 +40,7 @@ def set(self, path_or_variables, args=None, overwrite=False): def _import_if_needed(self, path_or_variables, args=None): if not is_string(path_or_variables): return path_or_variables - LOGGER.info("Importing variable file '%s' with args %s" - % (path_or_variables, args)) + LOGGER.info(f"Importing variable file '{path_or_variables}' with args {args}.") if path_or_variables.lower().endswith(('.yaml', '.yml')): importer = YamlImporter() elif path_or_variables.lower().endswith('.json'): @@ -50,50 +49,16 @@ def _import_if_needed(self, path_or_variables, args=None): importer = PythonImporter() try: return importer.import_variables(path_or_variables, args) - except: - args = 'with arguments %s ' % seq2str2(args) if args else '' - raise DataError("Processing variable file '%s' %sfailed: %s" - % (path_or_variables, args, get_error_message())) + except Exception: + args = f'with arguments {seq2str2(args)} ' if args else '' + raise DataError(f"Processing variable file '{path_or_variables}' " + f"{args}failed: {get_error_message()}") def _set(self, variables, overwrite=False): for name, value in variables: self._store.add(name, value, overwrite) -class YamlImporter: - - def import_variables(self, path, args=None): - if args: - raise DataError('YAML variable files do not accept arguments.') - variables = self._import(path) - return [('${%s}' % name, self._dot_dict(value)) - for name, value in variables] - - def _import(self, path): - with io.open(path, encoding='UTF-8') as stream: - variables = self._load_yaml(stream) - if not is_dict_like(variables): - raise DataError('YAML variable file must be a mapping, got %s.' - % type_name(variables)) - return variables.items() - - def _load_yaml(self, stream): - if not yaml: - raise DataError('Using YAML variable files requires PyYAML module ' - 'to be installed. Typically you can install it ' - 'by running `pip install pyyaml`.') - if yaml.__version__.split('.')[0] == '3': - return yaml.load(stream) - return yaml.full_load(stream) - - def _dot_dict(self, value): - if is_dict_like(value): - return DotDict((k, self._dot_dict(v)) for k, v in value.items()) - if is_list_like(value): - return [self._dot_dict(v) for v in value] - return value - - class PythonImporter: def import_variables(self, path, args=None): @@ -102,24 +67,20 @@ def import_variables(self, path, args=None): return self._get_variables(var_file, args) def _get_variables(self, var_file, args): - if self._is_dynamic(var_file): - variables = self._get_dynamic(var_file, args) + get_variables = (getattr(var_file, 'get_variables', None) or + getattr(var_file, 'getVariables', None)) + if get_variables: + variables = self._get_dynamic(get_variables, args) else: variables = self._get_static(var_file) return list(self._decorate_and_validate(variables)) - def _is_dynamic(self, var_file): - return (hasattr(var_file, 'get_variables') or - hasattr(var_file, 'getVariables')) - - def _get_dynamic(self, var_file, args): - get_variables = (getattr(var_file, 'get_variables', None) or - getattr(var_file, 'getVariables')) + def _get_dynamic(self, get_variables, args): variables = get_variables(*args) if is_dict_like(variables): return variables.items() - raise DataError("Expected '%s' to return dict-like value, got %s." - % (get_variables.__name__, type_name(variables))) + raise DataError(f"Expected '{get_variables.__name__}' to return " + f"a dictionary-like value, got {type_name(variables)}.") def _get_static(self, var_file): names = [attr for attr in dir(var_file) if not attr.startswith('_')] @@ -132,40 +93,36 @@ def _get_static(self, var_file): def _decorate_and_validate(self, variables): for name, value in variables: - name = self._decorate(name) - self._validate(name, value) + if name.startswith('LIST__'): + if not is_list_like(value): + raise DataError(f"Invalid variable '{name}': Expected a " + f"list-like value, got {type_name(value)}.") + name = f'@{{{name[6:]}}}' + elif name.startswith('DICT__'): + if not is_dict_like(value): + raise DataError(f"Invalid variable '{name}': Expected a " + f"dictionary-like value, got {type_name(value)}.") + name = f'&{{{name[6:]}}}' + else: + name = f'${{{name}}}' yield name, value - def _decorate(self, name): - if name.startswith('LIST__'): - return '@{%s}' % name[6:] - if name.startswith('DICT__'): - return '&{%s}' % name[6:] - return '${%s}' % name - - def _validate(self, name, value): - if name[0] == '@' and not is_list_like(value): - raise DataError("Invalid variable '%s': Expected list-like value, " - "got %s." % (name, type_name(value))) - if name[0] == '&' and not is_dict_like(value): - raise DataError("Invalid variable '%s': Expected dict-like value, " - "got %s." % (name, type_name(value))) - class JsonImporter: + def import_variables(self, path, args=None): if args: raise DataError('JSON variable files do not accept arguments.') variables = self._import(path) - return [('${%s}' % name, self._dot_dict(value)) + return [(f'${{{name}}}', self._dot_dict(value)) for name, value in variables] def _import(self, path): with io.open(path, encoding='UTF-8') as stream: variables = json.load(stream) if not is_dict_like(variables): - raise DataError('JSON variable file must be a mapping, got %s.' - % type_name(variables)) + raise DataError(f'JSON variable file must be a mapping, ' + f'got {type_name(variables)}.') return variables.items() def _dot_dict(self, value): @@ -174,3 +131,36 @@ def _dot_dict(self, value): if is_list_like(value): return [self._dot_dict(v) for v in value] return value + + +class YamlImporter: + + def import_variables(self, path, args=None): + if args: + raise DataError('YAML variable files do not accept arguments.') + variables = self._import(path) + return [(f'${{{name}}}', self._dot_dict(value)) for name, value in variables] + + def _import(self, path): + with io.open(path, encoding='UTF-8') as stream: + variables = self._load_yaml(stream) + if not is_dict_like(variables): + raise DataError(f'YAML variable file must be a mapping, ' + f'got {type_name(variables)}.') + return variables.items() + + def _load_yaml(self, stream): + if not yaml: + raise DataError('Using YAML variable files requires PyYAML module ' + 'to be installed. Typically you can install it ' + 'by running `pip install pyyaml`.') + if yaml.__version__.split('.')[0] == '3': + return yaml.load(stream) + return yaml.full_load(stream) + + def _dot_dict(self, value): + if is_dict_like(value): + return DotDict((k, self._dot_dict(v)) for k, v in value.items()) + if is_list_like(value): + return [self._dot_dict(v) for v in value] + return value From 45dfc0c0b9a4c44868c4bed5345d3dacb13073a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 18 Oct 2023 16:12:33 +0300 Subject: [PATCH 0784/1592] Support conversion and named args with argument files. Fixes #4903. --- .../core/resource_and_variable_imports.robot | 4 +-- .../commandline_variable_files.robot | 27 ++++++++++++------- .../getting_vars_from_dynamic_var_file.robot | 10 +++++++ .../robot/variables/json_variable_file.robot | 9 ++++--- .../list_and_dict_from_variable_file.robot | 4 +-- .../robot/variables/yaml_variable_file.robot | 10 ++++--- .../builtin/import_variables.robot | 5 ++-- .../commandline_variable_files.robot | 3 +++ .../argument_conversion.py | 4 +++ .../getting_vars_from_dynamic_var_file.robot | 8 +++++- .../variables/resvarfiles/cli_vars_2.py | 12 +++++---- .../running/arguments/argumentvalidator.py | 3 ++- src/robot/variables/filesetter.py | 17 ++++++++---- 13 files changed, 81 insertions(+), 35 deletions(-) create mode 100644 atest/testdata/variables/dynamic_variable_files/argument_conversion.py diff --git a/atest/robot/core/resource_and_variable_imports.robot b/atest/robot/core/resource_and_variable_imports.robot index 89200c238e0..03d2afacdb5 100644 --- a/atest/robot/core/resource_and_variable_imports.robot +++ b/atest/robot/core/resource_and_variable_imports.robot @@ -51,14 +51,14 @@ Dynamic Variable File With Variables And Backslashes In Args Invalid return value from dynamic variable file ${path} = Normalize Path ${RESDIR}/dynamic_variables.py Error in file 4 ${DATAFILE} 10 - ... Processing variable file '${path}' with arguments [ Two args | returns invalid ] failed: + ... Processing variable file '${path}' with arguments ['Two args', 'returns invalid'] failed: ... Expected 'get_variables' to return a dictionary-like value, got None. ... pattern=False Dynamic variable file raises exception ${path} = Normalize Path ${RESDIR}/dynamic_variables.py Error in file 5 ${DATAFILE} 12 - ... Processing variable file '${path}' with arguments [ More | args | raises | exception ] failed: + ... Processing variable file '${path}' with arguments ['More', 'args', 'raises', 'exception'] failed: ... Invalid arguments for get_variables ... pattern=False diff --git a/atest/robot/variables/commandline_variable_files.robot b/atest/robot/variables/commandline_variable_files.robot index 6c2114f389a..fdb5a1bdb0a 100644 --- a/atest/robot/variables/commandline_variable_files.robot +++ b/atest/robot/variables/commandline_variable_files.robot @@ -16,6 +16,9 @@ Arguments To Variable Files Arguments To Variable Files Using Semicolon Separator Check Test Case ${TEST NAME} +Argument Conversion + Check Test Case ${TEST NAME} + Variable File From PYTHONPATH Check Test Case ${TEST NAME} @@ -28,18 +31,21 @@ Variable File From PYTHONPATH as module Variable File From PYTHONPATH as submodule Check Test Case ${TEST NAME} -Non-Existing Variable File - Stderr Should Contain [ ERROR ] Variable file '${VF3}' does not exist. - Stderr Should Contain [ ERROR ] Variable file '${VF4}' does not exist. - Too Few Arguments To Variable File - Stderr Should Contain [ ERROR ] Processing variable file '${VF2}' failed: TypeError: get_variables() + Check Log Message ${ERRORS}[0] Processing variable file '${VF2}' failed: Variable file expected 1 to 3 arguments, got 0. level=ERROR Too Many Arguments To Variable File - Stderr Should Contain [ ERROR ] Processing variable file '${VF2}' with arguments [ too | many | args ] failed: TypeError: get_variables() + Check Log Message ${ERRORS}[2] Processing variable file '${VF2}' with arguments ['too', 'many', 'args', 'here', 'we', 'have'] failed: Variable file expected 1 to 3 arguments, got 6. level=ERROR + +Invalid Arguments To Variable File + Check Log Message ${ERRORS}[3] Processing variable file '${VF2}' with arguments ['ok', 'ok', 'not number'] failed: ValueError: Argument 'conversion' got value 'not number' that cannot be converted to integer. level=ERROR Invalid Variable File - Stderr Should Contain [ ERROR ] Processing variable file '${VF2}' with arguments [ FAIL ] failed: ZeroDivisionError: + Check Log Message ${ERRORS}[1] Processing variable file '${VF2}' with arguments [[]'FAIL'[]] failed: ZeroDivisionError: * level=ERROR pattern=True + +Non-Existing Variable File + Check Log Message ${ERRORS}[4] Variable file '${VF3}' does not exist. level=ERROR + Check Log Message ${ERRORS}[5] Variable file '${VF4}' does not exist. level=ERROR *** Keywords *** Run Test Data @@ -49,13 +55,14 @@ Run Test Data ${VF4} = Set Variable non_absolute_non_existing.py ${options} = Catenate ... --variablefile ${VF1} - ... -V ${VF2}:arg + ... -V ${VF2}:arg:conversion=42 ... -V "${VF2}:arg2:value;with;semi;colons" ... -V "${VF2};semicolon;separator" - ... -V "${VF2};semi:colon;separator:with:colons" + ... -V "${VF2};semi:colon;separator:with:colons;42" ... --VariableFile ${VF2} ... -V ${VF2}:FAIL - ... -V ${VF2}:too:many:args + ... -V ${VF2}:too:many:args:here:we:have + ... -V "${VF2}:ok:ok:not number" ... --variablef ${VF3} ... --VARIABLEFILE ${VF4} ... --VariableFile pythonpath_varfile.py diff --git a/atest/robot/variables/getting_vars_from_dynamic_var_file.robot b/atest/robot/variables/getting_vars_from_dynamic_var_file.robot index 6a8a500c0cf..404d03f58e5 100644 --- a/atest/robot/variables/getting_vars_from_dynamic_var_file.robot +++ b/atest/robot/variables/getting_vars_from_dynamic_var_file.robot @@ -17,3 +17,13 @@ Variables From UserDict Should Be Loaded Variables From My UserDict Should Be Loaded Check Test Case ${TEST NAME} + +Argument conversion + Check Test Case ${TEST NAME} + +Failing argument conversion + ${path} = Normalize Path ${DATADIR}/variables/dynamic_variable_files/argument_conversion.py + Error In File 0 variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot 8 + ... Processing variable file '${path}' with arguments ['ok', 'bad'] failed: + ... ValueError: Argument 'number' got value 'bad' that cannot be converted to integer or float. + ... pattern=False diff --git a/atest/robot/variables/json_variable_file.robot b/atest/robot/variables/json_variable_file.robot index 0f1d9f3850d..8cc43ca8846 100644 --- a/atest/robot/variables/json_variable_file.robot +++ b/atest/robot/variables/json_variable_file.robot @@ -46,8 +46,9 @@ Non-mapping JSON file JSON files do not accept arguments Processing should have failed 2 6 valid.json - ... with arguments ? arguments | not | accepted ?${SPACE} + ... with arguments ['arguments', 'not', 'accepted']${SPACE} ... JSON variable files do not accept arguments. + ... pattern=False Non-existing JSON file Importing should have failed 3 7 @@ -60,13 +61,15 @@ JSON with invalid encoding *** Keywords *** Processing should have failed - [Arguments] ${index} ${lineno} ${file} ${arguments} ${error} + [Arguments] ${index} ${lineno} ${file} ${arguments} ${error} ${pattern}=True ${path} = Normalize Path ${DATADIR}/variables/${file} Importing should have failed ${index} ${lineno} ... Processing variable file '${path}' ${arguments}failed: ... ${error} + ... pattern=${pattern} Importing should have failed - [Arguments] ${index} ${lineno} @{error} + [Arguments] ${index} ${lineno} @{error} ${pattern}=True Error In File ${index} variables/json_variable_file.robot ${lineno} ... @{error} + ... pattern=${pattern} diff --git a/atest/robot/variables/list_and_dict_from_variable_file.robot b/atest/robot/variables/list_and_dict_from_variable_file.robot index eb522a4e1e5..d56d3a11bc6 100644 --- a/atest/robot/variables/list_and_dict_from_variable_file.robot +++ b/atest/robot/variables/list_and_dict_from_variable_file.robot @@ -21,14 +21,14 @@ Dict is ordered Invalid list Check Test Case ${TESTNAME} Verify Error 0 3 - ... [ LIST__inv_list | not a list ] + ... ['LIST__inv_list', 'not a list'] ... LIST__inv_list ... Expected a list-like value, got string. Invalid dict Check Test Case ${TESTNAME} Verify Error 1 4 - ... [ DICT__inv_dict | ['1', '2', 3] ] + ... ['DICT__inv_dict', ['1', '2', 3]] ... DICT__inv_dict ... Expected a dictionary-like value, got list. diff --git a/atest/robot/variables/yaml_variable_file.robot b/atest/robot/variables/yaml_variable_file.robot index ff37cf393dc..397e5ffc5df 100644 --- a/atest/robot/variables/yaml_variable_file.robot +++ b/atest/robot/variables/yaml_variable_file.robot @@ -47,8 +47,9 @@ Non-mapping YAML file YAML files do not accept arguments Processing should have failed 2 7 valid.yaml - ... with arguments ? arguments | not | accepted ?${SPACE} + ... with arguments ['arguments', 'not', 'accepted']${SPACE} ... YAML variable files do not accept arguments. + ... pattern=False Non-existing YAML file Importing should have failed 3 8 @@ -61,14 +62,15 @@ YAML with invalid encoding *** Keywords *** Processing should have failed - [Arguments] ${index} ${lineno} ${file} ${arguments} ${error} + [Arguments] ${index} ${lineno} ${file} ${arguments} ${error} ${pattern}=True ${path} = Normalize Path ${DATADIR}/variables/${file} Importing should have failed ${index} ${lineno} ... Processing variable file '${path}' ${arguments}failed: ... ${error} + ... pattern=${pattern} Importing should have failed - [Arguments] ${index} ${lineno} @{error} + [Arguments] ${index} ${lineno} @{error} ${pattern}=True Error In File ${index} variables/yaml_variable_file.robot ${lineno} ... @{error} - + ... pattern=${pattern} diff --git a/atest/testdata/standard_libraries/builtin/import_variables.robot b/atest/testdata/standard_libraries/builtin/import_variables.robot index 8eba0f6bda9..aa5bf6738c2 100644 --- a/atest/testdata/standard_libraries/builtin/import_variables.robot +++ b/atest/testdata/standard_libraries/builtin/import_variables.robot @@ -32,8 +32,9 @@ Import Variables With Arguments Should Be Equal ${COMMON VARIABLE} ${2} Inport Variables With Invalid Arguments - [Documentation] FAIL REGEXP: - ... Processing variable file '.*variables_to_import_2.py' with arguments \\[ 1 \\| 2 \\| 3 \\] failed: TypeError: .* + [Documentation] FAIL GLOB: + ... Processing variable file '*[/\\]variables_to_import_2.py' with arguments [[]'1', '2', '3'[]] failed: \ + ... Variable file expected 1 to 2 arguments, got 3. Import Variables ${VAR FILE 2} 1 2 3 Import Variables In User Keyword 1 diff --git a/atest/testdata/variables/commandline_variable_files.robot b/atest/testdata/variables/commandline_variable_files.robot index a33c3005fbb..1611603201e 100644 --- a/atest/testdata/variables/commandline_variable_files.robot +++ b/atest/testdata/variables/commandline_variable_files.robot @@ -19,6 +19,9 @@ Arguments To Variable Files Using Semicolon Separator Should Be Equal ${SEMICOLON} separator Should Be Equal ${SEMI:COLON} separator:with:colons +Argument Conversion + Should Be Equal ${CONVERSION} ${42} + Variable File From PYTHONPATH Should Be Equal ${PYTHONPATH VAR 0} Varfile found from PYTHONPATH Should Be Equal ${PYTHONPATH ARGS 0} ${EMPTY} diff --git a/atest/testdata/variables/dynamic_variable_files/argument_conversion.py b/atest/testdata/variables/dynamic_variable_files/argument_conversion.py new file mode 100644 index 00000000000..32289a6131d --- /dev/null +++ b/atest/testdata/variables/dynamic_variable_files/argument_conversion.py @@ -0,0 +1,4 @@ +def get_variables(string: str, number: 'int|float'): + assert isinstance(string, str) + assert isinstance(number, (int, float)) + return {'string': string, 'number': number} diff --git a/atest/testdata/variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot b/atest/testdata/variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot index 49c846e08ea..3cb6108e8d7 100644 --- a/atest/testdata/variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot +++ b/atest/testdata/variables/dynamic_variable_files/getting_vars_from_dynamic_var_file.robot @@ -1,9 +1,11 @@ *** Settings *** Variables dyn_vars.py dict -Variables dyn_vars.py mydict +Variables dyn_vars.py type=mydict Variables dyn_vars.py Mapping Variables dyn_vars.py UserDict Variables dyn_vars.py MyUserDict +Variables argument_conversion.py ${42} number=3.14 +Variables argument_conversion.py ok bad *** Test Cases *** Variables From Dict Should Be Loaded @@ -25,3 +27,7 @@ Variables From UserDict Should Be Loaded Variables From My UserDict Should Be Loaded Should Be Equal ${from my userdict} This From MyUserDict Should Be Equal ${from my userdict2} ${2} + +Argument conversion + Should Be Equal ${string} 42 + Should Be Equal ${number} ${3.14} diff --git a/atest/testdata/variables/resvarfiles/cli_vars_2.py b/atest/testdata/variables/resvarfiles/cli_vars_2.py index c20b35ca67d..3dec99a9e64 100644 --- a/atest/testdata/variables/resvarfiles/cli_vars_2.py +++ b/atest/testdata/variables/resvarfiles/cli_vars_2.py @@ -1,10 +1,12 @@ -def get_variables(name, value='default value'): +def get_variables(name, value='default value', conversion: int = 0): if name == 'FAIL': 1/0 - varz = { name: value, - 'ANOTHER_SCALAR': 'Variable from CLI var file with get_variables', - 'LIST__ANOTHER_LIST': ['List variable from CLI var file', - 'with get_variables'] } + assert isinstance(conversion, int) + varz = {name: value, + 'ANOTHER_SCALAR': 'Variable from CLI var file with get_variables', + 'LIST__ANOTHER_LIST': ['List variable from CLI var file', + 'with get_variables'], + 'CONVERSION': conversion} for name in 'PRIORITIES_1', 'PRIORITIES_2', 'PRIORITIES_2B': varz[name] = 'Second Variable File from CLI' return varz diff --git a/src/robot/running/arguments/argumentvalidator.py b/src/robot/running/arguments/argumentvalidator.py index 634e7993b3c..9bb431812b9 100644 --- a/src/robot/running/arguments/argumentvalidator.py +++ b/src/robot/running/arguments/argumentvalidator.py @@ -47,7 +47,8 @@ def _validate_no_multiple_values(self, positional, named, spec): def _raise_error(self, message): spec = self.arg_spec - raise DataError(f"{spec.type.capitalize()} '{spec.name}' {message}.") + name = f"'{spec.name}' " if spec.name else '' + raise DataError(f"{spec.type.capitalize()} {name}{message}.") def _validate_no_positional_only_as_named(self, named, spec): if not spec.var_named: diff --git a/src/robot/variables/filesetter.py b/src/robot/variables/filesetter.py index 0988473e659..9071ebccec3 100644 --- a/src/robot/variables/filesetter.py +++ b/src/robot/variables/filesetter.py @@ -23,8 +23,8 @@ from robot.errors import DataError from robot.output import LOGGER -from robot.utils import (get_error_message, is_dict_like, is_list_like, - is_string, seq2str2, type_name, DotDict, Importer) +from robot.utils import (DotDict, get_error_message, Importer, is_dict_like, + is_list_like, type_name) class VariableFileSetter: @@ -38,7 +38,7 @@ def set(self, path_or_variables, args=None, overwrite=False): return variables def _import_if_needed(self, path_or_variables, args=None): - if not is_string(path_or_variables): + if not isinstance(path_or_variables, str): return path_or_variables LOGGER.info(f"Importing variable file '{path_or_variables}' with args {args}.") if path_or_variables.lower().endswith(('.yaml', '.yml')): @@ -50,7 +50,7 @@ def _import_if_needed(self, path_or_variables, args=None): try: return importer.import_variables(path_or_variables, args) except Exception: - args = f'with arguments {seq2str2(args)} ' if args else '' + args = f'with arguments {args} ' if args else '' raise DataError(f"Processing variable file '{path_or_variables}' " f"{args}failed: {get_error_message()}") @@ -76,12 +76,19 @@ def _get_variables(self, var_file, args): return list(self._decorate_and_validate(variables)) def _get_dynamic(self, get_variables, args): - variables = get_variables(*args) + positional, named = self._resolve_arguments(get_variables, args) + variables = get_variables(*positional, **dict(named)) if is_dict_like(variables): return variables.items() raise DataError(f"Expected '{get_variables.__name__}' to return " f"a dictionary-like value, got {type_name(variables)}.") + def _resolve_arguments(self, get_variables, args): + # Avoid cyclic import. Yuck. + from robot.running.arguments import PythonArgumentParser + spec = PythonArgumentParser('variable file').parse(get_variables) + return spec.resolve(args) + def _get_static(self, var_file): names = [attr for attr in dir(var_file) if not attr.startswith('_')] if hasattr(var_file, '__all__'): From 057f2844e921c79cde8a08c7e171541aae1ee6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 18 Oct 2023 22:38:15 +0300 Subject: [PATCH 0785/1592] Make importing static variable file with args an error. Fixes #4904. --- .../core/resource_and_variable_imports.robot | 48 ++++++++++++------- .../core/resource_and_variable_imports.robot | 37 +++++++------- .../dynamic_variables.py | 33 ++++++------- src/robot/variables/filesetter.py | 4 +- 4 files changed, 67 insertions(+), 55 deletions(-) diff --git a/atest/robot/core/resource_and_variable_imports.robot b/atest/robot/core/resource_and_variable_imports.robot index 03d2afacdb5..b089fb86f92 100644 --- a/atest/robot/core/resource_and_variable_imports.robot +++ b/atest/robot/core/resource_and_variable_imports.robot @@ -37,34 +37,50 @@ Invalid List Variable [Documentation] List variable not containing a list value causes an error Check Test Case ${TEST NAME} ${path} = Normalize Path ${RESDIR}/invalid_list_variable.py - Error in file 14 ${DATAFILE} 43 + Error in file 17 ${DATAFILE} 48 ... Processing variable file '${path}' failed: ... Invalid variable 'LIST__invalid_list': Expected a list-like value, got string. Dynamic Variable File - Check Test Case ${TEST NAME} With No Args - Check Test Case ${TEST NAME} With One Arg + Check Test Case ${TEST NAME} Dynamic Variable File With Variables And Backslashes In Args Check Test Case ${TEST NAME} +Static variable file does not accept arguments + ${path} = Normalize Path ${DATADIR}/core/resources_and_variables/variables.py + Error in file 6 ${DATAFILE} 18 + ... Processing variable file '${path}' with arguments ['static', 'does', 'not', 'accept', 'args'] failed: Static variable files do not accept arguments. + ... pattern=False + +Too few arguments to dynamic variable file + ${path} = Normalize Path ${DATADIR}/core/resources_and_variables/dynamic_variables.py + Error in file 7 ${DATAFILE} 19 + ... Processing variable file '${path}' failed: Variable file expected 1 to 4 arguments, got 0. + +Too many arguments to dynamic variable file + ${path} = Normalize Path ${DATADIR}/core/resources_and_variables/dynamic_variables.py + Error in file 8 ${DATAFILE} 20 + ... Processing variable file '${path}' with arguments ['More', 'than', 'four', 'arguments', 'fails'] failed: Variable file expected 1 to 4 arguments, got 5. + ... pattern=False + Invalid return value from dynamic variable file ${path} = Normalize Path ${RESDIR}/dynamic_variables.py Error in file 4 ${DATAFILE} 10 - ... Processing variable file '${path}' with arguments ['Two args', 'returns invalid'] failed: + ... Processing variable file '${path}' with arguments ['Three args', 'returns None', 'which is invalid'] failed: ... Expected 'get_variables' to return a dictionary-like value, got None. ... pattern=False Dynamic variable file raises exception ${path} = Normalize Path ${RESDIR}/dynamic_variables.py Error in file 5 ${DATAFILE} 12 - ... Processing variable file '${path}' with arguments ['More', 'args', 'raises', 'exception'] failed: - ... Invalid arguments for get_variables + ... Processing variable file '${path}' with arguments ['Four', 'args', 'raises', 'exception'] failed: + ... Ooops! ... pattern=False Non-Existing Variable In Arguments To Dynamic Variable File ${path} = Normalize Path ${RESDIR}/dynamicVariables.py - Error in file 13 ${DATAFILE} 42 + Error in file 16 ${DATAFILE} 47 ... Replacing variables from setting 'Variables' failed: ... Variable '\${non_existing_var_as_arg}' not found. @@ -91,28 +107,28 @@ Re-Import Variable File Variable dynamic_variables.py ${SPACE}with arguments [ One arg works ] Non-Existing Resource File - Error in file 6 ${DATAFILE} 34 + Error in file 9 ${DATAFILE} 39 ... Resource file 'non_existing.robot' does not exist. Non-Existing Variable File - Error in file 7 ${DATAFILE} 35 + Error in file 10 ${DATAFILE} 40 ... Variable file 'non_existing.py' does not exist. Empty Resource File ${path} = Normalize Path ${RESDIR}/empty_resource.robot - Check log message ${ERRORS}[8] + Check log message ${ERRORS}[11] ... Imported resource file '${path}' is empty. WARN Invalid Resource Import Parameters - Error in file 0 ${DATAFILE} 37 + Error in file 0 ${DATAFILE} 42 ... Setting 'Resource' accepts only one value, got 2. Initialization file cannot be used as a resource file ${path} = Normalize Path ${DATADIR}/core/test_suite_dir_with_init_file/__init__.robot - Error in file 9 ${DATAFILE} 38 + Error in file 12 ${DATAFILE} 43 ... Initialization file '${path}' cannot be imported as a resource file. ${path} = Normalize Path ${DATADIR}/core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot - Error in file 10 ${DATAFILE} 39 + Error in file 13 ${DATAFILE} 44 ... Initialization file '${path}' cannot be imported as a resource file. Invalid Setting In Resource File @@ -129,18 +145,18 @@ Resource cannot contain tests Invalid Variable File ${path} = Normalize Path ${RESDIR}/invalid_variable_file.py - Error in file 12 ${DATAFILE} 41 + Error in file 15 ${DATAFILE} 46 ... Processing variable file '${path}' failed: ... Importing variable file '${path}' failed: ... This is an invalid variable file ... traceback=* Resource Import Without Path - Error in file 11 ${DATAFILE} 40 + Error in file 14 ${DATAFILE} 45 ... Resource setting requires value. Variable Import Without Path - Error in file 15 ${DATAFILE} 44 + Error in file 18 ${DATAFILE} 49 ... Variables setting requires value. Resource File In PYTHONPATH diff --git a/atest/testdata/core/resource_and_variable_imports.robot b/atest/testdata/core/resource_and_variable_imports.robot index a42ff4d67ed..8abe44d4038 100644 --- a/atest/testdata/core/resource_and_variable_imports.robot +++ b/atest/testdata/core/resource_and_variable_imports.robot @@ -5,15 +5,20 @@ VARIABLES resources_and_variables/variables.py Variables ${variables2_file} # Arguments to variable files -VarIables resources_and_variables/dynamic_variables.py # No args works -variables resources_and_variables/dynamic_variables.py One arg works +varIables resources_and_variables/dynamic_variables.py One arg works +Variables resources_and_variables/dynamic_variables.py Two args works Variables resources_and_variables/dynamic_variables.py -... Two args returns invalid +... Three args returns None which is invalid Variables resources_and_variables/dynamic_variables.py -... More args raises exception +... Four args raises exception Variables resources_and_variables/dynamicVariables.py ... This ${1} ${works} back \\ slash \${escaped} ${CURDIR} +# Invalid arguments to variable files +Variables resources_and_variables/variables.py static does not accept args +Variables resources_and_variables/dynamic_variables.py # No args fails +Variables resources_and_variables/dynamic_variables.py More than four arguments fails + # Resources and variables in PYTHONPATH Resource resource_in_pythonpath.robot resource resvar_subdir/resource_in_pythonpath_2.robot @@ -81,23 +86,15 @@ Invalid List Variable Variable Should Not Exist \@{invalid_list} Variable Should Not Exist \${var_in_invalid_list_variable_file} -Dynamic Variable File With No Args - Variable Should Not Exist $no_args_vars - Variable Should Not Exist $one_arg_vars +Dynamic Variable File + Variable Should Not Exist $NOT_VARIABLE Variable Should Not Exist $get_variables - Log Variables - Should Be Equal ${dyn_no_args_get_var} Dyn var got with no args from get_variables - Should Be Equal ${dyn_no_args_get_var_2} ${2} - Should Be Equal ${dyn_no_args_get_var_list}[0] one - Should Be Equal ${dyn_no_args_get_var_list}[1] ${2} - -Dynamic Variable File With One Arg - Should Be Equal ${dyn_one_arg_get_var} Dyn var got with one arg from get_variables - Should Be Equal ${dyn_one_arg_get_var_False} ${False} - Should Be Equal ${dyn_one_arg_get_var_list}[0] one - Should Be Equal ${dyn_one_arg_get_var_list}[1] ${False} - ${dict} = Set Variable ${dyn_one_arg_get_var_list}[2] - Should Be Equal ${dict}[dyn_no_args_get_var_2] ${2} + Should Be Equal ${dyn_one_arg} Dynamic variable got with one argument + Should Be Equal ${dyn_one_arg_1} ${1} + Should Be Equal ${dyn_one_arg_list} ${{['one', 1]}} + Should Be Equal ${dyn_two_args} Dynamic variable got with two arguments + Should Be Equal ${dyn_two_args_False} ${False} + Should Be Equal ${dyn_two_args_list} ${{['two', 2]}} Dynamic Variable File With Variables And Backslashes In Args Should Be Equal ${dyn_multi_args_getVar} Dyn var got with multiple args from getVariables diff --git a/atest/testdata/core/resources_and_variables/dynamic_variables.py b/atest/testdata/core/resources_and_variables/dynamic_variables.py index 59805e1f163..e87c3d13193 100644 --- a/atest/testdata/core/resources_and_variables/dynamic_variables.py +++ b/atest/testdata/core/resources_and_variables/dynamic_variables.py @@ -1,20 +1,17 @@ -no_args_vars = { - 'dyn_no_args_get_var': 'Dyn var got with no args from get_variables', - 'dyn_no_args_get_var_2': 2, - 'LIST__dyn_no_args_get_var_list': ['one', 2] -} -one_arg_vars = { - 'dyn_one_arg_get_var': 'Dyn var got with one arg from get_variables', - 'dyn_one_arg_get_var_False': False, - 'LIST__dyn_one_arg_get_var_list': ['one', False, no_args_vars] -} +NOT_VARIABLE = True -def get_variables(*args): - if len(args) == 0: - return no_args_vars - if len(args) == 1: - return one_arg_vars - if len(args) == 2: - return None # this is invalid - raise Exception('Invalid arguments for get_variables') +def get_variables(a, b=None, c=None, d=None): + if b is None: + return {'dyn_one_arg': 'Dynamic variable got with one argument', + 'dyn_one_arg_1': 1, + 'LIST__dyn_one_arg_list': ['one', 1], + 'args': [a, b, c, d]} + if c is None: + return {'dyn_two_args': 'Dynamic variable got with two arguments', + 'dyn_two_args_False': False, + 'LIST__dyn_two_args_list': ['two', 2], + 'args': [a, b, c, d]} + if d is None: + return None + raise Exception('Ooops!') diff --git a/src/robot/variables/filesetter.py b/src/robot/variables/filesetter.py index 9071ebccec3..f5d8683a2bc 100644 --- a/src/robot/variables/filesetter.py +++ b/src/robot/variables/filesetter.py @@ -71,8 +71,10 @@ def _get_variables(self, var_file, args): getattr(var_file, 'getVariables', None)) if get_variables: variables = self._get_dynamic(get_variables, args) - else: + elif not args: variables = self._get_static(var_file) + else: + raise DataError('Static variable files do not accept arguments.') return list(self._decorate_and_validate(variables)) def _get_dynamic(self, get_variables, args): From cdd3a1a8d4d16d965e0447b003dce5d658d8efa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 19 Oct 2023 12:06:44 +0300 Subject: [PATCH 0786/1592] Document that var files support conversion and named args. Missing part of #4903. --- .../CreatingTestData/ResourceAndVariableFiles.rst | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst index 18509c69898..fbc6cf32cbd 100644 --- a/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst +++ b/doc/userguide/src/CreatingTestData/ResourceAndVariableFiles.rst @@ -556,7 +556,7 @@ The example below is functionally identical to the first example related to `get_variables` can also take arguments, which facilitates changing what variables actually are created. Arguments to the function are set just as any other arguments for a Python function. When `taking variable files -into use`_ in the test data, arguments are specified in cells after the path +into use`_, arguments are specified after the path to the variable file, and in the command line they are separated from the path with a colon or a semicolon. @@ -578,6 +578,17 @@ or database where to read variables from. else: return variables2 +Starting from Robot Framework 7.0, arguments to variable files support automatic +argument conversion as well as named argument syntax. For example, a variable +file with `get_variables(first: int = 0, second: str = '')` could be imported +like this: + +.. sourcecode:: robotframework + + *** Settings *** + Variables example.py 42 # Converted to integer. + Variables example.py second=value # Named argument syntax. + Implementing variable file as a class ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From ca28119ea21078b6ee8b7e406e586c4753fa20ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 19 Oct 2023 13:12:50 +0300 Subject: [PATCH 0787/1592] Support nested variable assign `${${x}}`. This syntax is supported in the following cases: - In the Variables section. Fixes #4905. - With the new VAR syntax. Part of #3761. - With keyword return values. Fixes #4545. It is not supported in all places where variables are created. For example, it doesn't work with FOR loops or in user keyword arguments. --- atest/robot/running/if/inline_if_else.robot | 3 ++ .../dict_variable_in_variable_table.robot | 2 +- atest/robot/variables/return_values.robot | 6 +++ atest/robot/variables/var_syntax.robot | 6 +++ atest/robot/variables/variable_section.robot | 42 ++++++++------- .../testdata/running/if/inline_if_else.robot | 5 ++ .../running/if/invalid_inline_if.robot | 4 +- .../builtin/create_dictionary.robot | 3 +- atest/testdata/variables/return_values.robot | 33 ++++++++---- atest/testdata/variables/var_syntax.robot | 15 +++++- .../testdata/variables/variable_section.robot | 15 ++++-- .../src/CreatingTestData/Variables.rst | 52 +++++++++++++++---- src/robot/parsing/lexer/statementlexers.py | 6 ++- src/robot/running/bodyrunner.py | 3 +- src/robot/running/model.py | 6 +-- src/robot/variables/assigner.py | 5 +- src/robot/variables/filesetter.py | 21 ++++---- src/robot/variables/search.py | 2 +- src/robot/variables/store.py | 22 ++++---- src/robot/variables/tablesetter.py | 23 ++++---- 20 files changed, 177 insertions(+), 97 deletions(-) diff --git a/atest/robot/running/if/inline_if_else.robot b/atest/robot/running/if/inline_if_else.robot index f3af1456008..a1418d70100 100644 --- a/atest/robot/running/if/inline_if_else.robot +++ b/atest/robot/running/if/inline_if_else.robot @@ -64,6 +64,9 @@ List assign Dict assign NOT RUN PASS +Assign based on another variable + PASS NOT RUN index=1 + Assign without ELSE PASS NOT RUN index=0 NOT RUN PASS NOT RUN index=2 diff --git a/atest/robot/variables/dict_variable_in_variable_table.robot b/atest/robot/variables/dict_variable_in_variable_table.robot index 555aec833bd..4c4e7feb02c 100644 --- a/atest/robot/variables/dict_variable_in_variable_table.robot +++ b/atest/robot/variables/dict_variable_in_variable_table.robot @@ -53,7 +53,7 @@ Invalid key Check Test Case ${TESTNAME} Error In File 5 variables/dict_variable_in_variable_table.robot 34 ... Setting variable '\&{NON HASHABLE KEY}' failed: - ... Creating dictionary failed: * + ... Creating dictionary variable failed: * Non-dict cannot be used as dict variable Check Test Case ${TESTNAME} 1 diff --git a/atest/robot/variables/return_values.robot b/atest/robot/variables/return_values.robot index 78606e2a089..7b5391cee62 100644 --- a/atest/robot/variables/return_values.robot +++ b/atest/robot/variables/return_values.robot @@ -208,6 +208,12 @@ Optional Assign Mark With Multiple Variables Assign Mark Can Be Used Only With The Last Variable Check Test Case ${TESTNAME} +Named based on another variable + Check Test Case ${TESTNAME} + +Non-existing variable in name + Check Test Case ${TESTNAME} + Files are not lists Check Test Case ${TESTNAME} diff --git a/atest/robot/variables/var_syntax.robot b/atest/robot/variables/var_syntax.robot index 1c2827a84e8..3e3072b23ff 100644 --- a/atest/robot/variables/var_syntax.robot +++ b/atest/robot/variables/var_syntax.robot @@ -46,6 +46,12 @@ Non-existing variable in value Non-existing variable in separator Check Test Case ${TESTNAME} +Named based on another variable + Check Test Case ${TESTNAME} + +Non-existing variable in name + Check Test Case ${TESTNAME} + With FOR Check Test Case ${TESTNAME} diff --git a/atest/robot/variables/variable_section.robot b/atest/robot/variables/variable_section.robot index 49e60467a28..75191382b0c 100644 --- a/atest/robot/variables/variable_section.robot +++ b/atest/robot/variables/variable_section.robot @@ -48,12 +48,22 @@ Assign Mark With List Variable Three dots on the same line should be interpreted as string Check Test Case ${TEST NAME} +Named based on another variable + Check Test Case ${TEST NAME} + +Non-existing variable in name + Creating Variable Should Have Failed 0 32 \${BASED ON \${BAD}} + ... Variable '\${BAD}' not found. + Invalid variable name - Parsing Variable Should Have Failed 0 16 Invalid Name - Parsing Variable Should Have Failed 1 17 \${} - Parsing Variable Should Have Failed 2 18 \${not - Parsing Variable Should Have Failed 3 19 \${not}[[]ok] - Parsing Variable Should Have Failed 4 20 \${not \${ok}} + Creating Variable Should Have Failed 1 33 Invalid Name + ... Invalid variable name 'Invalid Name'. + Creating Variable Should Have Failed 2 34 \${} + ... Invalid variable name '\${}'. + Creating Variable Should Have Failed 3 35 \${not + ... Invalid variable name '\${not'. + Creating Variable Should Have Failed 4 36 \${not}[[]ok] + ... Invalid variable name '\${not}[[]ok]'. Scalar catenated from multiple values Check Test Case ${TEST NAME} @@ -66,36 +76,30 @@ Scalar catenated from multiple values with 'separator' option Creating variable using non-existing variable fails Check Test Case ${TEST NAME} - Creating Variable Should Have Failed 8 \${NONEX 1} 35 + Creating Variable Should Have Failed 8 37 \${NONEX 1} ... Variable '\${NON EXISTING}' not found. - Creating Variable Should Have Failed 9 \${NONEX 2A} 36 + Creating Variable Should Have Failed 9 38 \${NONEX 2A} ... Variable '\${NON EX}' not found.* - Creating Variable Should Have Failed 10 \${NONEX 2B} 37 + Creating Variable Should Have Failed 10 39 \${NONEX 2B} ... Variable '\${NONEX 2A}' not found.* Using variable created from non-existing variable in imports fails - Creating Variable Should Have Failed 5 \${NONEX 3} 38 + Creating Variable Should Have Failed 5 40 \${NONEX 3} ... Variable '\${NON EXISTING VARIABLE}' not found. - Import Should Have Failed 6 Resource 41 + Import Should Have Failed 6 43 Resource ... Variable '\${NONEX 3}' not found.* - Import Should Have Failed 7 Library 42 + Import Should Have Failed 7 44 Library ... Variable '\${NONEX 3}' not found.* *** Keywords *** -Parsing Variable Should Have Failed - [Arguments] ${index} ${lineno} ${name} - Error In File ${index} variables/variable_section.robot ${lineno} - ... Setting variable '${name}' failed: - ... Invalid variable name '${name}'. - Creating Variable Should Have Failed - [Arguments] ${index} ${name} ${lineno} @{message} + [Arguments] ${index} ${lineno} ${name} @{message} Error In File ${index} variables/variable_section.robot ${lineno} ... Setting variable '${name}' failed: ... @{message} Import Should Have Failed - [Arguments] ${index} ${name} ${lineno} @{message} + [Arguments] ${index} ${lineno} ${name} @{message} Error In File ${index} variables/variable_section.robot ${lineno} ... Replacing variables from setting '${name}' failed: ... @{message} diff --git a/atest/testdata/running/if/inline_if_else.robot b/atest/testdata/running/if/inline_if_else.robot index 1b8878de95a..ca53fee178b 100644 --- a/atest/testdata/running/if/inline_if_else.robot +++ b/atest/testdata/running/if/inline_if_else.robot @@ -89,6 +89,11 @@ Dict assign &{x} = IF False Not run ELSE Create dictionary a=1 b=2 Should Be True ${x} == {'a': '1', 'b': '2'} +Assign based on another variable + VAR ${x} y + ${${x}} = IF True Set Variable Y ELSE Not run + Should Be Equal ${y} Y + Assign without ELSE ${x} = IF True Set variable Hello! Should Be Equal ${x} Hello! diff --git a/atest/testdata/running/if/invalid_inline_if.robot b/atest/testdata/running/if/invalid_inline_if.robot index f711839f7f5..f45e12aa452 100644 --- a/atest/testdata/running/if/invalid_inline_if.robot +++ b/atest/testdata/running/if/invalid_inline_if.robot @@ -139,11 +139,11 @@ Invalid number of variables in assign ${x} ${y} = IF False Create list x y ELSE Create list x y z Invalid value for list assign - [Documentation] FAIL Cannot set variable '\@{x}': Expected list-like value, got string. + [Documentation] FAIL Setting variable '\@{x}' failed: Expected list-like value, got string. @{x} = IF True Set variable String is not list Invalid value for dict assign - [Documentation] FAIL Cannot set variable '\&{x}': Expected dictionary-like value, got string. + [Documentation] FAIL Setting variable '\&{x}' failed: Expected dictionary-like value, got string. &{x} = IF False Not run ELSE Set variable String is not dict either Assign when IF branch is empty diff --git a/atest/testdata/standard_libraries/builtin/create_dictionary.robot b/atest/testdata/standard_libraries/builtin/create_dictionary.robot index 8fdb0667929..50b59e3fe3e 100644 --- a/atest/testdata/standard_libraries/builtin/create_dictionary.robot +++ b/atest/testdata/standard_libraries/builtin/create_dictionary.robot @@ -3,7 +3,6 @@ &{DICT} a=1 b=${2} ${3}=c ${EQUALS} foo=bar - *** Test Cases *** Empty &{d} = Create Dictionary @@ -74,7 +73,7 @@ Separate keys and values with invalid key Create Dictionary ${NONEX KEY}=${NONEX VALUE} `key=value` syntax with invalid key - [Documentation] FAIL STARTS: Creating dictionary failed: + [Documentation] FAIL STARTS: Creating dictionary variable failed: Create Dictionary ${DICT}=non-hashable `key=value` syntax without equals diff --git a/atest/testdata/variables/return_values.robot b/atest/testdata/variables/return_values.robot index 7e9a74a856d..ddb61c02173 100644 --- a/atest/testdata/variables/return_values.robot +++ b/atest/testdata/variables/return_values.robot @@ -108,11 +108,11 @@ None To List Variable Should Be True ${list} == [] List When Non-List Returned 1 - [Documentation] FAIL Cannot set variable '\@{list}': Expected list-like value, got string. + [Documentation] FAIL Setting variable '\@{list}' failed: Expected list-like value, got string. @{list} = Set Variable kekkonen List When Non-List Returned 2 - [Documentation] FAIL Cannot set variable '\@{list}': Expected list-like value, got integer. + [Documentation] FAIL Setting variable '\@{list}' failed: Expected list-like value, got integer. @{list} = Set Variable ${42} Only One List Variable Allowed 1 @@ -191,7 +191,7 @@ Dictionary is dot-accessible Should Be Equal ${nested.nested.key} nested value Scalar dictionary is not dot-accessible - [Documentation] FAIL STARTS: Resolving variable '${normal.key}' failed: AttributeError: + [Documentation] FAIL STARTS: Resolving variable '\${normal.key}' failed: AttributeError: ${normal} = Evaluate {'key': 'value'} Should Be Equal ${normal['key']} value Should Be Equal ${normal.key} value @@ -217,15 +217,15 @@ Dictionary only allowed alone 5 &{d1} &{d2} = Fail Not executed Dict when non-dict returned 1 - [Documentation] FAIL Cannot set variable '\&{ret}': Expected dictionary-like value, got list. + [Documentation] FAIL Setting variable '\&{ret}' failed: Expected dictionary-like value, got list. &{ret} = Create List Dict when non-dict returned 2 - [Documentation] FAIL Cannot set variable '\&{ret}': Expected dictionary-like value, got string. + [Documentation] FAIL Setting variable '\&{ret}' failed: Expected dictionary-like value, got string. &{ret} = Set variable foo Dict when non-dict returned 3 - [Documentation] FAIL Cannot set variable '\&{ret}': Expected dictionary-like value, got integer. + [Documentation] FAIL Setting variable '\&{ret}' failed: Expected dictionary-like value, got integer. &{ret} = Set variable ${5} Long String To Scalar Variable @@ -298,8 +298,19 @@ Assign Mark Can Be Used Only With The Last Variable [Documentation] FAIL Assign mark '=' can be used only with the last variable. ${v1} = ${v2} = Set Variable a b +Named based on another variable + ${x} = Set Variable y + ${${x}} = Set Variable z + Should Be Equal ${y} z + ${x-${x}-${y}} = Set Variable x-${x}-${y} + Should Be Equal ${x-y-z} x-y-z + +Non-existing variable in name + [Documentation] FAIL Setting variable '\${\${x}}' failed: Variable '\${x}' not found. + ${${x}} = Set Variable z + Files are not lists - [Documentation] FAIL Cannot set variable '\@{works not}': Expected list-like value, got file. + [Documentation] FAIL Setting variable '\@{works not}' failed: Expected list-like value, got file. ${works} = Get open file @{works not} = Get open file @@ -326,16 +337,16 @@ Invalid type error is catchable ... Teardown failed: ... Several failures occurred: ... - ... 1) Cannot set variable '\@{x}': Expected list-like value, got boolean. + ... 1) Setting variable '\@{x}' failed: Expected list-like value, got boolean. ... - ... 2) Cannot set variable '\&{x}': Expected dictionary-like value, got string. + ... 2) Setting variable '\&{x}' failed: Expected dictionary-like value, got string. ... ... 3) Also this is executed! Run Keyword And Expect Error - ... Cannot set variable '\@{x}': Expected list-like value, got string. + ... Setting variable '\@{x}' failed: Expected list-like value, got string. ... Assign list variable not list Run Keyword And Expect Error - ... Cannot set variable '\&{x}': Expected dictionary-like value, got integer. + ... Setting variable '\&{x}' failed: Expected dictionary-like value, got integer. ... Assign dict variable ${42} [Teardown] Run Keywords ... Assign list variable ${False} AND diff --git a/atest/testdata/variables/var_syntax.robot b/atest/testdata/variables/var_syntax.robot index 07ef2bba560..09978bba356 100644 --- a/atest/testdata/variables/var_syntax.robot +++ b/atest/testdata/variables/var_syntax.robot @@ -55,13 +55,24 @@ Non-existing variable as scope VAR ${x} x scope=${invalid} Non-existing variable in value - [Documentation] FAIL Setting variable '\${x} failed: Variable '\${bad}' not found. + [Documentation] FAIL Setting variable '\${x}' failed: Variable '\${bad}' not found. VAR ${x} ${bad} Non-existing variable in separator - [Documentation] FAIL Setting variable '\${x} failed: Variable '\${bad}' not found. + [Documentation] FAIL Setting variable '\${x}' failed: Variable '\${bad}' not found. VAR ${x} a b separator=${bad} +Named based on another variable + VAR ${x} y + VAR ${${x}} z + VAR ${x-${x}-${y}} x-y-z + Should Be Equal ${y} z + Should Be Equal ${x-y-z} x-y-z + +Non-existing variable in name + [Documentation] FAIL Setting variable '\${this is \${bad}}' failed: Variable '${\bad}' not found. + VAR ${this is ${bad}} wharever + With FOR FOR ${x} IN a b c VAR ${y} ${x} diff --git a/atest/testdata/variables/variable_section.robot b/atest/testdata/variables/variable_section.robot index af3084a7a22..179509f87b1 100644 --- a/atest/testdata/variables/variable_section.robot +++ b/atest/testdata/variables/variable_section.robot @@ -13,11 +13,6 @@ ${NO VALUE} ${EMPTY} @{LIST CREATED FROM LIST WITH ESCAPES} @{LIST WITH ESCAPES} @{SPACE ESC LIST} \ lead trail \ \ \ 2 \ \ \ \ \ 3 \ \ \ @{EMPTY LIST} -Invalid Name Decoration missing -${} Body missing -${not closed -${not}[ok] This is variable but not valid assign -${not ${ok}} This is variable but not valid assign ${lowercase} Variable name in lower case @{lowercaselist} Variable name in lower case ${S P a c e s } Variable name with spaces @@ -32,6 +27,13 @@ ${CATENATED} By default values are joined with a ${SEPARATOR VALUE} SEPARATOR=- Special SEPARATOR marker as ${1} st value ${SEPARATOR OPTION} Explicit separator option works since RF ${7.0} separator=- ${BOTH SEPARATORS} SEPARATOR=marker has lower precedence than option separator=: +${VAR} existing +${BASED ON ${VAR}} Supported since 7.0 +${BASED ON ${BAD}} Ooop! +Invalid Name Decoration missing +${} Body missing +${not closed +${not}[ok] This is variable but not valid assign ${NONEX 1} Creating variable based on ${NON EXISTING} variable fails. ${NONEX 2A} This ${NON EX} is used for creating another variable. ${NONEX 2B} ${NONEX 2A} @@ -137,6 +139,9 @@ Scalar catenated from multiple values with 'separator' option Should Be Equal ${SEPARATOR OPTION} Explicit-separator-option-works-since-RF-7.0 Should Be Equal ${BOTH SEPARATORS} SEPARATOR=marker:has:lower:precedence:than:option +Named based on another variable + Should Be Equal ${BASED ON EXISTING} Supported since 7.0 + Creating variable using non-existing variable fails Variable Should Not Exist ${NONEX 1} Variable Should Not Exist ${NONEX 2A} diff --git a/doc/userguide/src/CreatingTestData/Variables.rst b/doc/userguide/src/CreatingTestData/Variables.rst index 2b218548953..e1d03323f28 100644 --- a/doc/userguide/src/CreatingTestData/Variables.rst +++ b/doc/userguide/src/CreatingTestData/Variables.rst @@ -586,6 +586,22 @@ dictionary keys. For example, `@{MANY}` variable would have value `['first', __ Escaping_ +Creating variable based on another variable +''''''''''''''''''''''''''''''''''''''''''' + +Starting from Robot Framework 7.0, it is possible to create the variable name +dynamically based on another variable: + +.. sourcecode:: robotframework + + *** Variables *** + ${X} Y + ${${X}} Z # Name is created based on '${X}'. + + *** Test Cases *** + Dynamically created name + Should Be Equal ${Y} Z + Variable file ~~~~~~~~~~~~~ @@ -685,25 +701,39 @@ Assigning variables with item values Starting from Robot Framework 6.1, when working with variables that support item assignment such as lists or dictionaries, it is possible to set their values -by specifying the index or key of the item using the syntax `${var}[index]=`: +by specifying the index or key of the item using the syntax `${var}[item]` +where the `item` part can itself contain a variable: .. sourcecode:: robotframework *** Test Cases *** Item assignment to list - ${list} = Create List one two three four - ${list}[0] = Set Variable first - ${list}[${1}] = Set Variable second - ${list}[2:3] = Evaluate ['third'] - ${list}[-1] = Set Variable last - Log Many @{list} # Logs 'first', 'second', 'third' and 'last' + ${list} = Create List one two three four + ${list}[0] = Set Variable first + ${list}[${1}] = Set Variable second + ${list}[2:3] = Evaluate ['third'] + ${list}[-1] = Set Variable last + Log Many @{list} # Logs 'first', 'second', 'third' and 'last' Item assignment to dictionary - ${dictionary} = Create Dictionary first_name=unknown - ${dictionary}[first_name] = Set Variable John - ${dictionary}[last_name] = Set Variable Doe - Log ${dictionary} # Logs {'first_name': 'John', 'last_name': 'Doe'} + ${dict} = Create Dictionary first_name=unknown + ${dict}[first_name] = Set Variable John + ${dict}[last_name] = Set Variable Doe + Log ${dictionary} # Logs {'first_name': 'John', 'last_name': 'Doe'} + +Creating variable based on another variable +''''''''''''''''''''''''''''''''''''''''''' + +Starting from Robot Framework 7.0, it is possible to create the name of the assigned +variable dynamically based on another variable: +.. sourcecode:: robotframework + + *** Test Cases *** + Dynamically created name + ${x} = Set Variable y + ${${x}} = Set Variable z # Name is created based on '${x}'. + Should Be Equal ${y} z Assigning list variables '''''''''''''''''''''''' diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 3a993547ea1..9fd3f76d8ec 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -206,7 +206,8 @@ def _lex_as_keyword_call(self): for token in self.statement: if keyword_seen: token.type = Token.ARGUMENT - elif is_assign(token.value, allow_assign_mark=True, allow_items=True): + elif is_assign(token.value, allow_assign_mark=True, allow_nested=True, + allow_items=True): token.type = Token.ASSIGN else: token.type = Token.KEYWORD @@ -250,7 +251,8 @@ def handles(self, statement: StatementTokens) -> bool: for token in statement: if token.value == 'IF': return True - if not is_assign(token.value, allow_assign_mark=True, allow_items=True): + if not is_assign(token.value, allow_assign_mark=True, allow_nested=True, + allow_items=True): return False return False diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py index 7a03033dfea..489ab1cc21c 100644 --- a/src/robot/running/bodyrunner.py +++ b/src/robot/running/bodyrunner.py @@ -23,8 +23,7 @@ from robot.errors import (BreakLoop, ContinueLoop, DataError, ExecutionFailed, ExecutionFailures, ExecutionPassed, ExecutionStatus) from robot.result import (For as ForResult, While as WhileResult, If as IfResult, - IfBranch as IfBranchResult, Try as TryResult, - TryBranch as TryBranchResult) + Try as TryResult) from robot.output import librarylogger as logger from robot.utils import (cut_assign_value, frange, get_error_message, is_list_like, is_number, plural_or_not as s, secs_to_timestr, seq2str, diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 2a32fed5d39..6b8c0a31fff 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -40,7 +40,7 @@ from robot import model from robot.conf import RobotSettings -from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword +from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword, VariableError from robot.model import BodyItem, create_fixture, DataDict, ModelObject, TestSuites from robot.output import LOGGER, Output, pyloggingconf from robot.result import (Break as BreakResult, Continue as ContinueResult, @@ -291,11 +291,11 @@ def run(self, context, run=True, templated=False): if not context.dry_run: scope = self._get_scope(context.variables) setter = getattr(context.variables, f'set_{scope}') - resolver = VariableResolver.from_variable(self) try: + resolver = VariableResolver.from_variable(self) setter(self.name, resolver.resolve(context.variables)) except DataError as err: - raise DataError(f"Setting variable '{self.name} failed: {err}") + raise VariableError(f"Setting variable '{self.name}' failed: {err}") def _get_scope(self, variables): if not self.scope: diff --git a/src/robot/variables/assigner.py b/src/robot/variables/assigner.py index 2b62ecc90f9..eaf1fdf5bd8 100644 --- a/src/robot/variables/assigner.py +++ b/src/robot/variables/assigner.py @@ -197,7 +197,10 @@ def _item_assign(self, name, items, value, variables): return value def _normal_assign(self, name, value, variables): - variables[name] = value + try: + variables[name] = value + except DataError as err: + raise VariableError(f"Setting variable '{name}' failed: {err}") # Always return the actually assigned value. return value if name[0] == '$' else variables[name] diff --git a/src/robot/variables/filesetter.py b/src/robot/variables/filesetter.py index f5d8683a2bc..c874bb774e4 100644 --- a/src/robot/variables/filesetter.py +++ b/src/robot/variables/filesetter.py @@ -26,11 +26,13 @@ from robot.utils import (DotDict, get_error_message, Importer, is_dict_like, is_list_like, type_name) +from .store import VariableStore + class VariableFileSetter: - def __init__(self, store): - self._store = store + def __init__(self, store: VariableStore): + self.store = store def set(self, path_or_variables, args=None, overwrite=False): variables = self._import_if_needed(path_or_variables, args) @@ -56,7 +58,7 @@ def _import_if_needed(self, path_or_variables, args=None): def _set(self, variables, overwrite=False): for name, value in variables: - self._store.add(name, value, overwrite) + self.store.add(name, value, overwrite, decorated=False) class PythonImporter: @@ -106,14 +108,14 @@ def _decorate_and_validate(self, variables): if not is_list_like(value): raise DataError(f"Invalid variable '{name}': Expected a " f"list-like value, got {type_name(value)}.") - name = f'@{{{name[6:]}}}' + name = name[6:] + value = list(value) elif name.startswith('DICT__'): if not is_dict_like(value): raise DataError(f"Invalid variable '{name}': Expected a " f"dictionary-like value, got {type_name(value)}.") - name = f'&{{{name[6:]}}}' - else: - name = f'${{{name}}}' + name = name[6:] + value = DotDict(value) yield name, value @@ -123,8 +125,7 @@ def import_variables(self, path, args=None): if args: raise DataError('JSON variable files do not accept arguments.') variables = self._import(path) - return [(f'${{{name}}}', self._dot_dict(value)) - for name, value in variables] + return [(name, self._dot_dict(value)) for name, value in variables] def _import(self, path): with io.open(path, encoding='UTF-8') as stream: @@ -148,7 +149,7 @@ def import_variables(self, path, args=None): if args: raise DataError('YAML variable files do not accept arguments.') variables = self._import(path) - return [(f'${{{name}}}', self._dot_dict(value)) for name, value in variables] + return [(name, self._dot_dict(value)) for name, value in variables] def _import(self, path): with io.open(path, encoding='UTF-8') as stream: diff --git a/src/robot/variables/search.py b/src/robot/variables/search.py index 1bc722a717e..fd269727baf 100644 --- a/src/robot/variables/search.py +++ b/src/robot/variables/search.py @@ -121,7 +121,7 @@ def is_dict_variable(self): def is_assign(self, allow_assign_mark=False, allow_nested=False, allow_items=False): if allow_assign_mark and self.string.endswith('='): match = search_variable(self.string[:-1].rstrip(), ignore_errors=True) - return match.is_assign(allow_items=allow_items) + return match.is_assign(allow_nested=allow_nested, allow_items=allow_items) return (self.is_variable() and self.identifier in '$@&' and (allow_items or not self.items) diff --git a/src/robot/variables/store.py b/src/robot/variables/store.py index 0aa24e548c3..5c210f4cfee 100644 --- a/src/robot/variables/store.py +++ b/src/robot/variables/store.py @@ -19,7 +19,7 @@ from .notfound import variable_not_found from .resolvable import GlobalVariableValue, Resolvable -from .search import is_assign +from .search import is_assign, unescape_variable_syntax class VariableStore: @@ -66,7 +66,7 @@ def get(self, name, default=NOT_SET, decorated=True): if decorated: name = self._undecorate(name) return self[name] - except VariableError: + except DataError: if default is NOT_SET: raise return default @@ -86,26 +86,26 @@ def add(self, name, value, overwrite=True, decorated=True): self.data[name] = value def _undecorate(self, name): - if not is_assign(name): - raise VariableError("Invalid variable name '%s'." % name) - return name[2:-1] + if not is_assign(name, allow_nested=True): + raise DataError(f"Invalid variable name '{name}'.") + return self._variables.replace_string( + name[2:-1], custom_unescaper=unescape_variable_syntax + ) def _undecorate_and_validate(self, name, value): undecorated = self._undecorate(name) + if isinstance(value, Resolvable): + return undecorated, value if name[0] == '@': if not is_list_like(value): - self._raise_cannot_set_type(name, value, 'list') + raise DataError(f'Expected list-like value, got {type_name(value)}.') value = list(value) if name[0] == '&': if not is_dict_like(value): - self._raise_cannot_set_type(name, value, 'dictionary') + raise DataError(f'Expected dictionary-like value, got {type_name(value)}.') value = DotDict(value) return undecorated, value - def _raise_cannot_set_type(self, name, value, expected): - raise VariableError("Cannot set variable '%s': Expected %s-like value, got %s." - % (name, expected, type_name(value))) - def __len__(self): return len(self.data) diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index fc1d13820a7..4c6e553a163 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -24,31 +24,27 @@ if TYPE_CHECKING: from robot.running.model import Var, Variable + from .store import VariableStore class VariableTableSetter: - def __init__(self, store): - self._store = store + def __init__(self, store: 'VariableStore'): + self.store = store def set(self, variables: 'Sequence[Variable]', overwrite: bool = False): - for name, value in self._get_items(variables): - self._store.add(name, value, overwrite, decorated=False) - - def _get_items(self, variables: 'Sequence[Variable]'): for var in variables: try: value = VariableResolver.from_variable(var) + self.store.add(var.name, value, overwrite) except DataError as err: var.report_error(str(err)) - else: - yield var.name[2:-1], value class VariableResolver(Resolvable): def __init__(self, value: Sequence[str], error_reporter=None): - self.value = value + self.value = tuple(value) self.error_reporter = error_reporter self._resolving = False @@ -56,7 +52,7 @@ def __init__(self, value: Sequence[str], error_reporter=None): def from_name_and_value(cls, name: str, value: 'str|Sequence[str]', separator: 'str|None' = None, error_reporter=None) -> 'VariableResolver': - if not is_assign(name): + if not is_assign(name, allow_nested=True): raise DataError(f"Invalid variable name '{name}'.") if name[0] == '$': return ScalarVariableResolver(value, separator, error_reporter) @@ -95,7 +91,7 @@ def report_error(self, error): if self.error_reporter: self.error_reporter(error) else: - raise DataError(f'Error reported not set. Reported error was: {error}') + raise DataError(f'Error reporter not set. Reported error was: {error}') class ScalarVariableResolver(VariableResolver): @@ -157,7 +153,7 @@ def _replace_variables(self, variables): try: return DotDict(self._yield_replaced(self.value, variables.replace_scalar)) except TypeError as err: - raise DataError(f'Creating dictionary failed: {err}') + raise DataError(f'Creating dictionary variable failed: {err}') def _yield_replaced(self, values, replace_scalar): for item in values: @@ -165,5 +161,4 @@ def _yield_replaced(self, values, replace_scalar): key, values = item yield replace_scalar(key), replace_scalar(values) else: - for key, values in replace_scalar(item).items(): - yield key, values + yield from replace_scalar(item).items() From 548099f61ab27e0b6b93aef21a2ef7f0d545c279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 20 Oct 2023 00:04:57 +0300 Subject: [PATCH 0788/1592] rm codecov config. We haven't used codecov for some time. --- codecov.yml | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 8fe9aed8100..00000000000 --- a/codecov.yml +++ /dev/null @@ -1,7 +0,0 @@ -coverage: - round: up - range: 0..10 - -ignore: - - "utest" - - "atest" From e1b138b6ece14fa0544e5b4b3106860d9f6c0ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 20 Oct 2023 00:32:32 +0300 Subject: [PATCH 0789/1592] Enhance error reporting if expression evaluation fails. If we see `RF_VAR_xxx` in an error message, `$xxx` has been used in a scope where it cannot be seen. Mention that these variables aren't seen everywhere also in the User Guide. Fixes #4898. --- .../robot/standard_libraries/builtin/evaluate.robot | 6 ++++++ .../standard_libraries/builtin/evaluate.robot | 12 ++++++++++++ .../src/Appendices/EvaluatingExpressions.rst | 5 +++++ src/robot/variables/evaluation.py | 12 +++++++++--- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/evaluate.robot b/atest/robot/standard_libraries/builtin/evaluate.robot index c642d379c0b..d405715b1e9 100644 --- a/atest/robot/standard_libraries/builtin/evaluate.robot +++ b/atest/robot/standard_libraries/builtin/evaluate.robot @@ -111,5 +111,11 @@ Evaluate Nonstring Evaluate doesn't see module globals Check Test Case ${TESTNAME} +Automatic variables are not seen in expression part of comprehensions + Check Test Case ${TESTNAME} + +Automatic variables are not seen inside lambdas + Check Test Case ${TESTNAME} + Evaluation errors can be caught Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/builtin/evaluate.robot b/atest/testdata/standard_libraries/builtin/evaluate.robot index 2c6b519acb1..3593933da87 100644 --- a/atest/testdata/standard_libraries/builtin/evaluate.robot +++ b/atest/testdata/standard_libraries/builtin/evaluate.robot @@ -278,6 +278,18 @@ Evaluate doesn't see module globals [Documentation] FAIL STARTS: Evaluating expression 'DataError' failed: NameError: Evaluate DataError +Automatic variables are not seen in expression part of comprehensions + [Documentation] FAIL Evaluating expression '[$x + x for x in 'abc']' failed: \ + ... Robot Framework variable '$x' used in the expression part of a comprehension or some other scope where it cannot be seen. + VAR ${x} + Evaluate [$x + x for x in 'abc'] + +Automatic variables are not seen inside lambdas + [Documentation] FAIL Evaluating expression '(lambda: $x)()' failed: \ + ... Robot Framework variable '$x' used in the expression part of a comprehension or some other scope where it cannot be seen. + VAR ${x} + Evaluate (lambda: $x)() + Evaluation errors can be caught FOR ${invalid} IN ooops 1/0 $ $nonex len(None) ${EMPTY} ${7} ${err1} = Run Keyword And Expect Error * Evaluate ${invalid} diff --git a/doc/userguide/src/Appendices/EvaluatingExpressions.rst b/doc/userguide/src/Appendices/EvaluatingExpressions.rst index 5f0bb21d192..e12d820e37f 100644 --- a/doc/userguide/src/Appendices/EvaluatingExpressions.rst +++ b/doc/userguide/src/Appendices/EvaluatingExpressions.rst @@ -178,3 +178,8 @@ This should not typically matter, but should be taken into account if complex expressions are evaluated often and there are strict time constrains. Moving such logic to test libraries is typically a good idea anyway. + +.. note:: Due to technical reasons, these special variables are available during + evaluation as local variables. That makes them unavailable in non-local + scopes such as in the expression part of list comprehensions and inside + lambdas. diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py index afff6713d1f..db3c8825495 100644 --- a/src/robot/variables/evaluation.py +++ b/src/robot/variables/evaluation.py @@ -14,6 +14,7 @@ # limitations under the License. import builtins +import re import token from collections.abc import MutableMapping from io import StringIO @@ -32,6 +33,7 @@ def evaluate_expression(expression, variables, modules=None, namespace=None, resolve_variables=False): original = expression + recommendation = '' try: if not isinstance(expression, str): raise TypeError(f'Expression must be string, got {type_name(expression)}.') @@ -44,10 +46,14 @@ def evaluate_expression(expression, variables, modules=None, namespace=None, return _evaluate(expression, variables.store, modules, namespace) except DataError as err: error = str(err) - recommendation = '' - except Exception: + except Exception as err: error = get_error_message() - recommendation = _recommend_special_variables(original) + if isinstance(err, NameError) and 'RF_VAR_' in error: + name = re.search(r'RF_VAR_([\w_]*)', error).group(1) + error = (f"Robot Framework variable '${name}' used in the expression part " + f"of a comprehension or some other scope where it cannot be seen.") + else: + recommendation = '\n\n' + _recommend_special_variables(original) raise DataError(f"Evaluating expression '{expression}' failed: {error}\n\n" f"{recommendation}".strip()) From a66242ab54db6b6109e814c3d1e4daf0142de5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 23 Oct 2023 19:01:44 +0300 Subject: [PATCH 0790/1592] Remove extra newlines from error message. Newlines left accidentally after refactoring. --- src/robot/variables/evaluation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py index db3c8825495..d3f49f1e232 100644 --- a/src/robot/variables/evaluation.py +++ b/src/robot/variables/evaluation.py @@ -33,7 +33,6 @@ def evaluate_expression(expression, variables, modules=None, namespace=None, resolve_variables=False): original = expression - recommendation = '' try: if not isinstance(expression, str): raise TypeError(f'Expression must be string, got {type_name(expression)}.') @@ -46,16 +45,18 @@ def evaluate_expression(expression, variables, modules=None, namespace=None, return _evaluate(expression, variables.store, modules, namespace) except DataError as err: error = str(err) + variable_recommendation = '' except Exception as err: error = get_error_message() + variable_recommendation = '' if isinstance(err, NameError) and 'RF_VAR_' in error: name = re.search(r'RF_VAR_([\w_]*)', error).group(1) error = (f"Robot Framework variable '${name}' used in the expression part " f"of a comprehension or some other scope where it cannot be seen.") else: - recommendation = '\n\n' + _recommend_special_variables(original) + variable_recommendation = _recommend_special_variables(original) raise DataError(f"Evaluating expression '{expression}' failed: {error}\n\n" - f"{recommendation}".strip()) + f"{variable_recommendation}".strip()) def _evaluate(expression, variable_store, modules=None, namespace=None): From 2e93178286fff78bec12e0c3a8c9df3c6e2aaf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 23 Oct 2023 19:06:36 +0300 Subject: [PATCH 0791/1592] Refactor - Type hints to `robot.variables.search` module. - Rename `VariableIterator` to `VariableMatch` and change it to yield match objects instead of a three tuple of match parts. --- src/robot/parsing/lexer/tokens.py | 28 +++--- src/robot/running/arguments/embedded.py | 12 +-- src/robot/running/builder/transformers.py | 11 ++- src/robot/utils/escaping.py | 20 +++-- src/robot/variables/__init__.py | 2 +- src/robot/variables/evaluation.py | 16 ++-- src/robot/variables/search.py | 100 +++++++++++++--------- utest/running/test_handlers.py | 2 +- utest/variables/test_search.py | 54 ++++++------ 9 files changed, 136 insertions(+), 109 deletions(-) diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 25dbaae52a3..eebc54f8855 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -16,7 +16,7 @@ from collections.abc import Iterator from typing import cast, List -from robot.variables import VariableIterator +from robot.variables import VariableMatches # Type alias to ease typing elsewhere @@ -207,26 +207,26 @@ def tokenize_variables(self) -> 'Iterator[Token]': """ if self.type not in Token.ALLOW_VARIABLES: return self._tokenize_no_variables() - variables = VariableIterator(self.value) - if not variables: + matches = VariableMatches(self.value) + if not matches: return self._tokenize_no_variables() - return self._tokenize_variables(variables) + return self._tokenize_variables(matches) def _tokenize_no_variables(self) -> 'Iterator[Token]': yield self - def _tokenize_variables(self, variables) -> 'Iterator[Token]': + def _tokenize_variables(self, matches) -> 'Iterator[Token]': lineno = self.lineno col_offset = self.col_offset - remaining = '' - for before, variable, remaining in variables: - if before: - yield Token(self.type, before, lineno, col_offset) - col_offset += len(before) - yield Token(Token.VARIABLE, variable, lineno, col_offset) - col_offset += len(variable) - if remaining: - yield Token(self.type, remaining, lineno, col_offset) + after = '' + for match in matches: + if match.before: + yield Token(self.type, match.before, lineno, col_offset) + yield Token(Token.VARIABLE, match.match, lineno, col_offset + match.start) + col_offset += match.end + after = match.after + if after: + yield Token(self.type, after, lineno, col_offset) def __str__(self) -> str: return self.value diff --git a/src/robot/running/arguments/embedded.py b/src/robot/running/arguments/embedded.py index 1301b93741c..7ceb243d6df 100644 --- a/src/robot/running/arguments/embedded.py +++ b/src/robot/running/arguments/embedded.py @@ -17,7 +17,7 @@ from robot.errors import DataError from robot.utils import get_error_message, is_string -from robot.variables import VariableIterator +from robot.variables import VariableMatches from ..context import EXECUTION_CONTEXTS @@ -79,14 +79,16 @@ def parse(self, string): args = [] custom_patterns = {} name_regexp = ['^'] - for before, variable, string in VariableIterator(string, identifiers='$'): - name, pattern, custom = self._get_name_and_pattern(variable[2:-1]) + after = string + for match in VariableMatches(string, identifiers='$'): + name, pattern, custom = self._get_name_and_pattern(match.base) args.append(name) if custom: custom_patterns[name] = pattern pattern = self._format_custom_regexp(pattern) - name_regexp.extend([re.escape(before), f'({pattern})']) - name_regexp.extend([re.escape(string), '$']) + name_regexp.extend([re.escape(match.before), f'({pattern})']) + after = match.after + name_regexp.extend([re.escape(after), '$']) name = self._compile_regexp(name_regexp) if args else None return EmbeddedArguments(name, args, custom_patterns or None) diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index be2170f8b1a..1858c599080 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -18,7 +18,7 @@ from robot.errors import DataError from robot.output import LOGGER from robot.parsing import File, Token -from robot.variables import VariableIterator +from robot.variables import VariableMatches from .settings import FileSettings from ..model import (For, If, IfBranch, ResourceFile, TestSuite, TestCase, Try, @@ -250,14 +250,13 @@ def _set_template(self, parent, template): item.args = args def _format_template(self, template, arguments): - variables = VariableIterator(template, identifiers='$') - count = len(variables) + matches = VariableMatches(template, identifiers='$') + count = len(matches) if count == 0 or count != len(arguments): return template, arguments temp = [] - for (before, _, after), arg in zip(variables, arguments): - temp.extend([before, arg]) - temp.append(after) + for match, arg in zip(matches, arguments): + temp[-1:] = [match.before, arg, match.after] return ''.join(temp), () def visit_Documentation(self, node): diff --git a/src/robot/utils/escaping.py b/src/robot/utils/escaping.py index 7656aeb6461..4b415e67dc2 100644 --- a/src/robot/utils/escaping.py +++ b/src/robot/utils/escaping.py @@ -94,27 +94,29 @@ def _handle_escapes(self, match): def split_from_equals(string): - from robot.variables import VariableIterator + from robot.variables import VariableMatches if not is_string(string) or '=' not in string: return string, None - variables = VariableIterator(string, ignore_errors=True) - if not variables and '\\' not in string: + matches = VariableMatches(string, ignore_errors=True) + if not matches and '\\' not in string: return tuple(string.split('=', 1)) try: - index = _find_split_index(string, variables) + index = _find_split_index(string, matches) except ValueError: return string, None return string[:index], string[index+1:] -def _find_split_index(string, variables): +def _find_split_index(string, matches): + remaining = string relative_index = 0 - for before, match, string in variables: + for match in matches: try: - return _find_split_index_from_part(before) + relative_index + return _find_split_index_from_part(match.before) + relative_index except ValueError: - relative_index += len(before) + len(match) - return _find_split_index_from_part(string) + relative_index + remaining = match.after + relative_index += match.end + return _find_split_index_from_part(remaining) + relative_index def _find_split_index_from_part(string): diff --git a/src/robot/variables/__init__.py b/src/robot/variables/__init__.py index 2cb4a44fdd7..c51caf93950 100644 --- a/src/robot/variables/__init__.py +++ b/src/robot/variables/__init__.py @@ -28,6 +28,6 @@ is_scalar_variable, is_scalar_assign, is_dict_variable, is_dict_assign, is_list_variable, is_list_assign, - VariableIterator) + VariableMatches) from .tablesetter import VariableResolver, DictVariableResolver from .variables import Variables diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py index d3f49f1e232..ac14221a2ee 100644 --- a/src/robot/variables/evaluation.py +++ b/src/robot/variables/evaluation.py @@ -23,7 +23,7 @@ from robot.errors import DataError from robot.utils import get_error_message, type_name -from .search import search_variable +from .search import VariableMatches from .notfound import variable_not_found @@ -113,16 +113,12 @@ def _import_modules(module_names): def _recommend_special_variables(expression): - example = [] - remaining = expression - while True: - match = search_variable(remaining) - if not match: - break - example[-1:] = [match.before, match.identifier, match.base, match.after] - remaining = example[-1] - if not example: + matches = VariableMatches(expression) + if not matches: return '' + example = [] + for match in matches: + example[-1:] += [match.before, match.identifier, match.base, match.after] example = ''.join(example) return (f"Variables in the original expression '{expression}' were resolved " f"before the expression was evaluated. Try using '{example}' " diff --git a/src/robot/variables/search.py b/src/robot/variables/search.py index fd269727baf..0a371f4fe99 100644 --- a/src/robot/variables/search.py +++ b/src/robot/variables/search.py @@ -14,63 +14,79 @@ # limitations under the License. import re +from typing import Iterator, Sequence from robot.errors import VariableError from robot.utils import is_string -def search_variable(string, identifiers='$@&%*', ignore_errors=False): +def search_variable(string: str, identifiers: Sequence[str] = '$@&%*', + ignore_errors: bool = False) -> 'VariableMatch': if not (is_string(string) and '{' in string): return VariableMatch(string) return _search_variable(string, identifiers, ignore_errors) -def contains_variable(string, identifiers='$@&'): +def contains_variable(string: str, identifiers: Sequence[str] = '$@&') -> bool: match = search_variable(string, identifiers, ignore_errors=True) return bool(match) -def is_variable(string, identifiers='$@&'): +def is_variable(string: str, identifiers: Sequence[str] = '$@&') -> bool: match = search_variable(string, identifiers, ignore_errors=True) return match.is_variable() -def is_scalar_variable(string): +def is_scalar_variable(string: str) -> bool: return is_variable(string, '$') -def is_list_variable(string): +def is_list_variable(string: str) -> bool: return is_variable(string, '@') -def is_dict_variable(string): +def is_dict_variable(string: str) -> bool: return is_variable(string, '&') -def is_assign(string, identifiers='$@&', allow_assign_mark=False, - allow_nested=False, allow_items=False): +def is_assign(string: str, + identifiers: Sequence[str] = '$@&', + allow_assign_mark: bool = False, + allow_nested: bool = False, + allow_items: bool = False) -> bool: match = search_variable(string, identifiers, ignore_errors=True) return match.is_assign(allow_assign_mark, allow_nested, allow_items) -def is_scalar_assign(string, allow_assign_mark=False, allow_nested=False, - allow_items=False): +def is_scalar_assign(string: str, + allow_assign_mark: bool = False, + allow_nested: bool = False, + allow_items: bool = False) -> bool: return is_assign(string, '$', allow_assign_mark, allow_nested, allow_items) -def is_list_assign(string, allow_assign_mark=False, allow_nested=False, - allow_items=False): +def is_list_assign(string: str, + allow_assign_mark: bool = False, + allow_nested: bool = False, + allow_items: bool = False) -> bool: return is_assign(string, '@', allow_assign_mark, allow_nested, allow_items) -def is_dict_assign(string, allow_assign_mark=False, allow_nested=False, - allow_items=False): +def is_dict_assign(string: str, + allow_assign_mark: bool = False, + allow_nested: bool = False, + allow_items: bool = False) -> bool: return is_assign(string, '&', allow_assign_mark, allow_nested, allow_items) class VariableMatch: - def __init__(self, string, identifier=None, base=None, items=(), start=-1, end=-1): + def __init__(self, string: str, + identifier: 'str|None' = None, + base: 'str|None' = None, + items: 'tuple[str, ...]' = (), + start: int = -1, + end: int = -1): self.string = string self.identifier = identifier self.base = base @@ -88,37 +104,38 @@ def resolve_base(self, variables, ignore_errors=False): ) @property - def name(self): - return '%s{%s}' % (self.identifier, self.base) if self else None + def name(self) -> 'str|None': + return f'{self.identifier}{{{self.base}}}' if self.identifier else None @property - def before(self): + def before(self) -> str: return self.string[:self.start] if self.identifier else self.string @property - def match(self): + def match(self) -> 'str|None': return self.string[self.start:self.end] if self.identifier else None @property - def after(self): - return self.string[self.end:] if self.identifier else None + def after(self) -> str: + return self.string[self.end:] if self.identifier else '' - def is_variable(self): + def is_variable(self) -> bool: return bool(self.identifier and self.base and self.start == 0 and self.end == len(self.string)) - def is_scalar_variable(self): + def is_scalar_variable(self) -> bool: return self.identifier == '$' and self.is_variable() - def is_list_variable(self): + def is_list_variable(self) -> bool: return self.identifier == '@' and self.is_variable() - def is_dict_variable(self): + def is_dict_variable(self) -> bool: return self.identifier == '&' and self.is_variable() - def is_assign(self, allow_assign_mark=False, allow_nested=False, allow_items=False): + def is_assign(self, allow_assign_mark: bool = False, allow_nested: bool = False, + allow_items: bool = False) -> bool: if allow_assign_mark and self.string.endswith('='): match = search_variable(self.string[:-1].rstrip(), ignore_errors=True) return match.is_assign(allow_nested=allow_nested, allow_items=allow_items) @@ -127,26 +144,30 @@ def is_assign(self, allow_assign_mark=False, allow_nested=False, allow_items=Fal and (allow_items or not self.items) and (allow_nested or not search_variable(self.base))) - def is_scalar_assign(self, allow_assign_mark=False, allow_nested=False): + def is_scalar_assign(self, allow_assign_mark: bool = False, + allow_nested: bool = False) -> bool: return self.identifier == '$' and self.is_assign(allow_assign_mark, allow_nested) - def is_list_assign(self, allow_assign_mark=False, allow_nested=False): + def is_list_assign(self, allow_assign_mark: bool = False, + allow_nested: bool = False) -> bool: return self.identifier == '@' and self.is_assign(allow_assign_mark, allow_nested) - def is_dict_assign(self, allow_assign_mark=False, allow_nested=False): + def is_dict_assign(self, allow_assign_mark: bool = False, + allow_nested: bool = False) -> bool: return self.identifier == '&' and self.is_assign(allow_assign_mark, allow_nested) - def __bool__(self): + def __bool__(self) -> bool: return self.identifier is not None - def __str__(self): + def __str__(self) -> str: if not self: return '' items = ''.join('[%s]' % i for i in self.items) if self.items else '' return '%s{%s}%s' % (self.identifier, self.base, items) -def _search_variable(string, identifiers, ignore_errors=False): +def _search_variable(string: str, identifiers: Sequence[str], + ignore_errors: bool = False) -> VariableMatch: start = _find_variable_start(string, identifiers) if start < 0: return VariableMatch(string) @@ -197,7 +218,7 @@ def _search_variable(string, identifiers, ignore_errors=False): raise VariableError(f"Variable '{incomplete}' was not closed properly.") raise VariableError(f"Variable item '{incomplete}' was not closed properly.") - return match if match else VariableMatch(match) + return match def _find_variable_start(string, identifiers): @@ -236,26 +257,27 @@ def starts_with_variable_or_curly(text): return re.sub(r'(\\+)(?=(.+))', handle_escapes, item) -class VariableIterator: +class VariableMatches: - def __init__(self, string, identifiers='$@&%', ignore_errors=False): + def __init__(self, string: str, identifiers: Sequence[str] = '$@&%', + ignore_errors: bool = False): self.string = string self.identifiers = identifiers self.ignore_errors = ignore_errors - def __iter__(self): + def __iter__(self) -> Iterator[VariableMatch]: remaining = self.string while True: match = search_variable(remaining, self.identifiers, self.ignore_errors) if not match: break remaining = match.after - yield match.before, match.match, remaining + yield match - def __len__(self): + def __len__(self) -> int: return sum(1 for _ in self) - def __bool__(self): + def __bool__(self) -> bool: try: next(iter(self)) except StopIteration: diff --git a/utest/running/test_handlers.py b/utest/running/test_handlers.py index ccc01b1278e..0a622eb4c95 100644 --- a/utest/running/test_handlers.py +++ b/utest/running/test_handlers.py @@ -309,7 +309,7 @@ def test_package(self): from robot.variables.search import __file__ as source from robot.variables import __file__ as init_source lib = TestLibrary('robot.variables') - self._verify(lib.handlers['is_variable'], source, 33) + self._verify(lib.handlers['search_variable'], source, 23) self._verify(lib.init, init_source, -1) def test_decorated(self): diff --git a/utest/variables/test_search.py b/utest/variables/test_search.py index 8ed62f85cca..664f1f739d2 100644 --- a/utest/variables/test_search.py +++ b/utest/variables/test_search.py @@ -4,7 +4,7 @@ from robot.utils.asserts import (assert_equal, assert_false, assert_raises_with_msg, assert_true) from robot.variables.search import (search_variable, unescape_variable_syntax, - VariableIterator) + VariableMatches) class TestSearchVariable(unittest.TestCase): @@ -236,7 +236,7 @@ def _test(self, inp, variable=None, start=0, items=None, assert_equal(match.end, end, f'{inp!r} end') assert_equal(match.before, inp[:start] if start != -1 else inp) assert_equal(match.match, inp[start:end] if end != -1 else None) - assert_equal(match.after, inp[end:] if end != -1 else None) + assert_equal(match.after, inp[end:] if end != -1 else '') assert_equal(match.identifier, identifier, f'{inp!r} identifier') assert_equal(match.items, items, f'{inp!r} item') assert_equal(match.is_variable(), is_var) @@ -269,36 +269,42 @@ def test_is_dict_variable(self): assert_true(search_variable('&{x}[k][foo][bar][1]').is_dict_variable()) -class TestVariableIterator(unittest.TestCase): +class TestVariableMatches(unittest.TestCase): def test_no_variables(self): - iterator = VariableIterator('no vars here', identifiers='$') - assert_equal(list(iterator), []) - assert_equal(bool(iterator), False) - assert_equal(len(iterator), 0) + matches = VariableMatches('no vars here', identifiers='$') + assert_equal(list(matches), []) + assert_equal(bool(matches), False) + assert_equal(len(matches), 0) def test_one_variable(self): - iterator = VariableIterator('one ${var} here', identifiers='$') - assert_equal(list(iterator), [('one ', '${var}', ' here')]) - assert_equal(bool(iterator), True) - assert_equal(len(iterator), 1) + matches = VariableMatches('one ${var} here', identifiers='$') + assert_equal(bool(matches), True) + assert_equal(len(matches), 1) + self._assert_match(next(iter(matches)), 'one ', '${var}', ' here') def test_multiple_variables(self): - iterator = VariableIterator('${1} @{2} and %{3}', identifiers='$@%') - assert_equal(list(iterator), [('', '${1}', ' @{2} and %{3}'), - (' ', '@{2}', ' and %{3}'), - (' and ', '%{3}', '')]) - assert_equal(bool(iterator), True) - assert_equal(len(iterator), 3) + matches = VariableMatches('${1} @{2} and %{3}', identifiers='$@%') + assert_equal(bool(matches), True) + assert_equal(len(matches), 3) + m1, m2, m3 = matches + self._assert_match(m1, '', '${1}', ' @{2} and %{3}') + self._assert_match(m2, ' ', '@{2}', ' and %{3}') + self._assert_match(m3, ' and ', '%{3}', '') def test_can_be_iterated_many_times(self): - iterator = VariableIterator('one ${var} here', identifiers='$') - assert_equal(list(iterator), [('one ', '${var}', ' here')]) - assert_equal(list(iterator), [('one ', '${var}', ' here')]) - assert_equal(bool(iterator), True) - assert_equal(bool(iterator), True) - assert_equal(len(iterator), 1) - assert_equal(len(iterator), 1) + matches = VariableMatches('one ${var} here', identifiers='$') + assert_equal(bool(matches), True) + assert_equal(bool(matches), True) + assert_equal(len(matches), 1) + assert_equal(len(matches), 1) + self._assert_match(list(matches)[0], 'one ', '${var}', ' here') + self._assert_match(list(matches)[0], 'one ', '${var}', ' here') + + def _assert_match(self, match, before, variable, after): + assert_equal(match.before, before) + assert_equal(match.match, variable) + assert_equal(match.after, after) class TestUnescapeVariableSyntax(unittest.TestCase): From 362581495688492ba7134e059946f75bacdf9c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 23 Oct 2023 19:34:50 +0300 Subject: [PATCH 0792/1592] Accept `=` with VAR. All these are now supported and considered equal: VAR ${name} value VAR ${name}= value VAR ${name} = value This was requested by users on Slack. Part of #4674. --- atest/robot/variables/var_syntax.robot | 6 +++++ atest/testdata/variables/var_syntax.robot | 8 ++++++ src/robot/parsing/model/statements.py | 13 +++++----- utest/parsing/test_lexer.py | 10 ++++++++ utest/parsing/test_model.py | 30 ++++++++++++++++++++--- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/atest/robot/variables/var_syntax.robot b/atest/robot/variables/var_syntax.robot index 3e3072b23ff..fa645873856 100644 --- a/atest/robot/variables/var_syntax.robot +++ b/atest/robot/variables/var_syntax.robot @@ -22,6 +22,12 @@ Dict ${tc} = Check Test Case ${TESTNAME} Validate VAR ${tc.body}[0] \&{name} k1=v1 k2=v2 +Equals is accepted + ${tc} = Check Test Case ${TESTNAME} + Validate VAR ${tc.body}[0] \${name} value + Validate VAR ${tc.body}[2] \@{name} v1 v2 v3 + Validate VAR ${tc.body}[4] \&{name} k1=v1 k2=v2 + Scopes ${tc} = Check Test Case ${TESTNAME} 1 Validate VAR ${tc.body}[0] \${local1} local1 diff --git a/atest/testdata/variables/var_syntax.robot b/atest/testdata/variables/var_syntax.robot index 09978bba356..7ade0b713b0 100644 --- a/atest/testdata/variables/var_syntax.robot +++ b/atest/testdata/variables/var_syntax.robot @@ -21,6 +21,14 @@ Dict VAR &{name} k1=v1 k2=v2 Should Be Equal ${name} ${{{'k1': 'v1', 'k2': 'v2'}}} +Equals is accepted + VAR ${name}= value + Should Be Equal ${name} value + VAR @{name} = v1 v2 v3 + Should Be Equal ${name} ${{['v1', 'v2', 'v3']}} + VAR &{name}= k1=v1 k2=v2 + Should Be Equal ${name} ${{{'k1': 'v1', 'k2': 'v2'}}} + Scopes 1 VAR ${local1} local1 VAR ${local2} local2 scope=LOCAL diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 0d416a8b31c..57a5748af85 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -676,7 +676,7 @@ def separator(self) -> 'str|None': return self.get_option('separator') def validate(self, ctx: 'ValidationContext'): - VariableValidator(allow_assign_mark=True).validate(self) + VariableValidator().validate(self) self._validate_options() @@ -1262,7 +1262,10 @@ def from_params(cls, name: str, @property def name(self) -> str: - return self.get_value(Token.VARIABLE, '') + name = self.get_value(Token.VARIABLE, '') + if name.endswith('='): + return name[:-1].rstrip() + return name @property def value(self) -> 'tuple[str, ...]': @@ -1401,14 +1404,10 @@ def from_params(cls, eol: str = EOL): class VariableValidator: - def __init__(self, allow_assign_mark: bool = False): - self.allow_assign_mark = allow_assign_mark - def validate(self, statement: Statement): name = statement.get_value(Token.VARIABLE, '') match = search_variable(name, ignore_errors=True) - if not match.is_assign(allow_assign_mark=self.allow_assign_mark, - allow_nested=True): + if not match.is_assign(allow_assign_mark=True, allow_nested=True): statement.errors += (f"Invalid variable name '{name}'.",) if match.identifier == '&': self._validate_dict_items(statement) diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index d132c451533..1bb165ae20d 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -2279,6 +2279,16 @@ def test_simple(self): ] self._verify(data, expected) + def test_equals(self): + data = 'VAR ${name}= value' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '${name}=', 3, 11), + (T.ARGUMENT, 'value', 3, 23), + (T.EOS, '', 3, 28) + ] + self._verify(data, expected) + def test_multiple_values(self): data = 'VAR @{name} v1 v2\n... v3' expected = [ diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 2ecae47a408..97273fd4386 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1017,6 +1017,28 @@ def test_valid(self): test = get_and_assert_model(data, expected, depth=1) assert_equal([v.name for v in test.body], ['${x}', '@{y}', '&{z}', '${x${y}}']) + def test_equals(self): + data = ''' +*** Test Cases *** +Test + VAR ${x} = value + VAR @{y}= two values +''' + expected = TestCase( + header=TestCaseName([Token(Token.TESTCASE_NAME, 'Test', 2, 0)]), + body=[ + Var([Token(Token.VAR, 'VAR', 3, 4), + Token(Token.VARIABLE, '${x} =', 3, 11), + Token(Token.ARGUMENT, 'value', 3, 23)]), + Var([Token(Token.VAR, 'VAR', 4, 4), + Token(Token.VARIABLE, '@{y}=', 4, 11), + Token(Token.ARGUMENT, 'two', 4, 23), + Token(Token.ARGUMENT, 'values', 4, 30)]), + ] + ) + test = get_and_assert_model(data, expected, depth=1) + assert_equal([v.name for v in test.body], ['${x}', '@{y}']) + def test_options(self): data = r''' *** Test Cases *** @@ -1067,7 +1089,7 @@ def test_invalid(self): Keyword VAR bad name VAR ${not closed - VAR ${x}= = not accepted + VAR ${x}== only one = accepted VAR VAR &{d} o=k bad VAR ${x} ok scope=bad @@ -1084,9 +1106,9 @@ def test_invalid(self): Token(Token.ARGUMENT, 'closed', 4, 20)], ["Invalid variable name '${not'."]), Var([Token(Token.VAR, 'VAR', 5, 4), - Token(Token.VARIABLE, '${x}=', 5, 11), - Token(Token.ARGUMENT, '= not accepted', 5, 20)], - ["Invalid variable name '${x}='."]), + Token(Token.VARIABLE, '${x}==', 5, 11), + Token(Token.ARGUMENT, 'only one = accepted', 5, 20)], + ["Invalid variable name '${x}=='."]), Var([Token(Token.VAR, 'VAR', 6, 4)], ["Invalid variable name ''."]), Var([Token(Token.VAR, 'VAR', 7, 4), From 269cbb89380b7c5bd0e45083bddc20592c1b388b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 23 Oct 2023 22:29:56 +0300 Subject: [PATCH 0793/1592] Fine-tune control stucture option parsing. Change parsing of options so that each option is accepted only once. That most importantly affects the new VAR syntax (#3761) so that, for example, VAR &{x} scope=value scope=test creates `&{x}` with value `{'scope': 'value'}`. Earlier `scope` was considered an option twice which was an error. --- .../robot/running/for/for_in_enumerate.robot | 3 +- atest/robot/running/for/for_in_zip.robot | 4 +- .../running/try_except/except_behaviour.robot | 2 +- atest/robot/running/while/while_limit.robot | 2 +- atest/robot/variables/var_syntax.robot | 22 +++---- .../running/for/for_in_enumerate.robot | 6 +- atest/testdata/running/for/for_in_zip.robot | 6 +- .../running/try_except/except_behaviour.robot | 5 +- .../testdata/running/while/while_limit.robot | 2 +- atest/testdata/variables/var_syntax.robot | 34 ++++++----- src/robot/parsing/lexer/statementlexers.py | 12 ++-- src/robot/parsing/model/statements.py | 19 ++---- utest/parsing/test_lexer.py | 60 +++++++++++++++---- 13 files changed, 110 insertions(+), 67 deletions(-) diff --git a/atest/robot/running/for/for_in_enumerate.robot b/atest/robot/running/for/for_in_enumerate.robot index a030c0fc42f..67ddc3c3134 100644 --- a/atest/robot/running/for/for_in_enumerate.robot +++ b/atest/robot/running/for/for_in_enumerate.robot @@ -39,7 +39,8 @@ Invalid variable in start Check test and failed loop ${TEST NAME} IN ENUMERATE start=\${invalid} Start multiple times - Check test and failed loop ${TEST NAME} IN ENUMERATE start=1, 2, 3 + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ENUMERATE loop ${loop} 1 start=2 Index and two items ${loop} = Check test and get loop ${TEST NAME} 1 diff --git a/atest/robot/running/for/for_in_zip.robot b/atest/robot/running/for/for_in_zip.robot index 4c844ae8f05..41a9de220ea 100644 --- a/atest/robot/running/for/for_in_zip.robot +++ b/atest/robot/running/for/for_in_zip.robot @@ -110,9 +110,9 @@ Invalid mode from variable Config more than once ${tc} = Check Test Case ${TEST NAME} 1 - Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=longest, shortest + Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=shortest ${tc} = Check Test Case ${TEST NAME} 2 - Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=longest fill=x, y, z + Should be IN ZIP loop ${tc.body[0]} 1 FAIL fill=z Non-existing variable in mode ${tc} = Check Test Case ${TEST NAME} diff --git a/atest/robot/running/try_except/except_behaviour.robot b/atest/robot/running/try_except/except_behaviour.robot index 0451dba7654..1ede85342a5 100644 --- a/atest/robot/running/try_except/except_behaviour.robot +++ b/atest/robot/running/try_except/except_behaviour.robot @@ -50,7 +50,7 @@ Non-string pattern type FAIL FAIL pattern_types=['\${42}'] Pattern type multiple times - FAIL NOT RUN pattern_types=['glob, start'] + FAIL PASS NOT RUN pattern_types=['start'] Pattern type without patterns FAIL PASS diff --git a/atest/robot/running/while/while_limit.robot b/atest/robot/running/while/while_limit.robot index 866df1d1a7a..714fd961120 100644 --- a/atest/robot/running/while/while_limit.robot +++ b/atest/robot/running/while/while_limit.robot @@ -52,7 +52,7 @@ Invalid limit mistyped prefix Limit used multiple times ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.body[0].limit} 1, 2 + Should Be Equal ${tc.body[0].limit} 2 Invalid values after limit ${tc} = Check Test Case ${TESTNAME} diff --git a/atest/robot/variables/var_syntax.robot b/atest/robot/variables/var_syntax.robot index fa645873856..47ff57ade6b 100644 --- a/atest/robot/variables/var_syntax.robot +++ b/atest/robot/variables/var_syntax.robot @@ -9,18 +9,20 @@ Scalar Scalar with separator ${tc} = Check Test Case ${TESTNAME} - Validate VAR ${tc.body}[0] \${a} \${1} 2 3 separator=\\n - Validate VAR ${tc.body}[1] \${b} 1 \${2} 3 separator==== - Validate VAR ${tc.body}[2] \${c} 1 2 \${3} separator= - Validate VAR ${tc.body}[3] \${d} \${a} \${b} \${c} separator=\${0} + Validate VAR ${tc.body}[0] \${a} \${1} 2 3 separator=\\n + Validate VAR ${tc.body}[1] \${b} 1 \${2} 3 separator==== + Validate VAR ${tc.body}[2] \${c} 1 2 \${3} separator= + Validate VAR ${tc.body}[3] \${d} \${a} \${b} \${c} separator=\${0} + Validate VAR ${tc.body}[4] \${e} separator=has no effect + Validate VAR ${tc.body}[5] \${f} separator\=NO separator\=NO separator=--YES-- List ${tc} = Check Test Case ${TESTNAME} - Validate VAR ${tc.body}[0] \@{name} v1 v2 v3 + Validate VAR ${tc.body}[0] \@{name} v1 v2 separator\=v3 Dict ${tc} = Check Test Case ${TESTNAME} - Validate VAR ${tc.body}[0] \&{name} k1=v1 k2=v2 + Validate VAR ${tc.body}[0] \&{name} k1=v1 k2=v2 separator\=v3 Equals is accepted ${tc} = Check Test Case ${TESTNAME} @@ -31,10 +33,10 @@ Equals is accepted Scopes ${tc} = Check Test Case ${TESTNAME} 1 Validate VAR ${tc.body}[0] \${local1} local1 - Validate VAR ${tc.body}[1] \${local2} local2 scope=LOCAL - Validate VAR ${tc.body}[2] \${test} test scope=test - Validate VAR ${tc.body}[3] \${suite} suite scope=\${{'suite'}} - Validate VAR ${tc.body}[4] \${global} global scope=GLOBAL + Validate VAR ${tc.body}[1] \${local2} scope\=local2 scope=LOCAL + Validate VAR ${tc.body}[2] \@{test} scope\=value scope=test + Validate VAR ${tc.body}[3] \&{suite} scope\=value scope=\${{'suite'}} + Validate VAR ${tc.body}[4] \${global} global scope=GLOBAL Check Test Case ${TESTNAME} 2 Invalid scope diff --git a/atest/testdata/running/for/for_in_enumerate.robot b/atest/testdata/running/for/for_in_enumerate.robot index dd909a4bccc..604a13517eb 100644 --- a/atest/testdata/running/for/for_in_enumerate.robot +++ b/atest/testdata/running/for/for_in_enumerate.robot @@ -46,9 +46,9 @@ Invalid variable in start END Start multiple times - [Documentation] FAIL FOR option 'start' is accepted only once, got 3 values '1', '2' and '3'. - FOR ${index} ${item} IN ENUMERATE xxx start=1 start=2 start=3 - Fail Should not be executed + FOR ${index} ${item} IN ENUMERATE start=1 start=2 + Should Be Equal ${index} ${2} + Should Be Equal ${item} start=1 END Index and two items diff --git a/atest/testdata/running/for/for_in_zip.robot b/atest/testdata/running/for/for_in_zip.robot index c1dc7564e6a..e0e94bc8096 100644 --- a/atest/testdata/running/for/for_in_zip.robot +++ b/atest/testdata/running/for/for_in_zip.robot @@ -146,14 +146,14 @@ Invalid mode from variable END Config more than once 1 - [Documentation] FAIL FOR option 'mode' is accepted only once, got 2 values 'longest' and 'shortest'. + [Documentation] FAIL FOR IN ZIP items must be list-like, but item 3 is string. FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=longest mode=shortest Fail Should not be executed END Config more than once 2 - [Documentation] FAIL FOR option 'fill' is accepted only once, got 3 values 'x', 'y' and 'z'. - FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} fill=x mode=longest fill=y fill=z + [Documentation] FAIL FOR IN ZIP items must be list-like, but item 4 is string. + FOR ${items} IN ZIP ${LIST1} ${LIST2} ${LIST3} fill=x mode=longest fill=y fill=z Fail Should not be executed END diff --git a/atest/testdata/running/try_except/except_behaviour.robot b/atest/testdata/running/try_except/except_behaviour.robot index 01a8874d8cb..ceae17142ff 100644 --- a/atest/testdata/running/try_except/except_behaviour.robot +++ b/atest/testdata/running/try_except/except_behaviour.robot @@ -133,10 +133,11 @@ Non-string pattern type END Pattern type multiple times - [Documentation] FAIL EXCEPT option 'type' is accepted only once, got 2 values 'glob' and 'start'. TRY - Fail failure + Fail type=glob with stuff afterwards EXCEPT x type=glob type=start + No operation + ELSE Fail Should not be executed END diff --git a/atest/testdata/running/while/while_limit.robot b/atest/testdata/running/while/while_limit.robot index abb90747add..de662b9394e 100644 --- a/atest/testdata/running/while/while_limit.robot +++ b/atest/testdata/running/while/while_limit.robot @@ -106,7 +106,7 @@ Invalid limit mistyped prefix END Limit used multiple times - [Documentation] FAIL WHILE option 'limit' is accepted only once, got 2 values '1' and '2'. + [Documentation] FAIL WHILE accepts only one condition, got 2 conditions 'True' and 'limit=1'. WHILE True limit=1 limit=2 Fail Should not be executed END diff --git a/atest/testdata/variables/var_syntax.robot b/atest/testdata/variables/var_syntax.robot index 7ade0b713b0..bb327cb4c4c 100644 --- a/atest/testdata/variables/var_syntax.robot +++ b/atest/testdata/variables/var_syntax.robot @@ -8,18 +8,22 @@ Scalar with separator VAR ${b} 1 ${2} 3 separator==== VAR ${c} 1 2 ${3} separator= VAR ${d} ${a} ${b} ${c} separator=${0} + VAR ${e} separator=has no effect + VAR ${f} separator=NO separator=NO separator=--YES-- Should Be Equal ${a} 1\n2\n3 Should Be Equal ${b} 1===2===3 Should Be Equal ${c} 123 Should Be Equal ${d} ${a}0${b}0${c} + Should Be Equal ${e} ${EMPTY} + Should Be Equal ${f} separator=NO--YES--separator=NO List - VAR @{name} v1 v2 v3 - Should Be Equal ${name} ${{['v1', 'v2', 'v3']}} + VAR @{name} v1 v2 separator=v3 + Should Be Equal ${name} ${{['v1', 'v2', 'separator=v3']}} Dict - VAR &{name} k1=v1 k2=v2 - Should Be Equal ${name} ${{{'k1': 'v1', 'k2': 'v2'}}} + VAR &{name} k1=v1 k2=v2 separator=v3 + Should Be Equal ${name} ${{{'k1': 'v1', 'k2': 'v2', 'separator': 'v3'}}} Equals is accepted VAR ${name}= value @@ -31,14 +35,14 @@ Equals is accepted Scopes 1 VAR ${local1} local1 - VAR ${local2} local2 scope=LOCAL - VAR ${test} test scope=test - VAR ${suite} suite scope=${{'suite'}} - VAR ${global} global scope=GLOBAL + VAR ${local2} scope=local2 scope=LOCAL + VAR @{test} scope=value scope=test + VAR &{suite} scope=value scope=${{'suite'}} + VAR ${global} global scope=GLOBAL Should Be Equal ${local1} local1 - Should Be Equal ${local2} local2 - Should Be Equal ${test} test - Should Be Equal ${suite} suite + Should Be Equal ${local2} scope=local2 + Should Be Equal ${test} ${{['scope=value']}} + Should Be Equal ${suite} ${{{'scope': 'value'}}} Should Be Equal ${global} global Scopes Should Be Equal ${test} new-test @@ -47,7 +51,7 @@ Scopes 1 Scopes 2 Variable Should Not Exist ${local1} Variable Should Not Exist ${local2} - Should Be Equal ${suite} suite + Should Be Equal ${suite} ${{{'scope': 'value'}}} Should Be Equal ${global} global Invalid scope @@ -123,10 +127,10 @@ With TRY Scopes Variable Should Not Exist ${local1} Variable Should Not Exist ${local2} - Should Be Equal ${test} test - Should Be Equal ${suite} suite + Should Be Equal ${test} ${{['scope=value']}} + Should Be Equal ${suite} ${{{'scope': 'value'}}} Should Be Equal ${global} global VAR ${local3} local3 - VAR ${test} new ${test} scope=${test} separator=${{'-'}} + VAR ${test} new test scope=${{'test'}} separator=${{'-'}} Should Be Equal ${local3} local3 Should Be Equal ${test} new-test diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 9fd3f76d8ec..34bfa857b03 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -62,11 +62,15 @@ def lex(self): raise NotImplementedError def _lex_options(self, *names: str, end_index: 'int|None' = None): + seen = set() for token in reversed(self.statement[:end_index]): - if '=' in token.value and token.value.split('=')[0] in names: - token.type = Token.OPTION - else: - break + if '=' in token.value: + name = token.value.split('=')[0] + if name in names and name not in seen: + token.type = Token.OPTION + seen.add(name) + continue + break class SingleType(StatementLexer, ABC): diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 57a5748af85..3a8b8c10748 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -169,15 +169,10 @@ def get_option(self, name: str, default: 'str|None' = None) -> 'str|None': New in Robot Framework 6.1. """ - options = self._get_options() - return ', '.join(options[name]) if name in options else default + return self._get_options().get(name, default) - def _get_options(self) -> 'dict[str, list[str]]': - options: 'dict[str, list[str]]' = {} - for option in self.get_values(Token.OPTION): - name, value = option.split('=', 1) - options.setdefault(name, []).append(value) - return options + def _get_options(self) -> 'dict[str, str]': + return dict(opt.split('=', 1) for opt in self.get_values(Token.OPTION)) @property def lines(self) -> 'Iterator[list[Token]]': @@ -194,12 +189,8 @@ def validate(self, ctx: 'ValidationContext'): pass def _validate_options(self): - for name, values in self._get_options().items(): - if len(values) != 1: - self.errors += (f"{self.type} option '{name}' is accepted only once, " - f"got {len(values)} values {seq2str(values)}.",) - elif self.options[name] is not None: - value = values[0] + for name, value in self._get_options().items(): + if self.options[name] is not None: expected = self.options[name] if value.upper() not in expected and not contains_variable(value): self.errors += (f"{self.type} option '{name}' does not accept " diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 1bb165ae20d..15de36e29c8 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -2296,8 +2296,8 @@ def test_multiple_values(self): (T.VARIABLE, '@{name}', 3, 11), (T.ARGUMENT, 'v1', 3, 22), (T.ARGUMENT, 'v2', 3, 28), - (T.ARGUMENT, 'v3', 4, 7), - (T.EOS, '', 4, 9) + (T.ARGUMENT, 'v3', 4, 11), + (T.EOS, '', 4, 13) ] self._verify(data, expected) @@ -2319,13 +2319,42 @@ def test_no_name(self): self._verify(data, expected) def test_scope(self): - data = 'VAR ${name} value scope=GLOBAL' + data = ('VAR ${name} value scope=GLOBAL\n' + 'VAR @{name} value scope=suite\n' + 'VAR &{name} value scope=Test\n') expected = [ (T.VAR, 'VAR', 3, 4), (T.VARIABLE, '${name}', 3, 11), (T.ARGUMENT, 'value', 3, 22), (T.OPTION, 'scope=GLOBAL', 3, 31), - (T.EOS, '', 3, 43) + (T.EOS, '', 3, 43), + (T.VAR, 'VAR', 4, 4), + (T.VARIABLE, '@{name}', 4, 11), + (T.ARGUMENT, 'value', 4, 22), + (T.OPTION, 'scope=suite', 4, 31), + (T.EOS, '', 4, 42), + (T.VAR, 'VAR', 5, 4), + (T.VARIABLE, '&{name}', 5, 11), + (T.ARGUMENT, 'value', 5, 22), + (T.OPTION, 'scope=Test', 5, 31), + (T.EOS, '', 5, 41) + ] + self._verify(data, expected) + + def test_only_one_scope(self): + data = ('VAR ${name} scope=value scope=GLOBAL\n' + 'VAR &{name} scope=value scope=GLOBAL') + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '${name}', 3, 11), + (T.ARGUMENT, 'scope=value', 3, 22), + (T.OPTION, 'scope=GLOBAL', 3, 37), + (T.EOS, '', 3, 49), + (T.VAR, 'VAR', 4, 4), + (T.VARIABLE, '&{name}', 4, 11), + (T.ARGUMENT, 'scope=value', 4, 22), + (T.OPTION, 'scope=GLOBAL', 4, 37), + (T.EOS, '', 4, 49) ] self._verify(data, expected) @@ -2341,6 +2370,18 @@ def test_separator_with_scalar(self): ] self._verify(data, expected) + def test_only_one_separator(self): + data = 'VAR ${name} scope=v1 separator=v2 separator=-' + expected = [ + (T.VAR, 'VAR', 3, 4), + (T.VARIABLE, '${name}', 3, 11), + (T.ARGUMENT, 'scope=v1', 3, 22), + (T.ARGUMENT, 'separator=v2', 3, 34), + (T.OPTION, 'separator=-', 3, 50), + (T.EOS, '', 3, 61) + ] + self._verify(data, expected) + def test_no_separator_with_list(self): data = 'VAR @{name} v1 v2 separator=-' expected = [ @@ -2354,19 +2395,18 @@ def test_no_separator_with_list(self): self._verify(data, expected) def test_no_separator_with_dict(self): - data = 'VAR &{name} k1=v1 k2=v2 separator=-' + data = 'VAR &{name} scope=value separator=-' expected = [ (T.VAR, 'VAR', 3, 4), (T.VARIABLE, '&{name}', 3, 11), - (T.ARGUMENT, 'k1=v1', 3, 22), - (T.ARGUMENT, 'k2=v2', 3, 31), - (T.ARGUMENT, 'separator=-', 3, 40), - (T.EOS, '', 3, 51) + (T.ARGUMENT, 'scope=value', 3, 22), + (T.ARGUMENT, 'separator=-', 3, 37), + (T.EOS, '', 3, 48) ] self._verify(data, expected) def _verify(self, data, expected): - data = ' ' + ' \n'.join(data.splitlines()) + data = ' ' + '\n '.join(data.splitlines()) data = f'*** Test Cases ***\nName\n{data}' expected = [(T.TESTCASE_HEADER, '*** Test Cases ***', 1, 0), (T.EOS, '', 1, 18), From e2acc0eff601a651dfdc1e04164b55a9abbf5995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 24 Oct 2023 13:57:39 +0300 Subject: [PATCH 0794/1592] Small performance enhancement for resolving variables. Especially `replace_list` that is used with each keyword call to resolve arguments is faster due adding resolved items to a list instead of yielding them. `timeit` reports ~10% better performace with it which is really nice. All other execution overhead makes the difference in normal usage smaller. --- src/robot/variables/replacer.py | 69 ++++++++++++++++----------------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/src/robot/variables/replacer.py b/src/robot/variables/replacer.py index c58f7862a10..aa8d8c8b69b 100644 --- a/src/robot/variables/replacer.py +++ b/src/robot/variables/replacer.py @@ -42,33 +42,32 @@ def replace_list(self, items, replace_until=None, ignore_errors=False): items = list(items or []) if replace_until is not None: return self._replace_list_until(items, replace_until, ignore_errors) - return list(self._replace_list(items, ignore_errors)) + return self._replace_list(items, ignore_errors) - def _replace_list_until(self, items, replace_until, ignore_errors): + def _replace_list_until(self, items, limit, ignore_errors): # @{list} variables can contain more or less arguments than needed. # Therefore, we need to go through items one by one, and escape # possible extra items we got. replaced = [] - while len(replaced) < replace_until and items: + while len(replaced) < limit and items: replaced.extend(self._replace_list([items.pop(0)], ignore_errors)) - if len(replaced) > replace_until: - replaced[replace_until:] = [escape(item) - for item in replaced[replace_until:]] + if len(replaced) > limit: + replaced[limit:] = [escape(item) for item in replaced[limit:]] return replaced + items def _replace_list(self, items, ignore_errors): + result = [] for item in items: - for value in self._replace_list_item(item, ignore_errors): - yield value - - def _replace_list_item(self, item, ignore_errors): - match = search_variable(item, ignore_errors=ignore_errors) - if not match: - return [unescape(match.string)] - value = self.replace_scalar(match, ignore_errors) - if match.is_list_variable() and is_list_like(value): - return value - return [value] + match = search_variable(item, ignore_errors=ignore_errors) + if not match: + result.append(unescape(item)) + else: + value = self._replace_scalar(match, ignore_errors) + if match.is_list_variable() and is_list_like(value): + result.extend(value) + else: + result.append(value) + return result def replace_scalar(self, item, ignore_errors=False): """Replaces variables from a scalar item. @@ -77,20 +76,18 @@ def replace_scalar(self, item, ignore_errors=False): its value is returned. Otherwise, possible variables are replaced with 'replace_string'. Result may be any object. """ - match = self._search_variable(item, ignore_errors=ignore_errors) + if isinstance(item, VariableMatch): + match = item + else: + match = search_variable(item, ignore_errors=ignore_errors) if not match: return unescape(match.string) return self._replace_scalar(match, ignore_errors) - def _search_variable(self, item, ignore_errors): - if isinstance(item, VariableMatch): - return item - return search_variable(item, ignore_errors=ignore_errors) - def _replace_scalar(self, match, ignore_errors=False): - if not match.is_variable(): - return self.replace_string(match, ignore_errors=ignore_errors) - return self._get_variable_value(match, ignore_errors) + if match.is_variable(): + return self._get_variable_value(match, ignore_errors) + return self._replace_string(match, unescape, ignore_errors) def replace_string(self, item, custom_unescaper=None, ignore_errors=False): """Replaces variables from a string. Result is always a string. @@ -98,7 +95,10 @@ def replace_string(self, item, custom_unescaper=None, ignore_errors=False): Input can also be an already found VariableMatch. """ unescaper = custom_unescaper or unescape - match = self._search_variable(item, ignore_errors=ignore_errors) + if isinstance(item, VariableMatch): + match = item + else: + match = search_variable(item, ignore_errors=ignore_errors) if not match: return safe_str(unescaper(match.string)) return self._replace_string(match, unescaper, ignore_errors) @@ -106,10 +106,8 @@ def replace_string(self, item, custom_unescaper=None, ignore_errors=False): def _replace_string(self, match, unescaper, ignore_errors): parts = [] while match: - parts.extend([ - unescaper(match.before), - safe_str(self._get_variable_value(match, ignore_errors)) - ]) + parts.append(unescaper(match.before)) + parts.append(safe_str(self._get_variable_value(match, ignore_errors))) match = search_variable(match.after, ignore_errors=ignore_errors) parts.append(unescaper(match.string)) return ''.join(parts) @@ -126,17 +124,16 @@ def _get_variable_value(self, match, ignore_errors): if match.items: value = self._get_variable_item(match, value) try: - value = self._validate_value(match, value) + return self._validate_value(match, value) except VariableError: raise except Exception: error = get_error_message() raise VariableError(f"Resolving variable '{match}' failed: {error}") except DataError: - if not ignore_errors: - raise - value = unescape(match.match) - return value + if ignore_errors: + return unescape(match.match) + raise def _get_variable_item(self, match, value): name = match.name From bce75b2b61ff49f64d97e2a7d744b34976db13d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 24 Oct 2023 22:41:49 +0300 Subject: [PATCH 0795/1592] Statement: type and tokens from _fields to _attributes. Fixes #4912. --- src/robot/parsing/model/statements.py | 2 +- utest/parsing/parsing_test_utils.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 3a8b8c10748..73b9f9cbc98 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -47,7 +47,7 @@ class Node(ast.AST, ABC): class Statement(Node, ABC): - _fields = ('type', 'tokens') + _attributes = ('type', 'tokens') + Node._attributes type: str handles_types: 'ClassVar[tuple[str, ...]]' = () statement_handlers: 'ClassVar[dict[str, Type[Statement]]]' = {} diff --git a/utest/parsing/parsing_test_utils.py b/utest/parsing/parsing_test_utils.py index 8dff1b8dc5d..236d75a98d0 100644 --- a/utest/parsing/parsing_test_utils.py +++ b/utest/parsing/parsing_test_utils.py @@ -47,13 +47,13 @@ def assert_block(model, expected, expected_attrs): def assert_statement(model, expected): - assert_equal(model._fields, ('type', 'tokens')) assert_equal(model.type, expected.type) assert_equal(len(model.tokens), len(expected.tokens)) for m, e in zip(model.tokens, expected.tokens): assert_equal(m, e, formatter=repr) - assert_equal(model._attributes, ('lineno', 'col_offset', 'end_lineno', - 'end_col_offset', 'errors')) + assert_equal(model._fields, ()) + assert_equal(model._attributes, ('type', 'tokens', 'lineno', 'col_offset', + 'end_lineno', 'end_col_offset', 'errors')) assert_equal(model.lineno, expected.tokens[0].lineno) assert_equal(model.col_offset, expected.tokens[0].col_offset) assert_equal(model.end_lineno, expected.tokens[-1].lineno) From 6067ab0a6dccb9138709e446a3b5bce6d60d6a62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 25 Oct 2023 00:07:15 +0300 Subject: [PATCH 0796/1592] Document VAR syntax. #3761 --- .../src/CreatingTestData/Variables.rst | 182 ++++++++++++++++-- 1 file changed, 169 insertions(+), 13 deletions(-) diff --git a/doc/userguide/src/CreatingTestData/Variables.rst b/doc/userguide/src/CreatingTestData/Variables.rst index e1d03323f28..4993c789410 100644 --- a/doc/userguide/src/CreatingTestData/Variables.rst +++ b/doc/userguide/src/CreatingTestData/Variables.rst @@ -586,8 +586,8 @@ dictionary keys. For example, `@{MANY}` variable would have value `['first', __ Escaping_ -Creating variable based on another variable -''''''''''''''''''''''''''''''''''''''''''' +Creating variable name based on another variable +'''''''''''''''''''''''''''''''''''''''''''''''' Starting from Robot Framework 7.0, it is possible to create the variable name dynamically based on another variable: @@ -721,8 +721,8 @@ where the `item` part can itself contain a variable: ${dict}[last_name] = Set Variable Doe Log ${dictionary} # Logs {'first_name': 'John', 'last_name': 'Doe'} -Creating variable based on another variable -''''''''''''''''''''''''''''''''''''''''''' +Creating variable name based on another variable +'''''''''''''''''''''''''''''''''''''''''''''''' Starting from Robot Framework 7.0, it is possible to create the name of the assigned variable dynamically based on another variable: @@ -836,9 +836,165 @@ to use the BuiltIn_ :name:`Log` keyword to log it after the assignment. .. note:: The :option:`--maxassignlength` option is new in Robot Framework 5.0. +`VAR` syntax +~~~~~~~~~~~~ + +Starting from Robot Framework 7.0, it is possible to create variables inside +tests and user keywords using the `VAR` syntax. The `VAR` marker is case-sensitive +and it must be followed by a variable name and value. Other than the mandatory +`VAR`, the overall syntax is mostly the same as when creating variables +in the `Variable section`_. + +The new syntax is aims to make creating variables simpler and more uniform. It is +especially indented to replace the BuiltIn_ keywords :name:`Set Variable`, +:name:`Set Test Variable`, :name:`Set Suite Variable` and :name:`Set Global Variable`, +but it can be used instead of :name:`Catenate`, :name:`Create List` and +:name:`Create Dictionary` as well. + +Creating scalar variables +''''''''''''''''''''''''' + +In simple cases scalar variables are created by just giving a variable name +and its value. The value can be a hard-coded string or it can itself contain +a variable. If the value is long, it is possible to split it into multiple +columns and rows. In that case parts are joined together with a space by default, +but the separator to use can be specified with the `separator` configuration +option. It is possible to have an optional `=` after the variable name the same +way as when creating variables based on `return values from keywords`_ and in +the `Variable section`_. + +.. sourcecode:: robotframework + + *** Test Cases *** + Scalar examples + VAR ${simple} variable + VAR ${equals} = this works too + VAR ${variable} value contains ${simple} + VAR ${sentence} This is a bit longer variable value + ... that is split into multiple rows. + ... These parts are joined with a space. + VAR ${multiline} This is another longer value. + ... This time there is a custom separator. + ... As the result this becomes a multiline string. + ... separator=\n + +Creating list and dictionary variables +'''''''''''''''''''''''''''''''''''''' + +List and dictionary variables are created similarly as scalar variables. +When creating dictionaries, items must be specified using the `name=value` syntax. + +.. sourcecode:: robotframework + + *** Test Cases *** + List examples + VAR @{two items} Robot Framework + VAR @{empty list} + VAR @{lot of stuff} + ... first item + ... second item + ... third item + ... fourth item + ... last item + + Dictionary examples + VAR &{two items} name=Robot Framework url=http://robotframework.org + VAR &{empty dict} + VAR &{lot of stuff} + ... first=1 + ... second=2 + ... third=3 + ... fourth=4 + ... last=5 + +Scope +''''' + +Variables created with the `VAR` syntax are are available only within the test +or user keyword where they are created. That can, however, be altered by using +the `scope` configuration option. Supported values are `LOCAL` (default), +`TEST` (available within the current test), `TASK` (alias for `TEST`), `SUITE` +(available within the current suite) and `GLOBAL` (available globally). +Although Robot Framework variables are case-insensitive, it is recommended to +use capital letters with non-local variable names. + +.. sourcecode:: robotframework + + *** Variables *** + ${SUITE} this value is overridden + + *** Test Cases *** + Scope example + VAR ${local} local value + VAR ${TEST} test value scope=TEST + VAR ${SUITE} suite value scope=SUITE + VAR ${GLOBAL} global value scope=GLOBAL + Should Be Equal ${local} local value + Should Be Equal ${TEST} test value + Should Be Equal ${SUITE} suite value + Should Be Equal ${GLOBAL} global value + Keyword + Should Be Equal ${TEST} new test value + Should Be Equal ${SUITE} new suite value + Should Be Equal ${GLOBAL} new global value + + Scope example, part 2 + Should Be Equal ${SUITE} new suite value + Should Be Equal ${GLOBAL} new global value + + *** Keywords *** + Keyword + Should Be Equal ${TEST} test value + Should Be Equal ${SUITE} suite value + Should Be Equal ${GLOBAL} global value + VAR ${TEST} new ${TEST} scope=TEST + VAR ${SUITE} new ${SUITE} scope=SUITE + VAR ${GLOBAL} new ${GLOBAL} scope=GLOBAL + Should Be Equal ${TEST} new test value + Should Be Equal ${SUITE} new suite value + Should Be Equal ${GLOBAL} new global value + +Creating variables conditionally +'''''''''''''''''''''''''''''''' + +The `VAR` syntax works with `IF/ELSE structures`_ which makes it easy to create +variables conditionally. In simple cases using `inline IF`_ can be convenient. + +.. sourcecode:: robotframework + + *** Test Cases *** + IF/ELSE example + IF "${ENV}" == "devel" + VAR ${address} 127.0.0.1 + VAR ${name} demo + ELSE + VAR ${address} 192.168.1.42 + VAR ${name} robot + END + + Inline IF + IF "${ENV}" == "devel" VAR ${name} demo ELSE VAR ${name} robot + +Creating variable name based on another variable +'''''''''''''''''''''''''''''''''''''''''''''''' + +If there is a need, variable name can also be created dynamically based on +another variable. + +.. sourcecode:: robotframework + + *** Test Cases *** + Dynamic name + VAR ${x} y # Normal assignment. + VAR ${${x}} z # Name created dynamically. + Should Be Equal ${y} z + Using :name:`Set Test/Suite/Global Variable` keywords ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. note:: The `VAR` syntax is recommended over these keywords when using + Robot Framework 7.0 or newer. + The BuiltIn_ library has keywords :name:`Set Test Variable`, :name:`Set Suite Variable` and :name:`Set Global Variable` which can be used for setting variables dynamically during the test @@ -1226,7 +1382,7 @@ Global variables are available everywhere in the test data. These variables are normally `set from the command line`__ with the :option:`--variable` and :option:`--variablefile` options, but it is also possible to create new global variables or change the existing ones -with the BuiltIn_ keyword :name:`Set Global Variable` anywhere in +by using the `VAR syntax`_ or the :name:`Set Global Variable` keyword anywhere in the test data. Additionally also `built-in variables`_ are global. It is recommended to use capital letters with all global variables. @@ -1237,8 +1393,8 @@ Test suite scope Variables with the test suite scope are available anywhere in the test suite where they are defined or imported. They can be created in Variable sections, imported from `resource and variable files`_, -or set during the test execution using the BuiltIn_ keyword -:name:`Set Suite Variable`. +or set during the test execution using the `VAR syntax`_ or the +:name:`Set Suite Variable` keyword. The test suite scope *is not recursive*, which means that variables available in a higher-level test suite *are not available* in @@ -1253,10 +1409,10 @@ Test case scope Variables with the test case scope are visible in a test case and in all user keywords the test uses. Initially there are no variables in -this scope, but it is possible to create them by using the BuiltIn_ -keyword :name:`Set Test Variable` anywhere in a test case. -It is an error to call :name:`Set Test Variable` outside the -scope of a test (e.g. in a Suite Setup or Teardown). +this scope, but it is possible to create them by using the `VAR syntax`_ or +the :name:`Set Test Variable` keyword anywhere in a test case. +Trying to create test variables in suite setup or suite teardown causes +and error. Also variables in the test case scope are to some extend global. It is thus generally recommended to use capital letters with them too. @@ -1266,8 +1422,8 @@ Local scope Test cases and user keywords have a local variable scope that is not seen by other tests or keywords. Local variables can be created using -`return values`__ from executed keywords and user keywords also get -them as arguments__. +`return values`__ from executed keywords and with the `VAR syntax`_, +and user keywords also get them as arguments__. It is recommended to use lower-case letters with local variables. From 4a9ad8779ae7f0962780b182b245e95ccd1ff580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 25 Oct 2023 00:22:18 +0300 Subject: [PATCH 0797/1592] Test VAR in suite setup and teardown. Using `scope=test` causes an error. #3761 --- atest/robot/variables/var_syntax.robot | 15 +++++-- atest/testdata/variables/var_syntax.robot | 50 ++++++++++++++++------- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/atest/robot/variables/var_syntax.robot b/atest/robot/variables/var_syntax.robot index 47ff57ade6b..819f329dff3 100644 --- a/atest/robot/variables/var_syntax.robot +++ b/atest/robot/variables/var_syntax.robot @@ -30,13 +30,22 @@ Equals is accepted Validate VAR ${tc.body}[2] \@{name} v1 v2 v3 Validate VAR ${tc.body}[4] \&{name} k1=v1 k2=v2 +In suite setup and teardown + Check Test Case In suite setup + Validate VAR ${SUITE.setup.body}[0] \${local} value + Validate VAR ${SUITE.setup.body}[1] \${SUITE} set in \${where} scope=suite + Validate VAR ${SUITE.setup.body}[2] \${GLOBAL} set in \${where} scope=global + Validate VAR ${SUITE.teardown.body}[0] \${local} value + Validate VAR ${SUITE.teardown.body}[1] \${SUITE} set in \${where} scope=suite + Validate VAR ${SUITE.teardown.body}[2] \${GLOBAL} set in \${where} scope=global + Scopes ${tc} = Check Test Case ${TESTNAME} 1 Validate VAR ${tc.body}[0] \${local1} local1 Validate VAR ${tc.body}[1] \${local2} scope\=local2 scope=LOCAL - Validate VAR ${tc.body}[2] \@{test} scope\=value scope=test - Validate VAR ${tc.body}[3] \&{suite} scope\=value scope=\${{'suite'}} - Validate VAR ${tc.body}[4] \${global} global scope=GLOBAL + Validate VAR ${tc.body}[2] \@{TEST} scope\=value scope=test + Validate VAR ${tc.body}[3] \&{SUITE} scope\=value scope=\${{'suite'}} + Validate VAR ${tc.body}[4] \${GLOBAL} global scope=GLOBAL Check Test Case ${TESTNAME} 2 Invalid scope diff --git a/atest/testdata/variables/var_syntax.robot b/atest/testdata/variables/var_syntax.robot index bb327cb4c4c..2cde72f77e1 100644 --- a/atest/testdata/variables/var_syntax.robot +++ b/atest/testdata/variables/var_syntax.robot @@ -1,3 +1,7 @@ +*** Settings *** +Suite Setup VAR in suite setup and teardown suite setup +Suite Teardown VAR in suite setup and teardown suite teardown + *** Test Cases *** Scalar VAR ${name} value @@ -33,26 +37,30 @@ Equals is accepted VAR &{name}= k1=v1 k2=v2 Should Be Equal ${name} ${{{'k1': 'v1', 'k2': 'v2'}}} +In suite setup + Should Be Equal ${SUITE} set in suite setup + Should Be Equal ${GLOBAL} set in suite setup + Scopes 1 VAR ${local1} local1 VAR ${local2} scope=local2 scope=LOCAL - VAR @{test} scope=value scope=test - VAR &{suite} scope=value scope=${{'suite'}} - VAR ${global} global scope=GLOBAL + VAR @{TEST} scope=value scope=test + VAR &{SUITE} scope=value scope=${{'suite'}} + VAR ${GLOBAL} global scope=GLOBAL Should Be Equal ${local1} local1 Should Be Equal ${local2} scope=local2 - Should Be Equal ${test} ${{['scope=value']}} - Should Be Equal ${suite} ${{{'scope': 'value'}}} - Should Be Equal ${global} global + Should Be Equal ${TEST} ${{['scope=value']}} + Should Be Equal ${SUITE} ${{{'scope': 'value'}}} + Should Be Equal ${GLOBAL} global Scopes - Should Be Equal ${test} new-test + Should Be Equal ${TEST} new-test Variable Should Not Exist ${local3} Scopes 2 Variable Should Not Exist ${local1} Variable Should Not Exist ${local2} - Should Be Equal ${suite} ${{{'scope': 'value'}}} - Should Be Equal ${global} global + Should Be Equal ${SUITE} ${{{'scope': 'value'}}} + Should Be Equal ${GLOBAL} global Invalid scope [Documentation] FAIL VAR option 'scope' does not accept value 'invalid'. Valid values are 'GLOBAL', 'SUITE', 'TEST', 'TASK' and 'LOCAL'. @@ -127,10 +135,24 @@ With TRY Scopes Variable Should Not Exist ${local1} Variable Should Not Exist ${local2} - Should Be Equal ${test} ${{['scope=value']}} - Should Be Equal ${suite} ${{{'scope': 'value'}}} - Should Be Equal ${global} global + Should Be Equal ${TEST} ${{['scope=value']}} + Should Be Equal ${SUITE} ${{{'scope': 'value'}}} + Should Be Equal ${GLOBAL} global VAR ${local3} local3 - VAR ${test} new test scope=${{'test'}} separator=${{'-'}} + VAR ${TEST} new test scope=${{'test'}} separator=${{'-'}} Should Be Equal ${local3} local3 - Should Be Equal ${test} new-test + Should Be Equal ${TEST} new-test + +VAR in suite setup and teardown + [Arguments] ${where} + VAR ${local} value + VAR ${SUITE} set in ${where} scope=suite + VAR ${GLOBAL} set in ${where} scope=global + Should Be Equal ${local} value + Should Be Equal ${SUITE} set in ${where} + Should Be Equal ${GLOBAL} set in ${where} + TRY + VAR ${TEST} this fails scope=test + EXCEPT AS ${err} + Should Be Equal ${err} Setting variable '\${TEST}' failed: Cannot set test variable when no test is started. + END From 5cf3241d7cd8daed92af7abd5b1a1223ffaab918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 25 Oct 2023 02:44:00 +0300 Subject: [PATCH 0798/1592] Test cleanup --- atest/robot/keywords/embedded_arguments.robot | 18 +++++++++--------- .../testdata/keywords/embedded_arguments.robot | 12 +++++------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/atest/robot/keywords/embedded_arguments.robot b/atest/robot/keywords/embedded_arguments.robot index faf06ff9c9a..1540be432a8 100644 --- a/atest/robot/keywords/embedded_arguments.robot +++ b/atest/robot/keywords/embedded_arguments.robot @@ -106,13 +106,13 @@ Non String Variable Is Accepted With Custom Regexp Regexp Extensions Are Not Supported Check Test Case ${TEST NAME} - Creating Keyword Failed 0 294 + Creating Keyword Failed 0 292 ... Regexp extensions like \${x:(?x)re} are not supported ... Regexp extensions are not allowed in embedded arguments. Invalid Custom Regexp Check Test Case ${TEST NAME} - Creating Keyword Failed 1 297 + Creating Keyword Failed 1 295 ... Invalid \${x:(} Regexp ... Compiling embedded arguments regexp failed: * @@ -142,19 +142,19 @@ Embedded Arguments In Resource File Used Explicitly ${tc} = Check Test Case ${TEST NAME} Check Keyword Data ${tc.kws[0]} embedded_args_in_uk_1.peke uses resource file \${ret} -Embedded And Positional Arguments Do Not Work Together +Keyword with only embedded arguments doesn't accept normal arguments Check Test Case ${TEST NAME} Keyword with embedded args cannot be used as "normal" keyword Check Test Case ${TEST NAME} -Keyword with both normal and embedded arguments - Check Test Case ${TEST NAME} - -Keyword with both normal, positional and embedded arguments - Check Test Case ${TEST NAME} +Keyword with both embedded and normal arguments + ${tc} = Check Test Case ${TEST NAME} + Check Log message ${tc.body[0].body[0].msgs[0]} 2 horses are walking + Check Log message ${tc.body[1].body[0].msgs[0]} 2 horses are swimming + Check Log message ${tc.body[2].body[0].msgs[0]} 3 dogs are walking -Keyword with both normal and embedded arguments with too few arguments +Keyword with both embedded and normal arguments with too few arguments Check Test Case ${TEST NAME} Keyword matching multiple keywords in test case file diff --git a/atest/testdata/keywords/embedded_arguments.robot b/atest/testdata/keywords/embedded_arguments.robot index 534b60a2bc3..f87496b8b81 100644 --- a/atest/testdata/keywords/embedded_arguments.robot +++ b/atest/testdata/keywords/embedded_arguments.robot @@ -153,7 +153,7 @@ Embedded Arguments In Resource File Used Explicitly Should Be Equal ${ret} peke-resource embedded_args_in_uk_2.-r1-r2-+r1+ -Embedded And Positional Arguments Do Not Work Together +Keyword with only embedded arguments doesn't accept normal arguments [Documentation] FAIL Keyword 'User \${user} Selects \${item} From Webshop' expected 0 arguments, got 1. Given this "usage" with @{EMPTY} works @{EMPTY} Then User Invalid Selects Invalid From Webshop invalid @@ -162,14 +162,12 @@ Keyword with embedded args cannot be used as "normal" keyword [Documentation] FAIL Variable '${user}' not found. User ${user} Selects ${item} From Webshop -Keyword with both normal and embedded arguments +Keyword with both embedded and normal arguments Number of horses should be 2 - Number of dogs should be count=3 - -Keyword with both normal, positional and embedded arguments Number of horses should be 2 swimming + Number of dogs should be count=3 -Keyword with both normal and embedded arguments with too few arguments +Keyword with both embedded and normal arguments with too few arguments [Documentation] FAIL Keyword 'Number of ${animals} should be' expected 1 to 2 arguments, got 0. Number of horses should be @@ -314,4 +312,4 @@ It is totally ${same} Number of ${animals} should be [Arguments] ${count} ${activity}=walking - Log to console Checking if ${count} ${animals} are ${activity} + Log ${count} ${animals} are ${activity} From 4fbcff469f6dbafd357a2e5a99eec42973cee023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 26 Oct 2023 01:06:22 +0300 Subject: [PATCH 0799/1592] Reorganize documentation. --- src/robot/libraries/BuiltIn.py | 68 +++++++++++++++++----------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index f86cc7049e9..6626c12138d 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -3712,40 +3712,6 @@ class BuiltIn(_Verify, _Converter, _Variables, _RunKeyword, _Control, _Misc): HTML in messages is not limited to BuiltIn library but works with any error message. - = Using variables with keywords creating or accessing variables = - - This library has special keywords `Set Global Variable`, `Set Suite Variable`, - `Set Test Variable` and `Set Local Variable` for creating variables in - different scopes. These keywords take the variable name and its value as - arguments. The name can be given using the normal ``${variable}`` syntax or - in escaped format either like ``$variable`` or ``\${variable}``. For example, - these are typically equivalent and create new suite level variable - ``${name}`` with value ``value``: - - | Set Suite Variable ${name} value - | Set Suite Variable $name value - | Set Suite Variable \${name} value - - A problem with using the normal ``${variable}`` syntax is that these - keywords cannot easily know is the idea to create a variable with exactly - that name or does that variable actually contain the name of the variable - to create. If the variable does not initially exist, it will always be - created. If it exists and its value is a variable name either in the normal - or in the escaped syntax, variable with _that_ name is created instead. - For example, if ``${name}`` variable would exist and contain value - ``$example``, these examples would create different variables: - - | Set Suite Variable ${name} value # Creates ${example}. - | Set Suite Variable $name value # Creates ${name}. - | Set Suite Variable \${name} value # Creates ${name}. - - Because the behavior when using the normal ``${variable}`` syntax depends - on the possible existing value of the variable, it is *highly recommended - to use the escaped ``$variable`` or ``\${variable}`` format instead*. - - This same problem occurs also with special keywords for accessing variables - `Get Variable Value`, `Variable Should Exist` and `Variable Should Not Exist`. - = Evaluating expressions = Many keywords, such as `Evaluate`, `Run Keyword If` and `Should Be True`, @@ -3810,6 +3776,40 @@ class BuiltIn(_Verify, _Converter, _Variables, _RunKeyword, _Control, _Misc): to move the logic into a library. That eases maintenance and can also enhance execution speed. + = Using variables with keywords creating or accessing variables = + + This library has special keywords `Set Global Variable`, `Set Suite Variable`, + `Set Test Variable` and `Set Local Variable` for creating variables in + different scopes. These keywords take the variable name and its value as + arguments. The name can be given using the normal ``${variable}`` syntax or + in escaped format either like ``$variable`` or ``\${variable}``. For example, + these are typically equivalent and create new suite level variable + ``${name}`` with value ``value``: + + | Set Suite Variable ${name} value + | Set Suite Variable $name value + | Set Suite Variable \${name} value + + A problem with using the normal ``${variable}`` syntax is that these + keywords cannot easily know is the idea to create a variable with exactly + that name or does that variable actually contain the name of the variable + to create. If the variable does not initially exist, it will always be + created. If it exists and its value is a variable name either in the normal + or in the escaped syntax, variable with _that_ name is created instead. + For example, if ``${name}`` variable would exist and contain value + ``$example``, these examples would create different variables: + + | Set Suite Variable ${name} value # Creates ${example}. + | Set Suite Variable $name value # Creates ${name}. + | Set Suite Variable \${name} value # Creates ${name}. + + Because the behavior when using the normal ``${variable}`` syntax depends + on the possible existing value of the variable, it is *highly recommended + to use the escaped ``$variable`` or ``\${variable}`` format instead*. + + This same problem occurs also with special keywords for accessing variables + `Get Variable Value`, `Variable Should Exist` and `Variable Should Not Exist`. + = Boolean arguments = Some keywords accept arguments that are handled as Boolean values true or From fcd96f6c3895808888d237b356e629e8cab13c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 26 Oct 2023 01:21:07 +0300 Subject: [PATCH 0800/1592] Recommend VAR over Set Variable keywords. #3761 --- src/robot/libraries/BuiltIn.py | 42 ++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 6626c12138d..99f2a2c06bc 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1620,16 +1620,23 @@ def set_variable(self, *values): It is recommended to use `Create List` when creating new lists. Examples: - | ${hi} = | Set Variable | Hello, world! | - | ${hi2} = | Set Variable | I said: ${hi} | - | ${var1} | ${var2} = | Set Variable | Hello | world | - | @{list} = | Set Variable | ${list with some items} | - | ${item1} | ${item2} = | Set Variable | ${list with 2 items} | + | ${hi} = Set Variable Hello, world! + | ${hi2} = Set Variable I said: ${hi} + | ${var1} ${var2} = Set Variable Hello world + | @{list} = Set Variable ${list with some items} + | ${item1} ${item2} = Set Variable ${list with 2 items} Variables created with this keyword are available only in the scope where they are created. See `Set Global Variable`, `Set Test Variable` and `Set Suite Variable` for information on how to set variables so that they are available also in a larger scope. + + *NOTE:* The ``VAR`` syntax introduced in Robot Framework 7.0 is generally + recommended over this keyword. The basic usage is shown below and the Robot + Framework User Guide explains the syntax in detail. + + | VAR ${hi} Hello, world! + | VAR ${hi2} I said: ${hi} """ if len(values) == 0: return '' @@ -1669,6 +1676,9 @@ def set_local_variable(self, name, *values): ``${name}``. See also `Set Global Variable` and `Set Test Variable`. + + *NOTE:* The ``VAR`` syntax introduced in Robot Framework 7.0 is recommended + over this keyword. """ name = self._get_var_name(name) value = self._get_var_value(name, values) @@ -1695,6 +1705,9 @@ def set_test_variable(self, name, *values): When creating automated tasks, not tests, it is possible to use `Set Task Variable`. See also `Set Global Variable` and `Set Local Variable`. + + *NOTE:* The ``VAR`` syntax introduced in Robot Framework 7.0 is recommended + over this keyword. """ name = self._get_var_name(name) value = self._get_var_value(name, values) @@ -1707,6 +1720,9 @@ def set_task_variable(self, name, *values): This is an alias for `Set Test Variable` that is more applicable when creating tasks, not tests. + + *NOTE:* The ``VAR`` syntax introduced in Robot Framework 7.0 is recommended + over this keyword. """ self.set_test_variable(name, *values) @@ -1759,6 +1775,14 @@ def set_suite_variable(self, name, *values): | Set Suite Variable &DICT &{EMPTY} See also `Set Global Variable`, `Set Test Variable` and `Set Local Variable`. + + *NOTE:* The ``VAR`` syntax introduced in Robot Framework 7.0 is recommended + over this keyword. The basic usage is shown below and the Robot Framework + User Guide explains the syntax in detail. + + | VAR ${SCALAR} Hello, world! scope=SUITE + | VAR @{LIST} First item Second item scope=SUITE + | VAR &{DICT} key=value foo=bar scope=SUITE """ name = self._get_var_name(name) if values and is_string(values[-1]) and values[-1].startswith('children='): @@ -1792,6 +1816,9 @@ def set_global_variable(self, name, *values): section for information why it is recommended to give the variable name in escaped format like ``$name`` or ``\${name}`` instead of the normal ``${name}``. + + *NOTE:* The ``VAR`` syntax introduced in Robot Framework 7.0 is recommended + over this keyword. """ name = self._get_var_name(name) value = self._get_var_value(name, values) @@ -3810,6 +3837,11 @@ class BuiltIn(_Verify, _Converter, _Variables, _RunKeyword, _Control, _Misc): This same problem occurs also with special keywords for accessing variables `Get Variable Value`, `Variable Should Exist` and `Variable Should Not Exist`. + *NOTE:* It is recommended to use the ``VAR`` syntax introduced in Robot + Framework 7.0 for creating variables in different scopes instead of the + `Set Global/Suite/Test/Local Variable` keywords. It makes creating variables + uniform and avoids all the problems discussed above. + = Boolean arguments = Some keywords accept arguments that are handled as Boolean values true or From 522c3a5cdabd4318ac25b7409256865281472f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 26 Oct 2023 02:47:23 +0300 Subject: [PATCH 0801/1592] Saner and faster Set Variable If implemenation. Fixes #4886. --- .../builtin/set_variable_if.robot | 2 + .../builtin/set_variable_if.robot | 212 +++++++++++++++++- src/robot/libraries/BuiltIn.py | 26 ++- 3 files changed, 224 insertions(+), 16 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/set_variable_if.robot b/atest/robot/standard_libraries/builtin/set_variable_if.robot index 622fd25d053..c49447cb33e 100644 --- a/atest/robot/standard_libraries/builtin/set_variable_if.robot +++ b/atest/robot/standard_libraries/builtin/set_variable_if.robot @@ -46,3 +46,5 @@ With List Variables In Expressions And Values With List Variables Containing Escaped Values Check Test Case ${TESTNAME} +Lot of conditions + Check Test Case ${TESTNAME} diff --git a/atest/testdata/standard_libraries/builtin/set_variable_if.robot b/atest/testdata/standard_libraries/builtin/set_variable_if.robot index 5e1177d375e..d8e8a95bd88 100644 --- a/atest/testdata/standard_libraries/builtin/set_variable_if.robot +++ b/atest/testdata/standard_libraries/builtin/set_variable_if.robot @@ -37,11 +37,11 @@ Invalid Expression Set Variable If invalid expr whatever values Fails Without Values 1 - [Documentation] FAIL At least one value is required + [Documentation] FAIL At least one value is required. Set Variable If True Fails Without Values 2 - [Documentation] FAIL At least one value is required + [Documentation] FAIL At least one value is required. Set Variable If False Non-Existing Variables In Values 1 @@ -95,11 +95,11 @@ If / Else If / Else Should Be Equal ${var} ${None} With Empty List Variables 1 - [Documentation] FAIL At least one value is required + [Documentation] FAIL At least one value is required. Set Variable If True @{EMPTY LIST} With Empty List Variables 2 - [Documentation] FAIL At least one value is required + [Documentation] FAIL At least one value is required. Set Variable If False @{EMPTY LIST} @{EMPTY LIST} @{EMPTY LIST} With Empty List Variables 3 @@ -146,3 +146,207 @@ With List Variables Containing Escaped Values ${var} = Set Variable If @{NEEDS ESCAPING 3} Should Be Equal ${var} c:\\temp\\foo +Lot of conditions + ${var} = Set Variable If + ... ${0} > 0 not set + ... ${0} > 1 not set + ... ${0} > 2 not set + ... ${0} > 3 not set + ... ${0} > 4 not set + ... ${0} > 5 not set + ... ${0} > 6 not set + ... ${0} > 7 not set + ... ${0} > 8 not set + ... ${0} > 9 not set + ... ${0} > 10 not set + ... ${0} > 11 not set + ... ${0} > 12 not set + ... ${0} > 13 not set + ... ${0} > 14 not set + ... ${0} > 15 not set + ... ${0} > 16 not set + ... ${0} > 17 not set + ... ${0} > 18 not set + ... ${0} > 19 not set + ... ${0} > 20 not set + ... ${0} > 21 not set + ... ${0} > 22 not set + ... ${0} > 23 not set + ... ${0} > 24 not set + ... ${0} > 25 not set + ... ${0} > 26 not set + ... ${0} > 27 not set + ... ${0} > 28 not set + ... ${0} > 29 not set + ... ${0} > 30 not set + ... ${0} > 31 not set + ... ${0} > 32 not set + ... ${0} > 33 not set + ... ${0} > 34 not set + ... ${0} > 35 not set + ... ${0} > 36 not set + ... ${0} > 37 not set + ... ${0} > 38 not set + ... ${0} > 39 not set + ... ${0} > 40 not set + ... ${0} > 41 not set + ... ${0} > 42 not set + ... ${0} > 43 not set + ... ${0} > 44 not set + ... ${0} > 45 not set + ... ${0} > 46 not set + ... ${0} > 47 not set + ... ${0} > 48 not set + ... ${0} > 49 not set + ... ${0} > 50 not set + ... ${0} > 51 not set + ... ${0} > 52 not set + ... ${0} > 53 not set + ... ${0} > 54 not set + ... ${0} > 55 not set + ... ${0} > 56 not set + ... ${0} > 57 not set + ... ${0} > 58 not set + ... ${0} > 59 not set + ... ${0} > 60 not set + ... ${0} > 61 not set + ... ${0} > 62 not set + ... ${0} > 63 not set + ... ${0} > 64 not set + ... ${0} > 65 not set + ... ${0} > 66 not set + ... ${0} > 67 not set + ... ${0} > 68 not set + ... ${0} > 69 not set + ... ${0} > 70 not set + ... ${0} > 71 not set + ... ${0} > 72 not set + ... ${0} > 73 not set + ... ${0} > 74 not set + ... ${0} > 75 not set + ... ${0} > 76 not set + ... ${0} > 77 not set + ... ${0} > 78 not set + ... ${0} > 79 not set + ... ${0} > 80 not set + ... ${0} > 81 not set + ... ${0} > 82 not set + ... ${0} > 83 not set + ... ${0} > 84 not set + ... ${0} > 85 not set + ... ${0} > 86 not set + ... ${0} > 87 not set + ... ${0} > 88 not set + ... ${0} > 89 not set + ... ${0} > 90 not set + ... ${0} > 91 not set + ... ${0} > 92 not set + ... ${0} > 93 not set + ... ${0} > 94 not set + ... ${0} > 95 not set + ... ${0} > 96 not set + ... ${0} > 97 not set + ... ${0} > 98 not set + ... ${0} > 99 not set + ... ${0} > 100 not set + ... ${0} > 101 not set + ... ${0} > 102 not set + ... ${0} > 103 not set + ... ${0} > 104 not set + ... ${0} > 105 not set + ... ${0} > 106 not set + ... ${0} > 107 not set + ... ${0} > 108 not set + ... ${0} > 109 not set + ... ${0} > 110 not set + ... ${0} > 111 not set + ... ${0} > 112 not set + ... ${0} > 113 not set + ... ${0} > 114 not set + ... ${0} > 115 not set + ... ${0} > 116 not set + ... ${0} > 117 not set + ... ${0} > 118 not set + ... ${0} > 119 not set + ... ${0} > 120 not set + ... ${0} > 121 not set + ... ${0} > 122 not set + ... ${0} > 123 not set + ... ${0} > 124 not set + ... ${0} > 125 not set + ... ${0} > 126 not set + ... ${0} > 127 not set + ... ${0} > 128 not set + ... ${0} > 129 not set + ... ${0} > 130 not set + ... ${0} > 131 not set + ... ${0} > 132 not set + ... ${0} > 133 not set + ... ${0} > 134 not set + ... ${0} > 135 not set + ... ${0} > 136 not set + ... ${0} > 137 not set + ... ${0} > 138 not set + ... ${0} > 139 not set + ... ${0} > 140 not set + ... ${0} > 141 not set + ... ${0} > 142 not set + ... ${0} > 143 not set + ... ${0} > 144 not set + ... ${0} > 145 not set + ... ${0} > 146 not set + ... ${0} > 147 not set + ... ${0} > 148 not set + ... ${0} > 149 not set + ... ${0} > 150 not set + ... ${0} > 151 not set + ... ${0} > 152 not set + ... ${0} > 153 not set + ... ${0} > 154 not set + ... ${0} > 155 not set + ... ${0} > 156 not set + ... ${0} > 157 not set + ... ${0} > 158 not set + ... ${0} > 159 not set + ... ${0} > 160 not set + ... ${0} > 161 not set + ... ${0} > 162 not set + ... ${0} > 163 not set + ... ${0} > 164 not set + ... ${0} > 165 not set + ... ${0} > 166 not set + ... ${0} > 167 not set + ... ${0} > 168 not set + ... ${0} > 169 not set + ... ${0} > 170 not set + ... ${0} > 171 not set + ... ${0} > 172 not set + ... ${0} > 173 not set + ... ${0} > 174 not set + ... ${0} > 175 not set + ... ${0} > 176 not set + ... ${0} > 177 not set + ... ${0} > 178 not set + ... ${0} > 179 not set + ... ${0} > 180 not set + ... ${0} > 181 not set + ... ${0} > 182 not set + ... ${0} > 183 not set + ... ${0} > 184 not set + ... ${0} > 185 not set + ... ${0} > 186 not set + ... ${0} > 187 not set + ... ${0} > 188 not set + ... ${0} > 189 not set + ... ${0} > 190 not set + ... ${0} > 191 not set + ... ${0} > 192 not set + ... ${0} > 193 not set + ... ${0} > 194 not set + ... ${0} > 195 not set + ... ${0} > 196 not set + ... ${0} > 197 not set + ... ${0} > 198 not set + ... ${0} > 199 not set + ... ${0} > -1 set + Should Be Equal ${var} set diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 99f2a2c06bc..e0a59d62b25 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -2452,19 +2452,21 @@ def set_variable_if(self, condition, *values): Use `Get Variable Value` if you need to set variables dynamically based on whether a variable exist or not. """ - values = self._verify_values_for_set_variable_if(list(values)) - if self._is_true(condition): - return self._variables.replace_scalar(values[0]) - values = self._verify_values_for_set_variable_if(values[1:], True) - if len(values) == 1: - return self._variables.replace_scalar(values[0]) - return self.run_keyword('BuiltIn.Set Variable If', *values[0:]) - - def _verify_values_for_set_variable_if(self, values, default=False): + values = list(values) + while True: + values = self._verify_values_for_set_variable_if(values) + if self._is_true(condition): + return self._variables.replace_scalar(values[0]) + if len(values) == 1: + return None + if len(values) == 2: + return self._variables.replace_scalar(values[1]) + condition, *values = values[1:] + condition = self._variables.replace_scalar(condition) + + def _verify_values_for_set_variable_if(self, values): if not values: - if default: - return [None] - raise RuntimeError('At least one value is required') + raise RuntimeError('At least one value is required.') if is_list_variable(values[0]): values[:1] = [escape(item) for item in self._variables[values[0]]] return self._verify_values_for_set_variable_if(values) From 1a4d2522ec63098b012332f4eeeeaeaead55b772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 26 Oct 2023 13:24:05 +0300 Subject: [PATCH 0802/1592] Revert changing FOR and EXCEPT variable token types. #4708 We had changed the token type used with FOR variables and with EXCEPT's AS variable from VARIABLE to ASSIGN for them to be consistent with variables assigned with keyword calls. It later turned out that we still use VARIABLE in other places for similar purposes so inconsistency remains. In addition to that, we have noticed some other inconsistencies and problems with token types. We don't have time to properly go through them all now in RF 7. Better to postpone this to RF 8 altogether so that we aren't making backwards incompatible token type changes in every release. Also some error message tuning. --- .../try_except/invalid_try_except.robot | 6 +-- src/robot/parsing/lexer/statementlexers.py | 4 +- src/robot/parsing/model/statements.py | 22 ++++---- utest/parsing/test_lexer.py | 16 +++--- utest/parsing/test_model.py | 53 ++++++++++++++----- utest/parsing/test_statements.py | 8 +-- 6 files changed, 68 insertions(+), 41 deletions(-) diff --git a/atest/testdata/running/try_except/invalid_try_except.robot b/atest/testdata/running/try_except/invalid_try_except.robot index c3c1dd535f5..0a117274627 100644 --- a/atest/testdata/running/try_except/invalid_try_except.robot +++ b/atest/testdata/running/try_except/invalid_try_except.robot @@ -77,7 +77,7 @@ Multiple default EXCEPTs END AS requires variable - [Documentation] FAIL EXCEPT's AS requires variable. + [Documentation] FAIL EXCEPT AS requires a value. TRY Fail Should not be executed EXCEPT AS @@ -85,7 +85,7 @@ AS requires variable END AS accepts only one variable - [Documentation] FAIL EXCEPT's AS accepts only one variable. + [Documentation] FAIL EXCEPT AS accepts only one value. TRY Fail Should not be executed EXCEPT AS foo ${foo} @@ -93,7 +93,7 @@ AS accepts only one variable END Invalid AS variable - [Documentation] FAIL EXCEPT's AS variable 'foo' is invalid. + [Documentation] FAIL EXCEPT AS variable 'foo' is invalid. TRY Fail Should not be executed EXCEPT AS foo diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index 34bfa857b03..487ab2d5c55 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -234,7 +234,7 @@ def lex(self): token.type = Token.FOR_SEPARATOR separator = normalize_whitespace(token.value) else: - token.type = Token.ASSIGN + token.type = Token.VARIABLE if separator == 'IN ENUMERATE': self._lex_options('start') elif separator == 'IN ZIP': @@ -307,7 +307,7 @@ def lex(self): token.type = Token.AS as_index = index elif as_index: - token.type = Token.ASSIGN + token.type = Token.VARIABLE else: token.type = Token.ARGUMENT self._lex_options('type', end_index=as_index) diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 73b9f9cbc98..ff169f1802d 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -924,7 +924,7 @@ def from_params(cls, assign: 'Sequence[str]', Token(Token.FOR), Token(Token.SEPARATOR, separator)] for variable in assign: - tokens.extend([Token(Token.ASSIGN, variable), + tokens.extend([Token(Token.VARIABLE, variable), Token(Token.SEPARATOR, separator)]) tokens.append(Token(Token.FOR_SEPARATOR, flavor)) for value in values: @@ -935,7 +935,7 @@ def from_params(cls, assign: 'Sequence[str]', @property def assign(self) -> 'tuple[str, ...]': - return self.get_values(Token.ASSIGN) + return self.get_values(Token.VARIABLE) @property def variables(self) -> 'tuple[str, ...]': # TODO: Remove in RF 8.0. @@ -1117,7 +1117,7 @@ def from_params(cls, patterns: 'Sequence[str]' = (), type: 'str|None' = None, tokens.extend([Token(Token.SEPARATOR, separator), Token(Token.AS), Token(Token.SEPARATOR, separator), - Token(Token.ASSIGN, assign)]) + Token(Token.VARIABLE, assign)]) tokens.append(Token(Token.EOL, eol)) return cls(tokens) @@ -1131,7 +1131,7 @@ def pattern_type(self) -> 'str|None': @property def assign(self) -> 'str|None': - return self.get_value(Token.ASSIGN) + return self.get_value(Token.VARIABLE) @property def variable(self) -> 'str|None': # TODO: Remove in RF 8.0. @@ -1142,13 +1142,13 @@ def variable(self) -> 'str|None': # TODO: Remove in RF 8.0. def validate(self, ctx: 'ValidationContext'): as_token = self.get_token(Token.AS) if as_token: - variables = self.get_tokens(Token.ASSIGN) - if not variables: - self.errors += ("EXCEPT's AS requires variable.",) - elif len(variables) > 1: - self.errors += ("EXCEPT's AS accepts only one variable.",) - elif not is_scalar_assign(variables[0].value): - self.errors += (f"EXCEPT's AS variable '{variables[0].value}' is invalid.",) + assign = self.get_tokens(Token.VARIABLE) + if not assign: + self.errors += ("EXCEPT AS requires a value.",) + elif len(assign) > 1: + self.errors += ("EXCEPT AS accepts only one value.",) + elif not is_scalar_assign(assign[0].value): + self.errors += (f"EXCEPT AS variable '{assign[0].value}' is invalid.",) self._validate_options() diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 15de36e29c8..266ac25ea32 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -993,7 +993,7 @@ def test_for_loop_header(self): header = 'FOR ${i} IN foo bar' expected = [ (T.FOR, 'FOR', 3, 4), - (T.ASSIGN, '${i}', 3, 11), + (T.VARIABLE, '${i}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, 'foo', 3, 25), (T.ARGUMENT, 'bar', 3, 32), @@ -2007,7 +2007,7 @@ def test_in_for(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.ASSIGN, '${x}', 3, 11), + (T.VARIABLE, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2058,7 +2058,7 @@ def test_in_if(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.ASSIGN, '${x}', 3, 11), + (T.VARIABLE, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2084,7 +2084,7 @@ def test_in_try(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.ASSIGN, '${x}', 3, 11), + (T.VARIABLE, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2109,7 +2109,7 @@ def test_in_for(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.ASSIGN, '${x}', 3, 11), + (T.VARIABLE, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2174,7 +2174,7 @@ def test_in_if(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.ASSIGN, '${x}', 3, 11), + (T.VARIABLE, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2196,7 +2196,7 @@ def test_in_for(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.ASSIGN, '${x}', 3, 11), + (T.VARIABLE, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), @@ -2232,7 +2232,7 @@ def test_in_try(self): END ''' expected = [(T.FOR, 'FOR', 3, 4), - (T.ASSIGN, '${x}', 3, 11), + (T.VARIABLE, '${x}', 3, 11), (T.FOR_SEPARATOR, 'IN', 3, 19), (T.ARGUMENT, '@{STUFF}', 3, 25), (T.EOS, '', 3, 33), diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 97273fd4386..e8ad6aebd41 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -228,7 +228,7 @@ def test_valid(self): expected = For( header=ForHeader([ Token(Token.FOR, 'FOR', 3, 4), - Token(Token.ASSIGN, '${x}', 3, 11), + Token(Token.VARIABLE, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 19), Token(Token.ARGUMENT, 'a', 3, 25), Token(Token.ARGUMENT, 'b', 3, 30), @@ -255,7 +255,7 @@ def test_enumerate_with_start(self): expected = For( header=ForHeader([ Token(Token.FOR, 'FOR', 3, 4), - Token(Token.ASSIGN, '${x}', 3, 11), + Token(Token.VARIABLE, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN ENUMERATE', 3, 19), Token(Token.ARGUMENT, '@{stuff}', 3, 35), Token(Token.OPTION, 'start=1', 3, 47), @@ -283,7 +283,7 @@ def test_nested(self): expected = For( header=ForHeader([ Token(Token.FOR, 'FOR', 3, 4), - Token(Token.ASSIGN, '${x}', 3, 11), + Token(Token.VARIABLE, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 19), Token(Token.ARGUMENT, '1', 3, 25), Token(Token.ARGUMENT, 'start=has no special meaning here', 3, 30), @@ -292,7 +292,7 @@ def test_nested(self): For( header=ForHeader([ Token(Token.FOR, 'FOR', 4, 8), - Token(Token.ASSIGN, '${y}', 4, 15), + Token(Token.VARIABLE, '${y}', 4, 15), Token(Token.FOR_SEPARATOR, 'IN RANGE', 4, 23), Token(Token.ARGUMENT, '${x}', 4, 35), ]), @@ -340,7 +340,7 @@ def test_invalid(self): expected2 = For( header=ForHeader( tokens=[Token(Token.FOR, 'FOR', 3, 4), - Token(Token.ASSIGN, 'wrong', 3, 11), + Token(Token.VARIABLE, 'wrong', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 20)], errors=("FOR loop has invalid loop variable 'wrong'.", "FOR loop has no loop values."), @@ -799,7 +799,7 @@ def test_try_except_else_finally(self): next=Try( header=ExceptHeader([Token(Token.EXCEPT, 'EXCEPT', 7, 4), Token(Token.AS, 'AS', 7, 14), - Token(Token.ASSIGN, '${exp}', 7, 20)]), + Token(Token.VARIABLE, '${exp}', 7, 20)]), body=[KeywordCall([Token(Token.KEYWORD, 'Log', 8, 8), Token(Token.ARGUMENT, 'Catch', 8, 15)])], next=Try( @@ -827,6 +827,8 @@ def test_invalid(self): FINALLY invalid # EXCEPT AS invalid + EXCEPT AS + EXCEPT AS ${too} ${many} ${values} EXCEPT xx type=invalid ''' expected = Try( @@ -853,29 +855,54 @@ def test_invalid(self): header=ExceptHeader( tokens=[Token(Token.EXCEPT, 'EXCEPT', 8, 4), Token(Token.AS, 'AS', 8, 14), - Token(Token.ASSIGN, 'invalid', 8, 20)], - errors=("EXCEPT's AS variable 'invalid' is invalid.",) + Token(Token.VARIABLE, 'invalid', 8, 20)], + errors=("EXCEPT AS variable 'invalid' is invalid.",) ), errors=('EXCEPT branch cannot be empty.',), next=Try( header=ExceptHeader( tokens=[Token(Token.EXCEPT, 'EXCEPT', 9, 4), - Token(Token.ARGUMENT, 'xx', 9, 14), - Token(Token.OPTION, 'type=invalid', 9, 20)], - errors=("EXCEPT option 'type' does not accept value 'invalid'. " - "Valid values are 'GLOB', 'REGEXP', 'START' and 'LITERAL'.",) + Token(Token.AS, 'AS', 9, 14)], + errors=("EXCEPT AS requires a value.",) ), errors=('EXCEPT branch cannot be empty.',), + next=Try( + header=ExceptHeader( + tokens=[Token(Token.EXCEPT, 'EXCEPT', 10, 4), + Token(Token.AS, 'AS', 10, 14), + Token(Token.VARIABLE, '${too}', 10, 20), + Token(Token.VARIABLE, '${many}', 10, 30), + Token(Token.VARIABLE, '${values}', 10, 41)], + errors=("EXCEPT AS accepts only one value.",) + ), + errors=('EXCEPT branch cannot be empty.',), + next=Try( + header=ExceptHeader( + tokens=[Token(Token.EXCEPT, 'EXCEPT', 11, 4), + Token(Token.ARGUMENT, 'xx', 11, 14), + Token(Token.OPTION, 'type=invalid', 11, 20)], + errors=("EXCEPT option 'type' does not accept value 'invalid'. " + "Valid values are 'GLOB', 'REGEXP', 'START' and 'LITERAL'.",) + ), + errors=('EXCEPT branch cannot be empty.',), + ) + + ) ) ) ), ), errors=('TRY branch cannot be empty.', + 'EXCEPT not allowed after ELSE.', + 'EXCEPT not allowed after FINALLY.', + 'EXCEPT not allowed after ELSE.', + 'EXCEPT not allowed after FINALLY.', 'EXCEPT not allowed after ELSE.', 'EXCEPT not allowed after FINALLY.', 'EXCEPT not allowed after ELSE.', 'EXCEPT not allowed after FINALLY.', 'EXCEPT without patterns must be last.', + 'Only one EXCEPT without patterns allowed.', 'TRY must have closing END.') ) get_and_assert_model(data, expected) @@ -1307,7 +1334,7 @@ def test_continue(self): ''' expected = For( header=ForHeader([Token(Token.FOR, 'FOR', 3, 4), - Token(Token.ASSIGN, '${x}', 3, 11), + Token(Token.VARIABLE, '${x}', 3, 11), Token(Token.FOR_SEPARATOR, 'IN', 3, 19), Token(Token.ARGUMENT, '@{stuff}', 3, 25)]), body=[KeywordCall([Token(Token.KEYWORD, 'Continue', 4, 8), diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index 244f22825ab..dd718a4d6f4 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -696,9 +696,9 @@ def test_ForHeader(self): Token(Token.SEPARATOR, ' '), Token(Token.FOR), Token(Token.SEPARATOR, ' '), - Token(Token.ASSIGN, '${value1}'), + Token(Token.VARIABLE, '${value1}'), Token(Token.SEPARATOR, ' '), - Token(Token.ASSIGN, '${value2}'), + Token(Token.VARIABLE, '${value2}'), Token(Token.SEPARATOR, ' '), Token(Token.FOR_SEPARATOR, 'IN ZIP'), Token(Token.SEPARATOR, ' '), @@ -841,7 +841,7 @@ def test_ExceptHeader(self): Token(Token.SEPARATOR, ' '), Token(Token.AS, 'AS'), Token(Token.SEPARATOR, ' '), - Token(Token.ASSIGN, '${var}'), + Token(Token.VARIABLE, '${var}'), Token(Token.EOL, '\n') ] assert_created_statement( @@ -879,7 +879,7 @@ def test_ExceptHeader(self): Token(Token.SEPARATOR, ' '), Token(Token.AS, 'AS'), Token(Token.SEPARATOR, ' '), - Token(Token.ASSIGN, '${var}'), + Token(Token.VARIABLE, '${var}'), Token(Token.EOL, '\n')] assert_created_statement( tokens, From 6de25e64190c5b6947ef920a4772150b1ba4aaf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 26 Oct 2023 18:35:46 +0300 Subject: [PATCH 0803/1592] Consistent TODOs related to deprecated token types. --- src/robot/parsing/lexer/tokens.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index eebc54f8855..f38dfee8893 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -49,7 +49,7 @@ class Token: KEYWORD_HEADER = 'KEYWORD HEADER' COMMENT_HEADER = 'COMMENT HEADER' INVALID_HEADER = 'INVALID HEADER' - FATAL_INVALID_HEADER = 'FATAL INVALID HEADER' + FATAL_INVALID_HEADER = 'FATAL INVALID HEADER' # TODO: Remove in RF 8. TESTCASE_NAME = 'TESTCASE NAME' KEYWORD_NAME = 'KEYWORD NAME' @@ -63,7 +63,7 @@ class Token: TEST_TEMPLATE = 'TEST TEMPLATE' TEST_TIMEOUT = 'TEST TIMEOUT' TEST_TAGS = 'TEST TAGS' - FORCE_TAGS = TEST_TAGS # TODO: Remove FORCE_TAGS in RF 8. + FORCE_TAGS = TEST_TAGS # TODO: Remove in RF 8. DEFAULT_TAGS = 'DEFAULT TAGS' KEYWORD_TAGS = 'KEYWORD TAGS' LIBRARY = 'LIBRARY' @@ -75,13 +75,11 @@ class Token: TIMEOUT = 'TIMEOUT' TAGS = 'TAGS' ARGUMENTS = 'ARGUMENTS' - # Use ´RETURN_SETTING` type instead of `RETURN`. `[Return]` is deprecated and - # `RETURN` type will be used with `RETURN` statement in the future. - RETURN = 'RETURN' - RETURN_SETTING = RETURN + RETURN = 'RETURN' # TODO: Change to mean RETURN statement in RF 8. + RETURN_SETTING = RETURN # TODO: Remove in RF 8. AS = 'AS' - WITH_NAME = AS # TODO: Remove WITH_NAME in RF 8. + WITH_NAME = AS # TODO: Remove in RF 8. NAME = 'NAME' VARIABLE = 'VARIABLE' @@ -111,10 +109,8 @@ class Token: CONFIG = 'CONFIG' EOL = 'EOL' EOS = 'EOS' - ERROR = 'ERROR' - # TODO: FATAL_ERROR is no longer used, remove in RF 7.0 - FATAL_ERROR = 'FATAL ERROR' + FATAL_ERROR = 'FATAL ERROR' # TODO: Remove in RF 8. NON_DATA_TOKENS = frozenset(( SEPARATOR, From c35b82b7554127177a2e23f0e27eb5302e1bf295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 26 Oct 2023 18:50:29 +0300 Subject: [PATCH 0804/1592] Don't trace log embedded user keyword arguments twice. Fixes #4913. --- atest/robot/keywords/trace_log_keyword_arguments.robot | 9 +++++++-- .../keywords/trace_log_keyword_arguments.robot | 10 ++++++---- src/robot/running/userkeywordrunner.py | 7 +++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/atest/robot/keywords/trace_log_keyword_arguments.robot b/atest/robot/keywords/trace_log_keyword_arguments.robot index fc0ea436f10..2b651548cbe 100644 --- a/atest/robot/keywords/trace_log_keyword_arguments.robot +++ b/atest/robot/keywords/trace_log_keyword_arguments.robot @@ -74,8 +74,13 @@ Arguments With Run Keyword Embedded Arguments ${tc}= Check Test Case ${TEST NAME} Check Log Message ${tc.kws[0].msgs[0]} Arguments: [ \${first}='foo' | \${second}=42 | \${what}='UK' ] TRACE - Check Log Message ${tc.kws[1].msgs[0]} Arguments: [ 'bar' | 'Embedded Arguments' ] TRACE - Check Log Message ${tc.kws[2].msgs[0]} Arguments: [ \${embedded}='Embedded' | \${keyword}='keyword' | \${positional}='positively' ] TRACE + Check Log Message ${tc.kws[1].msgs[0]} Arguments: [ 'bar' | 'Embedded Arguments' ] TRACE + Check Log Message ${tc.kws[2].msgs[0]} Arguments: [ \${embedded}='embedded' | \${normal}='argument' ] TRACE + Check Log Message ${tc.kws[3].msgs[0]} Arguments: [ \${embedded}='embedded' | \${normal}='argument' ] TRACE + FOR ${kw} IN @{tc.kws} + Check Log Message ${kw.msgs[-1]} Return: None TRACE + Length Should Be ${kw.msgs} 2 + END *** Keywords *** Check Argument Value Trace diff --git a/atest/testdata/keywords/trace_log_keyword_arguments.robot b/atest/testdata/keywords/trace_log_keyword_arguments.robot index 38086a8408b..07a585e6476 100644 --- a/atest/testdata/keywords/trace_log_keyword_arguments.robot +++ b/atest/testdata/keywords/trace_log_keyword_arguments.robot @@ -79,7 +79,8 @@ Arguments With Run Keyword Embedded Arguments Embedded Arguments "foo" and "${42}" with UK Embedded Arguments "bar" and "${TEST NAME}" - Embedded arguments in a keyword with positional arguments positively + Both embedded and normal arguments argument + Both embedded and normal arguments normal=argument *** Keywords *** Set Unicode Repr Object As Variable @@ -115,6 +116,7 @@ Embedded Arguments "${first}" and "${second}" with ${what:[KU]+} Should be Equal ${second} ${42} Should be Equal ${what} UK -${embedded} arguments in a ${keyword} with positional arguments - [arguments] ${positional} - Log to console ${embedded} ${keyword} ${positional} +Both ${embedded} and normal arguments + [Arguments] ${normal} + Should Be Equal ${embedded} embedded + Should Be Equal ${normal} argument diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 6095552ab37..0c7036f257b 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -144,7 +144,8 @@ def _split_kwonly_and_kwargs(self, all_kwargs): def _trace_log_args_message(self, variables): return self._format_trace_log_args_message( - self._format_args_for_trace_logging(), variables) + self._format_args_for_trace_logging(), variables + ) def _format_args_for_trace_logging(self): args = [f'${{{arg}}}' for arg in self.arguments.positional] @@ -258,8 +259,6 @@ def _set_arguments(self, args, context): for name, value in self.embedded_args: variables[f'${{{name}}}'] = value super()._set_arguments(args, context) - context.output.trace(lambda: self._trace_log_args_message(variables), - write_if_flat=False) def _trace_log_args_message(self, variables): args = [f'${{{arg}}}' for arg in self._handler.embedded.args] @@ -267,6 +266,6 @@ def _trace_log_args_message(self, variables): return self._format_trace_log_args_message(args, variables) def _get_result(self, kw, assignment, variables): - result = UserKeywordRunner._get_result(self, kw, assignment, variables) + result = super()._get_result(kw, assignment, variables) result.source_name = self._handler.name return result From f4ee8750f6d285b6b43bc9295ebb9520e2d4ed04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 30 Oct 2023 15:22:28 +0200 Subject: [PATCH 0805/1592] Avoid resolving arguments twice. Normal arguments were resolved twice with user keywords having both embedded and normal arguments. This didn't cause any real problems other than wasting time. --- src/robot/running/userkeywordrunner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index 0c7036f257b..f8f6e0e7351 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -248,11 +248,11 @@ def __init__(self, handler, name): self.embedded_args = handler.embedded.match(name).groups() def _resolve_arguments(self, args, variables=None): - self.arguments.resolve(args, variables) + result = super()._resolve_arguments(args, variables) if variables: embedded = [variables.replace_scalar(e) for e in self.embedded_args] self.embedded_args = self._handler.embedded.map(embedded) - return super()._resolve_arguments(args, variables) + return result def _set_arguments(self, args, context): variables = context.variables From a0cfc9ced498446bb37cb3c2e68f3f2550d98961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 31 Oct 2023 22:57:01 +0200 Subject: [PATCH 0806/1592] Cleanup. - arg_spec -> spec - f-strings --- .../running/arguments/argumentconverter.py | 14 ++++---- .../running/arguments/argumentresolver.py | 29 ++++++++-------- src/robot/running/arguments/argumentspec.py | 5 +++ src/robot/running/testlibraries.py | 33 +++++++++---------- 4 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index bfb0d21b33b..904ae8f7c59 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -33,7 +33,7 @@ def __init__(self, arg_spec: 'ArgumentSpec', custom_converters: 'CustomArgumentConverters', dry_run: bool = False, languages: 'LanguagesLike' = None): - self.arg_spec = arg_spec + self.spec = arg_spec self.custom_converters = custom_converters self.dry_run = dry_run self.languages = languages @@ -42,22 +42,22 @@ def convert(self, positional, named): return self._convert_positional(positional), self._convert_named(named) def _convert_positional(self, positional): - names = self.arg_spec.positional + names = self.spec.positional converted = [self._convert(name, value) for name, value in zip(names, positional)] - if self.arg_spec.var_positional: - converted.extend(self._convert(self.arg_spec.var_positional, value) + if self.spec.var_positional: + converted.extend(self._convert(self.spec.var_positional, value) for value in positional[len(names):]) return converted def _convert_named(self, named): - names = set(self.arg_spec.positional) | set(self.arg_spec.named_only) - var_named = self.arg_spec.var_named + names = set(self.spec.positional) | set(self.spec.named_only) + var_named = self.spec.var_named return [(name, self._convert(name if name in names else var_named, value)) for name, value in named] def _convert(self, name, value): - spec = self.arg_spec + spec = self.spec if (spec.types is None or self.dry_run and contains_variable(value, identifiers='$@&%')): return value diff --git a/src/robot/running/arguments/argumentresolver.py b/src/robot/running/arguments/argumentresolver.py index 17ea3086bb4..15bb62ff060 100644 --- a/src/robot/running/arguments/argumentresolver.py +++ b/src/robot/running/arguments/argumentresolver.py @@ -27,29 +27,28 @@ class ArgumentResolver: - def __init__(self, arg_spec: 'ArgumentSpec', + def __init__(self, spec: 'ArgumentSpec', resolve_named: bool = True, resolve_variables_until: 'int|None' = None, dict_to_kwargs: bool = False): - self.named_resolver = NamedArgumentResolver(arg_spec) \ + self.named_resolver = NamedArgumentResolver(spec) \ if resolve_named else NullNamedArgumentResolver() self.variable_replacer = VariableReplacer(resolve_variables_until) - self.dict_to_kwargs = DictToKwargs(arg_spec, dict_to_kwargs) - self.argument_validator = ArgumentValidator(arg_spec) + self.dict_to_kwargs = DictToKwargs(spec, dict_to_kwargs) + self.argument_validator = ArgumentValidator(spec) def resolve(self, arguments, variables=None): positional, named = self.named_resolver.resolve(arguments, variables) positional, named = self.variable_replacer.replace(positional, named, variables) positional, named = self.dict_to_kwargs.handle(positional, named) - self.argument_validator.validate(positional, named, - dryrun=variables is None) + self.argument_validator.validate(positional, named, dryrun=variables is None) return positional, named class NamedArgumentResolver: - def __init__(self, arg_spec: 'ArgumentSpec'): - self.arg_spec = arg_spec + def __init__(self, spec: 'ArgumentSpec'): + self.spec = spec def resolve(self, arguments, variables=None): positional = [] @@ -74,14 +73,12 @@ def _is_named(self, arg, previous_named, variables=None): name = variables.replace_scalar(name) except DataError: return False - spec = self.arg_spec return bool(previous_named or - spec.var_named or - name in spec.positional_or_named or - name in spec.named_only) + self.spec.var_named or + name in self.spec.named) def _raise_positional_after_named(self): - raise DataError(f"{self.arg_spec.type.capitalize()} '{self.arg_spec.name}' " + raise DataError(f"{self.spec.type.capitalize()} '{self.spec.name}' " f"got positional argument after named arguments.") @@ -93,9 +90,9 @@ def resolve(self, arguments, variables=None): class DictToKwargs: - def __init__(self, arg_spec: 'ArgumentSpec', enabled: bool = False): - self.maxargs = arg_spec.maxargs - self.enabled = enabled and bool(arg_spec.var_named) + def __init__(self, spec: 'ArgumentSpec', enabled: bool = False): + self.maxargs = spec.maxargs + self.enabled = enabled and bool(spec.var_named) def handle(self, positional, named): if self.enabled and self._extra_arg_has_kwargs(positional, named): diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index 971b68b601a..931e225e4ba 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -33,6 +33,7 @@ def __init__(self, name=None, type='Keyword', positional_only=None, var_named=None, defaults=None, types=None): self.name = name self.type = type + # FIXME: Use tuples, not lists. Consider using __slots__. self.positional_only = positional_only or [] self.positional_or_named = positional_or_named or [] self.var_positional = var_positional @@ -49,6 +50,10 @@ def types(self, types) -> 'dict[str, TypeInfo]': def positional(self): return self.positional_only + self.positional_or_named + @property + def named(self): + return self.named_only + self.positional_or_named + @property def minargs(self): return len([arg for arg in self.positional if arg not in self.defaults]) diff --git a/src/robot/running/testlibraries.py b/src/robot/running/testlibraries.py index 2115483b4b2..ee3b98d4a75 100644 --- a/src/robot/running/testlibraries.py +++ b/src/robot/running/testlibraries.py @@ -13,9 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from functools import partial import inspect import os +from functools import partial from robot.errors import DataError from robot.libraries import STDLIBS @@ -134,10 +134,9 @@ def end_test(self): def report_error(self, message, details=None, level='ERROR', details_level='INFO'): prefix = 'Error in' if level in ('ERROR', 'WARN') else 'In' - self.logger.write("%s library '%s': %s" % (prefix, self.name, message), - level) + self.logger.write(f"{prefix} library '{self.name}': {message}", level) if details: - self.logger.write('Details:\n%s' % details, details_level) + self.logger.write(f'Details:\n{details}', details_level) def _get_version(self, libcode): return self._get_attr(libcode, 'ROBOT_LIBRARY_VERSION') \ @@ -209,8 +208,8 @@ def register_listeners(self): except DataError as err: self.has_listener = False # Error should have information about suite where the - # problem occurred but we don't have such info here. - self.report_error("Registering listeners failed: %s" % err) + # problem occurred, but we don't have such info here. + self.report_error(f"Registering listeners failed: {err}") def unregister_listeners(self, close=False): if self.has_listener: @@ -231,16 +230,16 @@ def _close_listener(self, listener): except Exception: message, details = get_error_details() name = getattr(listener, '__name__', None) or type_name(listener) - self.report_error("Calling method '%s' of listener '%s' failed: %s" - % (method.__name__, name, message), details) + self.report_error(f"Calling method '{method.__name__}' of listener " + f"'{name}' failed: {message}", details) def _create_handlers(self, libcode): try: names = self._get_handler_names(libcode) except Exception: message, details = get_error_details() - raise DataError("Getting keyword names from library '%s' failed: %s" - % (self.name, message), details) + raise DataError(f"Getting keyword names from library '{self.name}' " + f"failed: {message}", details) for name in names: method = self._try_to_get_handler_method(libcode, name) if method: @@ -251,7 +250,7 @@ def _create_handlers(self, libcode): except DataError as err: self._adding_keyword_failed(handler.name, err) else: - self.logger.debug("Created keyword '%s'" % handler.name) + self.logger.debug(f"Created keyword '{handler.name}'.") def _get_handler_names(self, libcode): def has_robot_name(name): @@ -277,7 +276,7 @@ def _try_to_get_handler_method(self, libcode, name): def _adding_keyword_failed(self, name, error, level='ERROR'): self.report_error( - "Adding keyword '%s' failed: %s" % (name, error.message), + f"Adding keyword '{name}' failed: {error}", error.details, level=level, details_level='DEBUG' @@ -327,14 +326,14 @@ def _validate_embedded_count(self, embedded, arguments): 'accepted arguments.') def _raise_creating_instance_failed(self): - msg, details = get_error_details() + message, details = get_error_details() if self.positional_args or self.named_args: - args = self.positional_args + ['%s=%s' % item for item in self.named_args] - args_text = 'arguments %s' % seq2str2(args) + args = self.positional_args + [f'{n}={v}' for n, v in self.named_args] + args_text = f'arguments {seq2str2(args)}' else: args_text = 'no arguments' - raise DataError("Initializing library '%s' with %s failed: %s\n%s" - % (self.name, args_text, msg, details)) + raise DataError(f"Initializing library '{self.name}' with {args_text} failed: " + f"{message}\n{details}") class _ClassLibrary(_BaseTestLibrary): From e4856ff378c56af49c845a1929dcaca8065ff28b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 1 Nov 2023 00:03:15 +0200 Subject: [PATCH 0807/1592] Support mixed arguments with library keywords. Fixes #4710. --- .../embedded_arguments_library_keywords.robot | 25 +++++--- atest/robot/libdoc/console_viewer.robot | 2 + atest/robot/libdoc/html_output.robot | 14 ++++- .../libdoc/invalid_library_keywords.robot | 2 +- atest/robot/libdoc/json_output.robot | 14 ++++- atest/robot/libdoc/module_library.robot | 8 +++ .../embedded_arguments_library_keywords.robot | 47 ++++++++++----- .../resources/embedded_args_in_lk_1.py | 16 ++++- atest/testdata/libdoc/module.py | 14 ++++- .../CreatingTestData/CreatingUserKeywords.rst | 26 ++++---- .../CreatingTestLibraries.rst | 60 ++++++++++++------- src/robot/libdocpkg/robotbuilder.py | 14 ++++- .../running/arguments/argumentresolver.py | 19 +++--- src/robot/running/arguments/argumentspec.py | 3 +- .../running/arguments/argumentvalidator.py | 15 +++-- src/robot/running/handlers.py | 15 ++--- src/robot/running/librarykeywordrunner.py | 7 +-- src/robot/running/testlibraries.py | 10 ++-- 18 files changed, 208 insertions(+), 103 deletions(-) diff --git a/atest/robot/keywords/embedded_arguments_library_keywords.robot b/atest/robot/keywords/embedded_arguments_library_keywords.robot index 66cf0a5ab51..b0646a0c6e1 100755 --- a/atest/robot/keywords/embedded_arguments_library_keywords.robot +++ b/atest/robot/keywords/embedded_arguments_library_keywords.robot @@ -56,7 +56,7 @@ Embedded Arguments as Variables Embedded Arguments as List And Dict Variables ${tc} = Check Test Case ${TEST NAME} - Check Keyword Data ${tc.kws[1]} embedded_args_in_lk_1.User \@{i1} Selects \&{i2} From Webshop \${o1}, \${o2} + Check Keyword Data ${tc.kws[1]} embedded_args_in_lk_1.User \@{inp1} Selects \&{inp2} From Webshop \${out1}, \${out2} Non-Existing Variable in Embedded Arguments ${tc} = Check Test Case ${TEST NAME} @@ -107,17 +107,29 @@ Keyword matching multiple keywords in library file Keyword matching multiple keywords in different library files Check Test Case ${TEST NAME} -Embedded And Positional Arguments Do Not Work Together +Keyword with only embedded arguments doesn't accept normal arguments Check Test Case ${TEST NAME} Keyword with embedded args cannot be used as "normal" keyword Check Test Case ${TEST NAME} -Embedded argument count must match accepted arguments +Keyword with both embedded and normal arguments + ${tc} = Check Test Case ${TEST NAME} + Check Log message ${tc.body[0].msgs[0]} 2 horses are walking + Check Log message ${tc.body[1].msgs[0]} 2 horses are swimming + Check Log message ${tc.body[2].msgs[0]} 3 dogs are walking + +Conversion with embedded and normal arguments + Check Test Case ${TEST NAME} + +Keyword with both embedded and normal arguments with too few arguments + Check Test Case ${TEST NAME} + +Must accept at least as many positional arguments as there are embedded arguments Check Test Case ${TESTNAME} Error in library embedded_args_in_lk_1 ... Adding keyword 'Wrong \${number} of embedded \${args}' failed: - ... Embedded argument count does not match number of accepted arguments. + ... Keyword must accept at least as many positional arguments as it has embedded arguments. Optional Non-Embedded Args Are Okay Check Test Case ${TESTNAME} @@ -125,10 +137,7 @@ Optional Non-Embedded Args Are Okay Varargs With Embedded Args Are Okay Check Test Case ${TESTNAME} -List variable is expanded when keyword accepts varargs - Check Test Case ${TESTNAME} - -Scalar variable containing list is not expanded when keyword accepts varargs +Lists are not expanded when keyword accepts varargs Check Test Case ${TESTNAME} Same name with different regexp works diff --git a/atest/robot/libdoc/console_viewer.robot b/atest/robot/libdoc/console_viewer.robot index 4cf27acb48e..20d7656864b 100644 --- a/atest/robot/libdoc/console_viewer.robot +++ b/atest/robot/libdoc/console_viewer.robot @@ -18,6 +18,8 @@ List all keywords ... Robot Espacers ... Set Name Using Robot Name Attribute ... Takes \${embedded} \${args} + ... Takes \${embedded} and normal args + ... Takes \${embedded} and positional-only args List some keywords Run Libdoc And Verify Output ${TESTDATADIR}/resource.robot list o diff --git a/atest/robot/libdoc/html_output.robot b/atest/robot/libdoc/html_output.robot index dd83cad9c23..94967285a0e 100644 --- a/atest/robot/libdoc/html_output.robot +++ b/atest/robot/libdoc/html_output.robot @@ -41,8 +41,18 @@ Keyword Arguments Embedded Arguments [Template] NONE - Should Be Equal ${MODEL}[keywords][13][name] Takes \${embedded} \${args} - Should Be Empty ${MODEL}[keywords][13][args] + Should Be Equal ${MODEL}[keywords][13][name] Takes \${embedded} \${args} + Should Be Empty ${MODEL}[keywords][13][args] + +Embedded and Normal Arguments + [Template] NONE + Should Be Equal ${MODEL}[keywords][14][name] Takes \${embedded} and normal args + Verify Argument Models ${MODEL}[keywords][14][args] mandatory optional=None + +Embedded and Positional-only Arguments + [Template] NONE + Should Be Equal ${MODEL}[keywords][15][name] Takes \${embedded} and positional-only args + Verify Argument Models ${MODEL}[keywords][15][args] mandatory / optional=None Keyword Documentation ${MODEL}[keywords][1][doc] diff --git a/atest/robot/libdoc/invalid_library_keywords.robot b/atest/robot/libdoc/invalid_library_keywords.robot index 4f212480158..f923179c717 100644 --- a/atest/robot/libdoc/invalid_library_keywords.robot +++ b/atest/robot/libdoc/invalid_library_keywords.robot @@ -20,7 +20,7 @@ Invalid embedded arguments Keyword Count Should Be 3 Stdout should contain adding keyword error ... Invalid embedded \${args} - ... Embedded argument count does not match number of accepted arguments. + ... Keyword must accept at least as many positional arguments as it has embedded arguments. *** Keywords *** Stdout should contain adding keyword error diff --git a/atest/robot/libdoc/json_output.robot b/atest/robot/libdoc/json_output.robot index 95b7d32af18..cfcaf4045a2 100644 --- a/atest/robot/libdoc/json_output.robot +++ b/atest/robot/libdoc/json_output.robot @@ -41,8 +41,18 @@ Keyword Arguments Embedded Arguments [Template] NONE - Should Be Equal ${MODEL}[keywords][13][name] Takes \${embedded} \${args} - Should Be Empty ${MODEL}[keywords][13][args] + Should Be Equal ${MODEL}[keywords][13][name] Takes \${embedded} \${args} + Should Be Empty ${MODEL}[keywords][13][args] + +Embedded and Normal Arguments + [Template] NONE + Should Be Equal ${MODEL}[keywords][14][name] Takes \${embedded} and normal args + Verify Argument Models ${MODEL}[keywords][14][args] mandatory optional=None + +Embedded and Positional-only Arguments + [Template] NONE + Should Be Equal ${MODEL}[keywords][15][name] Takes \${embedded} and positional-only args + Verify Argument Models ${MODEL}[keywords][15][args] mandatory / optional=None Keyword Documentation ${MODEL}[keywords][1][doc] diff --git a/atest/robot/libdoc/module_library.robot b/atest/robot/libdoc/module_library.robot index 3744d8e82d4..f05c7d6f054 100644 --- a/atest/robot/libdoc/module_library.robot +++ b/atest/robot/libdoc/module_library.robot @@ -53,6 +53,14 @@ Embedded Arguments Keyword Name Should Be 13 Takes \${embedded} \${args} Keyword Arguments Should Be 13 +Embedded and Normal Arguments + Keyword Name Should Be 14 Takes \${embedded} and normal args + Keyword Arguments Should Be 14 mandatory optional=None + +Embedded and Positional-only Arguments + Keyword Name Should Be 15 Takes \${embedded} and positional-only args + Keyword Arguments Should Be 15 mandatory / optional=None + Keyword Documentation Keyword Doc Should Be 1 A keyword.\n\nSee `get hello` for details. Keyword Shortdoc Should Be 1 A keyword. diff --git a/atest/testdata/keywords/embedded_arguments_library_keywords.robot b/atest/testdata/keywords/embedded_arguments_library_keywords.robot index 1f0b2566c34..b713999c92e 100755 --- a/atest/testdata/keywords/embedded_arguments_library_keywords.robot +++ b/atest/testdata/keywords/embedded_arguments_library_keywords.robot @@ -43,10 +43,12 @@ Embedded Arguments as Variables Should Be Equal ${item} ${{[]}} Embedded Arguments as List And Dict Variables - ${i1} ${i2} = Evaluate [1, 2, 3, 'neljä'], {'a': 1, 'b': 2} - ${o1} ${o2} = User @{i1} Selects &{i2} From Webshop - Should Be Equal ${o1} ${i1} - Should Be Equal ${o2} ${i2} + ${inp1} ${inp2} = Evaluate (1, 2, 3, 'neljä'), {'a': 1, 'b': 2} + ${out1} ${out2} = User @{inp1} Selects &{inp2} From Webshop + Should Be Equal ${out1} ${{list($inp1)}} + Should Be Equal ${out2} ${inp2} + Should Be Equal ${out2.a} ${1} + Should Be Equal ${out2.b} ${2} Non-Existing Variable in Embedded Arguments [Documentation] FAIL Variable '${non existing}' not found. @@ -132,8 +134,8 @@ Keyword Matching Multiple Keywords In Different Library Files ... ${INDENT}embedded_args_in_lk_2.\${a}*lib*\${b} foo*lib*bar -Embedded And Positional Arguments Do Not Work Together - [Documentation] FAIL Positional arguments are not allowed when using embedded arguments. +Keyword with only embedded arguments doesn't accept normal arguments + [Documentation] FAIL Keyword 'embedded_args_in_lk_1.User \${user} Selects \${item} From Webshop' expected 0 arguments, got 1. Given this "usage" with @{EMPTY} works @{EMPTY} Then User Invalid Selects Invalid From Webshop invalid @@ -141,24 +143,41 @@ Keyword with embedded args cannot be used as "normal" keyword [Documentation] FAIL Variable '\${user}' not found. User ${user} Selects ${item} From Webshop -Embedded argument count must match accepted arguments - [Documentation] FAIL No keyword with name 'Wrong number of embedded args' found. +Keyword with both embedded and normal arguments + Number of horses should be 2 + Number of horses should be 2 swimming + Number of dogs should be count=3 + +Conversion with embedded and normal arguments + [Documentation] FAIL ValueError: Argument 'num1' got value 'bad' that cannot be converted to integer. + Conversion with embedded 42 and normal 42 + Conversion with embedded bad and normal bad + +Keyword with both embedded and normal arguments with too few arguments + [Documentation] FAIL Keyword 'embedded_args_in_lk_1.Number of \${animals} should be' expected 1 to 2 arguments, got 0. + Number of horses should be + +Must accept at least as many positional arguments as there are embedded arguments + [Documentation] FAIL No keyword with name 'Wrong number of embedded args' found. Wrong number of embedded args Optional Non-Embedded Args Are Okay - Optional Non-Embedded Args Are Okay + @{ret} = Optional Non-Embedded Args Are Okay + Should Be Equal ${ret} ${{['Embedded', 'Okay', 3]}} + @{ret} = Optional Non-Embedded Args Are Usable Since RF 7! + Should Be Equal ${ret} ${{['Embedded', 'Usable', 'Since RF 7!']}} Varargs With Embedded Args Are Okay @{ret} = Varargs With Embedded Args are Okay Should Be Equal ${ret} ${{['Embedded', 'Okay']}} + @{ret} = Varargs With R Args are F ${SPACE} 7 . 0 ! ! ! + Should Be Equal ${{''.join($ret)}} RF 7.0!!! -List variable is expanded when keyword accepts varargs - @{ret} = Varargs With @{list} Args are Okay - Should Be Equal ${ret} ${{['first', 2, 'third', 'Okay']}} - -Scalar variable containing list is not expanded when keyword accepts varargs +Lists are not expanded when keyword accepts varargs @{ret} = Varargs With ${list} Args are Okay Should Be Equal ${ret} ${{[['first', 2, 'third'], 'Okay']}} + @{ret} = Varargs With @{list} Args are Okay + Should Be Equal ${ret} ${{[['first', 2, 'third'], 'Okay']}} Same name with different regexp works It is a car diff --git a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py index e4b6bc586bf..30e6353f583 100755 --- a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py +++ b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py @@ -125,9 +125,9 @@ def too_few_args_here(arg): pass -@keyword(name="Optional ${nonembedded} Args Are ${okay}") -def optional_args_are_okay(nonembedded=1, okay=2, indeed=3): - pass +@keyword(name="Optional non-${embedded} Args Are ${okay}") +def optional_args_are_okay(embedded=1, okay=2, extra=3): + return embedded, okay, extra @keyword(name="Varargs With ${embedded} Args Are ${okay}") @@ -158,3 +158,13 @@ def totally_same_1(arg): @keyword('It is totally ${same}') def totally_same_2(arg): raise Exception('Not executed') + + +@keyword('Number of ${animals} should be') +def number_of_animals_should_be(animals, count, activity='walking'): + log(f'{count} {animals} are {activity}') + + +@keyword('Conversion with embedded ${number} and normal') +def conversion_with_embedded_and_normal(num1: int, /, num2: int): + assert num1 == num2 == 42 diff --git a/atest/testdata/libdoc/module.py b/atest/testdata/libdoc/module.py index 8f1612d5a0f..dec5c97fc20 100644 --- a/atest/testdata/libdoc/module.py +++ b/atest/testdata/libdoc/module.py @@ -68,11 +68,23 @@ def name_set_in_method_signature(a, b, *args, **kwargs): @deco.keyword('Takes ${embedded} ${args}') -def takes_embedded_args(a=1, b=2, c=3): +def takes_embedded_args(a=1, b=2): """A keyword which uses embedded args.""" pass +@deco.keyword('Takes ${embedded} and normal args') +def takes_embedded_and_normal(embedded, mandatory, optional=None): + """A keyword which uses embedded and normal args.""" + pass + + +@deco.keyword('Takes ${embedded} and positional-only args') +def takes_embedded_and_pos_only(embedded, mandatory, /, optional=None): + """A keyword which uses embedded, positional-only and normal args.""" + pass + + @deco.keyword(tags=['1', 1, 'one', 'yksi']) def keyword_with_tags_1(): pass diff --git a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst index 24df8c78868..38286d44331 100644 --- a/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst +++ b/doc/userguide/src/CreatingTestData/CreatingUserKeywords.rst @@ -540,29 +540,29 @@ the keyword is called. In the above example, `${animal}` has value `cat` when the keyword is used for the first time and `dog` when it is used for the second time. -Starting from Robot Framework 6.1, it is possible to create user keywords that have -both embedded and "normal" (specified with :setting:`[Arguments]` setting) arguments. -Earlier, having "normal" arguments was not possible. Otherwise, keywords with embedded -arguments are created just like other user keywords. They are also used the same -way as other keywords except that spaces and underscores are not ignored in their -names when keywords are matched. They are, however, case-insensitive like -other keywords. For example, the keyword in the example above could be used like -:name:`select cow from list`, but not like :name:`Select cow fromlist`. -Example below demonstrates using embedded and regular arguments in a single keyword: +Starting from Robot Framework 6.1, it is possible to create user keywords +that accept both embedded and "normal" arguments: .. sourcecode:: robotframework *** Test Cases *** Embedded and normal arguments - Number of cats should be 5 - Number of elephants should be 1 + Number of cats should be 2 + Number of dogs should be count=3 *** Keywords *** Number of ${animals} should be - [Arguments] ${expected_count} + [Arguments] ${count} Open Page Pet Selection Select Items From List animal_list ${animals} - Number of Selected List Items Should Be ${expected_count} + Number of Selected List Items Should Be ${count} + +Other than the special name, keywords with embedded +arguments are created just like other user keywords. They are also used the same +way as other keywords except that spaces and underscores are not ignored in their +names when keywords are matched. They are, however, case-insensitive like +other keywords. For example, the :name:`Select ${animal} from list` keyword could +be used like :name:`select cow from list`, but not like :name:`Select cow fromlist`. Embedded arguments do not support default values or variable number of arguments like normal arguments do. If such functionality is needed, normal diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index ee1b6e3302a..17ba0f6e4ed 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1920,7 +1920,6 @@ signature-preserving decorators. .. note:: Support for "unwrapping" decorators decorated with `functools.wraps` is a new feature in Robot Framework 3.2. - __ https://realpython.com/primer-on-python-decorators/ __ https://docs.python.org/library/functools.html#functools.wraps __ https://pypi.org/project/decorator/ @@ -1929,45 +1928,62 @@ __ https://wrapt.readthedocs.io Embedding arguments into keyword names ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Library keywords can also accept arguments which are passed using -the `embedded argument syntax`__. The `@keyword decorator`_ -can be used to create a `custom keyword name`__ for the keyword -which includes the desired syntax. +Library keywords can also accept *embedded arguments* the same way as +`user keywords`_. This section mainly covers the Python syntax to use to +create such keywords, the embedded arguments syntax itself is covered in +detail as part of `user keyword documentation`__. + +Library keywords with embedded arguments need to have a `custom name`__ that +is typically set using the `@keyword decorator`_. Values matching embedded +arguments are passed to the function or method implementing the keyword as +positional arguments. If the function or method accepts more arguments, they +can be passed to the keyword as normal positional or named arguments. +Argument names do not need to match the embedded argument names, but that +is generally a good convention. __ `Embedding arguments into keyword name`_ __ `Setting custom name`_ +Keywords accepting embedded arguments: + .. sourcecode:: python from robot.api.deco import keyword - @keyword('Add ${quantity:\d+} copies of ${item} to cart') - def add_copies_to_cart(quantity, item): - # ... + @keyword('Select ${animal} from list') + def select_animal_from_list(animal): + ... + + + @keyword('Number of ${animals} should be') + def number_of_animals_should_be(animals, count): + ... + +Tests using the above keywords: .. sourcecode:: robotframework - *** Test Cases *** - My Test - Add 7 copies of coffee to cart + *** Test Cases *** + Embedded arguments + Select cat from list + Select dog from list -By default arguments are passed to implementing keywords as strings, but -automatic `argument conversion`_ works if type information is specified -somehow. It is convenient to use `function annotations`__, -and alternatively it is possible to pass types to the `@keyword decorator`__. -This example uses annotations: + Embedded and normal arguments + Number of cats should be 2 + Number of dogs should be count=3 + +If type information is specified, automatic `argument conversion`_ works also +with embedded arguments: .. sourcecode:: python - @keyword('Add ${quantity:\d+} copies of ${item} to cart') + @keyword('Add ${quantity} copies of ${item} to cart') def add_copies_to_cart(quantity: int, item: str): - # ... - -__ `Specifying argument types using function annotations`_ -__ `Specifying argument types using @keyword decorator`_ + ... -.. note:: Automatic type conversion is new in Robot Framework 3.1. +.. note:: Support for mixing embedded arguments and normal arguments is new + in Robot Framework 7.0. Asynchronous keywords ~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/robot/libdocpkg/robotbuilder.py b/src/robot/libdocpkg/robotbuilder.py index ce2319044d9..e6c2df00293 100644 --- a/src/robot/libdocpkg/robotbuilder.py +++ b/src/robot/libdocpkg/robotbuilder.py @@ -18,8 +18,8 @@ import re from robot.errors import DataError -from robot.running import (ResourceFileBuilder, TestLibrary, TestSuiteBuilder, - TypeInfo, UserLibrary, UserErrorHandler) +from robot.running import (ArgumentSpec, ResourceFileBuilder, TestLibrary, + TestSuiteBuilder, TypeInfo, UserLibrary, UserErrorHandler) from robot.utils import is_string, split_tags_from_doc, unescape from robot.variables import search_variable @@ -149,6 +149,8 @@ def build_keyword(self, kw): doc, tags = self._get_doc_and_tags(kw) if not self._resource: self._escape_strings_in_defaults(kw.arguments.defaults) + if kw.arguments.embedded: + self._remove_embedded(kw.arguments) return KeywordDoc(name=kw.name, args=kw.arguments, doc=doc, @@ -185,3 +187,11 @@ def _get_doc(self, kw): if self._resource and not isinstance(kw, UserErrorHandler): return unescape(kw.doc) return kw.doc + + def _remove_embedded(self, spec: ArgumentSpec): + embedded = len(spec.embedded) + pos_only = len(spec.positional_only) + spec.positional_only[:embedded] = [] + if embedded > pos_only: + spec.positional_or_named[:embedded-pos_only] = [] + spec.embedded = () diff --git a/src/robot/running/arguments/argumentresolver.py b/src/robot/running/arguments/argumentresolver.py index 15bb62ff060..4078b21a017 100644 --- a/src/robot/running/arguments/argumentresolver.py +++ b/src/robot/running/arguments/argumentresolver.py @@ -33,7 +33,7 @@ def __init__(self, spec: 'ArgumentSpec', dict_to_kwargs: bool = False): self.named_resolver = NamedArgumentResolver(spec) \ if resolve_named else NullNamedArgumentResolver() - self.variable_replacer = VariableReplacer(resolve_variables_until) + self.variable_replacer = VariableReplacer(spec, resolve_variables_until) self.dict_to_kwargs = DictToKwargs(spec, dict_to_kwargs) self.argument_validator = ArgumentValidator(spec) @@ -51,17 +51,15 @@ def __init__(self, spec: 'ArgumentSpec'): self.spec = spec def resolve(self, arguments, variables=None): - positional = [] named = [] - for arg in arguments: + for arg in arguments[len(self.spec.embedded):]: if is_dict_variable(arg): named.append(arg) elif self._is_named(arg, named, variables): named.append(split_from_equals(arg)) elif named: self._raise_positional_after_named() - else: - positional.append(arg) + positional = arguments[:-len(named)] if named else arguments return positional, named def _is_named(self, arg, previous_named, variables=None): @@ -107,13 +105,20 @@ def _extra_arg_has_kwargs(self, positional, named): class VariableReplacer: - def __init__(self, resolve_until: 'int|None' = None): + def __init__(self, spec: 'ArgumentSpec', resolve_until: 'int|None' = None): + self.spec = spec self.resolve_until = resolve_until def replace(self, positional, named, variables=None): # `variables` is None in dry-run mode and when using Libdoc. if variables: - positional = variables.replace_list(positional, self.resolve_until) + if self.spec.embedded: + embedded = len(self.spec.embedded) + positional = [ + variables.replace_scalar(emb) for emb in positional[:embedded] + ] + variables.replace_list(positional[embedded:]) + else: + positional = variables.replace_list(positional, self.resolve_until) named = list(self._replace_named(named, variables.replace_scalar)) else: # If `var` isn't a tuple, it's a &{dict} variables. diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index 931e225e4ba..0c4765e035a 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -30,7 +30,7 @@ class ArgumentSpec: def __init__(self, name=None, type='Keyword', positional_only=None, positional_or_named=None, var_positional=None, named_only=None, - var_named=None, defaults=None, types=None): + var_named=None, embedded=None, defaults=None, types=None): self.name = name self.type = type # FIXME: Use tuples, not lists. Consider using __slots__. @@ -39,6 +39,7 @@ def __init__(self, name=None, type='Keyword', positional_only=None, self.var_positional = var_positional self.named_only = named_only or [] self.var_named = var_named + self.embedded = embedded or () self.defaults = defaults or {} self.types = types diff --git a/src/robot/running/arguments/argumentvalidator.py b/src/robot/running/arguments/argumentvalidator.py index 9bb431812b9..840324cd005 100644 --- a/src/robot/running/arguments/argumentvalidator.py +++ b/src/robot/running/arguments/argumentvalidator.py @@ -41,7 +41,7 @@ def validate(self, positional, named, dryrun=False): self._validate_no_extra_named(named, self.arg_spec) def _validate_no_multiple_values(self, positional, named, spec): - for name in spec.positional[:len(positional)]: + for name in spec.positional[:len(positional)-len(spec.embedded)]: if name in named and name not in spec.positional_only: self._raise_error(f"got multiple values for argument '{name}'") @@ -66,15 +66,18 @@ def _named_positionals(self, named, spec): return sum(1 for n in named if n in spec.positional_or_named) def _raise_wrong_count(self, count, spec): - if spec.minargs == spec.maxargs: - expected = f'{spec.minargs} argument{s(spec.minargs)}' + embedded = len(spec.embedded) + minargs = spec.minargs - embedded + maxargs = spec.maxargs - embedded + if minargs == maxargs: + expected = f'{minargs} argument{s(minargs)}' elif not spec.var_positional: - expected = f'{spec.minargs} to {spec.maxargs} arguments' + expected = f'{minargs} to {maxargs} arguments' else: - expected = f'at least {spec.minargs} argument{s(spec.minargs)}' + expected = f'at least {minargs} argument{s(minargs)}' if spec.var_named or spec.named_only: expected = expected.replace('argument', 'non-named argument') - self._raise_error(f"expected {expected}, got {count}") + self._raise_error(f"expected {expected}, got {count - embedded}") def _validate_no_mandatory_missing(self, positional, named, spec): for name in spec.positional[len(positional):]: diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py index 11210467c45..334ba77084c 100644 --- a/src/robot/running/handlers.py +++ b/src/robot/running/handlers.py @@ -285,7 +285,6 @@ class EmbeddedArgumentsHandler: supports_embedded_args = True def __init__(self, embedded, orig_handler): - self.arguments = ArgumentSpec() # Show empty argument spec for Libdoc self.embedded = embedded self._orig_handler = orig_handler @@ -307,15 +306,11 @@ def create_runner(self, name, languages=None): return EmbeddedArgumentsRunner(self, name) def resolve_arguments(self, args, variables=None, languages=None): - argspec = self._orig_handler.arguments - if variables: - if argspec.var_positional: - args = variables.replace_list(args) - else: - args = [variables.replace_scalar(a) for a in args] - self.embedded.validate(args) - return argspec.convert(args, named={}, converters=self.library.converters, - dry_run=not variables) + positional, named = self.arguments.resolve(args, variables, + self.library.converters, + languages=languages) + self.embedded.validate(positional) + return positional, named def __copy__(self): return EmbeddedArgumentsHandler(self.embedded, copy(self._orig_handler)) diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index 7f2452dac12..4828d507d4d 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -135,13 +135,10 @@ def __init__(self, handler, name): self.embedded_args = handler.embedded.match(name).groups() def _run(self, context, args): - if args: - raise DataError("Positional arguments are not allowed when using " - "embedded arguments.") - return super()._run(context, self.embedded_args) + return super()._run(context, self.embedded_args + args) def _dry_run(self, context, args): - return super()._dry_run(context, self.embedded_args) + return super()._dry_run(context, self.embedded_args + args) def _get_result(self, kw, assignment): result = super()._get_result(kw, assignment) diff --git a/src/robot/running/testlibraries.py b/src/robot/running/testlibraries.py index ee3b98d4a75..45588e961ed 100644 --- a/src/robot/running/testlibraries.py +++ b/src/robot/running/testlibraries.py @@ -316,15 +316,13 @@ def _create_handler(self, handler_name, handler_method): def _get_possible_embedded_args_handler(self, handler): embedded = EmbeddedArguments.from_name(handler.name) if embedded: - self._validate_embedded_count(embedded, handler.arguments) + if len(embedded.args) > handler.arguments.maxargs: + raise DataError(f'Keyword must accept at least as many positional ' + f'arguments as it has embedded arguments.') + handler.arguments.embedded = embedded.args return EmbeddedArgumentsHandler(embedded, handler), True return handler, False - def _validate_embedded_count(self, embedded, arguments): - if not (arguments.minargs <= len(embedded.args) <= arguments.maxargs): - raise DataError('Embedded argument count does not match number of ' - 'accepted arguments.') - def _raise_creating_instance_failed(self): message, details = get_error_details() if self.positional_args or self.named_args: From 6f1ba54e2bace87fafc56ab69d2e6553267143bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 1 Nov 2023 00:59:11 +0200 Subject: [PATCH 0808/1592] Python 3.12 compatibility in tests. Evaluating `[$x + x for x in 'abc']` works with Python 3.12 due to comprehension inlining while with earlier versions `$x` cannot be seen. Need to adapt the test for reporting this situation specially (#4898). Evaluation other expressions (e.g. `(lambda: $x)()`) can still fail. Better to change our custom error message so that it doesn't anymore talk about comprehensions. Also change related errors to use `{xxx!r}` instead of `'{xxx}'`. --- .../standard_libraries/builtin/evaluate.robot | 2 +- .../running/for/for_dict_iteration.robot | 2 +- atest/testdata/running/if/invalid_if.robot | 8 +++---- .../running/while/invalid_while.robot | 6 ++--- .../standard_libraries/builtin/evaluate.robot | 24 ++++++++++++------- .../builtin/run_keyword_and_return.robot | 2 +- src/robot/variables/evaluation.py | 12 +++++----- 7 files changed, 32 insertions(+), 24 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/evaluate.robot b/atest/robot/standard_libraries/builtin/evaluate.robot index d405715b1e9..7454e955e1e 100644 --- a/atest/robot/standard_libraries/builtin/evaluate.robot +++ b/atest/robot/standard_libraries/builtin/evaluate.robot @@ -111,7 +111,7 @@ Evaluate Nonstring Evaluate doesn't see module globals Check Test Case ${TESTNAME} -Automatic variables are not seen in expression part of comprehensions +Automatic variables are seen in expression part of comprehensions only with Python 3.12+ Check Test Case ${TESTNAME} Automatic variables are not seen inside lambdas diff --git a/atest/testdata/running/for/for_dict_iteration.robot b/atest/testdata/running/for/for_dict_iteration.robot index 7e00b1d307c..67649310a44 100644 --- a/atest/testdata/running/for/for_dict_iteration.robot +++ b/atest/testdata/running/for/for_dict_iteration.robot @@ -144,7 +144,7 @@ Invalid dict 1 Invalid dict 2 [Documentation] FAIL ... STARTS: Resolving variable '\&{{{[]: 'ooops'}}}' failed: \ - ... Evaluating expression '{[]: 'ooops'}' failed: TypeError: + ... Evaluating expression "{[]: 'ooops'}" failed: TypeError: FOR ${x} IN &{{{[]: 'ooops'}}} Fail Not executed END diff --git a/atest/testdata/running/if/invalid_if.robot b/atest/testdata/running/if/invalid_if.robot index df9552aa939..ad838e4f585 100644 --- a/atest/testdata/running/if/invalid_if.robot +++ b/atest/testdata/running/if/invalid_if.robot @@ -14,7 +14,7 @@ IF without condition with ELSE END IF with invalid condition - [Documentation] FAIL STARTS: Invalid IF condition: Evaluating expression ''123'=123' failed: SyntaxError: + [Documentation] FAIL STARTS: Invalid IF condition: Evaluating expression "'123'=123" failed: SyntaxError: IF '123'=${123} Fail Should not be run END @@ -60,10 +60,10 @@ ELSE IF with invalid condition Recommend $var syntax if invalid condition contains ${var} [Documentation] FAIL Invalid IF condition: \ - ... Evaluating expression 'x == 'x'' failed: NameError: name 'x' is not defined nor importable as module + ... Evaluating expression "x == 'x'" failed: NameError: name 'x' is not defined nor importable as module ... - ... Variables in the original expression '\${x} == 'x'' were resolved before the expression was evaluated. \ - ... Try using '$x == 'x'' syntax to avoid that. See Evaluating Expressions appendix in Robot Framework User Guide for more details. + ... Variables in the original expression "\${x} == 'x'" were resolved before the expression was evaluated. \ + ... Try using "$x == 'x'" syntax to avoid that. See Evaluating Expressions appendix in Robot Framework User Guide for more details. ${x} = Set Variable x IF ${x} == 'x' Fail Shouldn't be run diff --git a/atest/testdata/running/while/invalid_while.robot b/atest/testdata/running/while/invalid_while.robot index cffa3dd691b..c9a2b7bc129 100644 --- a/atest/testdata/running/while/invalid_while.robot +++ b/atest/testdata/running/while/invalid_while.robot @@ -28,10 +28,10 @@ Non-existing $variable in condition Recommend $var syntax if invalid condition contains ${var} [Documentation] FAIL Invalid WHILE loop condition: \ - ... Evaluating expression 'x == 'x'' failed: NameError: name 'x' is not defined nor importable as module + ... Evaluating expression "x == 'x'" failed: NameError: name 'x' is not defined nor importable as module ... - ... Variables in the original expression '\${x} == 'x'' were resolved before the expression was evaluated. \ - ... Try using '$x == 'x'' syntax to avoid that. See Evaluating Expressions appendix in Robot Framework User Guide for more details. + ... Variables in the original expression "\${x} == 'x'" were resolved before the expression was evaluated. \ + ... Try using "$x == 'x'" syntax to avoid that. See Evaluating Expressions appendix in Robot Framework User Guide for more details. ${x} = Set Variable x WHILE ${x} == 'x' Fail Not executed! diff --git a/atest/testdata/standard_libraries/builtin/evaluate.robot b/atest/testdata/standard_libraries/builtin/evaluate.robot index 3593933da87..769bd9d0e5a 100644 --- a/atest/testdata/standard_libraries/builtin/evaluate.robot +++ b/atest/testdata/standard_libraries/builtin/evaluate.robot @@ -105,7 +105,7 @@ Explicit modules used in lambda Evaluation namespace is mutable [Documentation] FAIL - ... Evaluating expression 'locals().__setitem__('var', 1) or locals().__delitem__('var') or var' failed: \ + ... Evaluating expression "locals().__setitem__('var', 1) or locals().__delitem__('var') or var" failed: \ ... NameError: name 'var' is not defined nor importable as module ${variable} = Evaluate locals().__setitem__('variable', 'value') or variable Should Be Equal ${variable} value @@ -211,7 +211,7 @@ Invalid expression 2 Evaluate Someone forgot to add quotes! Invalid expression 3 - [Documentation] FAIL STARTS: Evaluating expression 'We have\nmultiple\nlines' failed: SyntaxError: + [Documentation] FAIL STARTS: Evaluating expression 'We have\\nmultiple\\nlines' failed: SyntaxError: Evaluate We have\nmultiple\nlines Invalid expression 4 @@ -271,22 +271,30 @@ Evaluate Empty Evaluate ${EMPTY} Evaluate Nonstring - [Documentation] FAIL Evaluating expression '5' failed: TypeError: Expression must be string, got integer. + [Documentation] FAIL Evaluating expression 5 failed: TypeError: Expression must be string, got integer. Evaluate ${5} Evaluate doesn't see module globals [Documentation] FAIL STARTS: Evaluating expression 'DataError' failed: NameError: Evaluate DataError -Automatic variables are not seen in expression part of comprehensions - [Documentation] FAIL Evaluating expression '[$x + x for x in 'abc']' failed: \ - ... Robot Framework variable '$x' used in the expression part of a comprehension or some other scope where it cannot be seen. +Automatic variables are seen in expression part of comprehensions only with Python 3.12+ VAR ${x} - Evaluate [$x + x for x in 'abc'] + VAR ${error} + ... Evaluating expression "[$x + x for x in 'abc']" failed: + ... Robot Framework variable '$x' is used in a scope where it cannot be seen. + TRY + ${result} = Evaluate [$x + x for x in 'abc'] + EXCEPT ${error} + Should Be True sys.version_info < (3, 12) + ELSE + Should Be True sys.version_info >= (3, 12) + Should Be equal ${result} ${{['a', 'b', 'c']}} + END Automatic variables are not seen inside lambdas [Documentation] FAIL Evaluating expression '(lambda: $x)()' failed: \ - ... Robot Framework variable '$x' used in the expression part of a comprehension or some other scope where it cannot be seen. + ... Robot Framework variable '$x' is used in a scope where it cannot be seen. VAR ${x} Evaluate (lambda: $x)() diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_and_return.robot b/atest/testdata/standard_libraries/builtin/run_keyword_and_return.robot index fea7a0ff7ba..ac0e4c14bc7 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_and_return.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_and_return.robot @@ -74,7 +74,7 @@ Run Keyword And Return If can have non-existing keywords and variables if condit Run Keyword And Return If With Non-Existing Keyword And Variables Run Keyword And Return If with list variable containing escaped items - [Documentation] FAIL STARTS: Evaluating expression 'c:\\temp' failed: + [Documentation] FAIL STARTS: Evaluating expression 'c:\\\\temp' failed: ${ret} = Run Keyword And Return If With Variables True Create List @{ESCAPING} Should Be Equal ${ret} ${ESCAPING} Run Keyword And Return If With Variables @{ESCAPING} diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py index ac14221a2ee..d436bde4cf8 100644 --- a/src/robot/variables/evaluation.py +++ b/src/robot/variables/evaluation.py @@ -51,12 +51,12 @@ def evaluate_expression(expression, variables, modules=None, namespace=None, variable_recommendation = '' if isinstance(err, NameError) and 'RF_VAR_' in error: name = re.search(r'RF_VAR_([\w_]*)', error).group(1) - error = (f"Robot Framework variable '${name}' used in the expression part " - f"of a comprehension or some other scope where it cannot be seen.") + error = (f"Robot Framework variable '${name}' is used in a scope " + f"where it cannot be seen.") else: variable_recommendation = _recommend_special_variables(original) - raise DataError(f"Evaluating expression '{expression}' failed: {error}\n\n" - f"{variable_recommendation}".strip()) + raise DataError(f'Evaluating expression {expression!r} failed: {error}\n\n' + f'{variable_recommendation}'.strip()) def _evaluate(expression, variable_store, modules=None, namespace=None): @@ -120,8 +120,8 @@ def _recommend_special_variables(expression): for match in matches: example[-1:] += [match.before, match.identifier, match.base, match.after] example = ''.join(example) - return (f"Variables in the original expression '{expression}' were resolved " - f"before the expression was evaluated. Try using '{example}' " + return (f"Variables in the original expression {expression!r} were resolved " + f"before the expression was evaluated. Try using {example!r} " f"syntax to avoid that. See Evaluating Expressions appendix in " f"Robot Framework User Guide for more details.") From 523d9dba31378dc9199dc2aa9b8bcdaddc513955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 1 Nov 2023 09:59:47 +0200 Subject: [PATCH 0809/1592] Avoid accessing `cached_property` during library imports. Also enhance handling `classmethod` so that we look is the thing it wraps, not the `classmethod` itself, a valid keyword method. That avoids accessing `classmethod/property`. Also use `inspect.getattr_static` instead of trying to find keyword methods by going through all base classes and using `getattr`. That simplifies code considerably. Fixes #4915. --- ...d_properties_when_creating_libraries.robot | 25 ++++++++------- .../test_libraries/AvoidProperties.py | 9 ++++++ ...d_properties_when_creating_libraries.robot | 18 ++++++++--- src/robot/running/testlibraries.py | 32 ++++++++----------- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/atest/robot/test_libraries/avoid_properties_when_creating_libraries.robot b/atest/robot/test_libraries/avoid_properties_when_creating_libraries.robot index 7098575bee2..b057c06d4f4 100644 --- a/atest/robot/test_libraries/avoid_properties_when_creating_libraries.robot +++ b/atest/robot/test_libraries/avoid_properties_when_creating_libraries.robot @@ -10,7 +10,11 @@ Property Classmethod property Check Test Case ${TESTNAME} - Adding keyword failed classmethod_property classmethod=True + Adding keyword failed classmethod_property + +Cached property + Check Test Case ${TESTNAME} + Adding keyword failed cached_property Non-data descriptor Check Test Case ${TESTNAME} @@ -18,7 +22,7 @@ Non-data descriptor Classmethod non-data descriptor Check Test Case ${TESTNAME} - Adding keyword failed classmethod_non_data_descriptor classmethod=True + Adding keyword failed classmethod_non_data_descriptor error_with_38=True Data descriptor Check Test Case ${TESTNAME} @@ -26,28 +30,25 @@ Data descriptor Classmethod data descriptor Check Test Case ${TESTNAME} - Adding keyword failed classmethod_data_descriptor classmethod=True + Adding keyword failed classmethod_data_descriptor Failing non-data descriptor Adding keyword failed failing_non_data_descriptor Getting handler method failed: ZeroDivisionError: Failing classmethod non-data descriptor - Adding keyword failed failing_classmethod_non_data_descriptor Getting handler method failed: ZeroDivisionError: classmethod=True + Adding keyword failed failing_classmethod_non_data_descriptor Getting handler method failed: ZeroDivisionError: error_with_38=True Failing data descriptor Adding keyword failed failing_data_descriptor Failing classmethod data descriptor - Adding keyword failed failing_classmethod_data_descriptor Getting handler method failed: ZeroDivisionError: classmethod=True + Adding keyword failed failing_classmethod_data_descriptor *** Keywords *** Adding keyword failed - [Arguments] ${name} ${error}=Not a method or function. ${classmethod}=False - # With Python < 3.9, descriptors wrapped with @classmethod are considered callable by - # inspect.isroutine, but inspect.signature doesn't like them. This results in error - # being reported on different places depending on Python version. - IF ${INTERPRETER.version_info} >= (3, 9) or not ${classmethod} - Syslog Should Contain | INFO \ | In library 'AvoidProperties': Adding keyword '${name}' failed: ${error} - ELSE + [Arguments] ${name} ${error}=Not a method or function. ${error_with_38}=False + IF ${INTERPRETER.version_info} < (3, 9) and ${error_with_38} Syslog Should Contain | ERROR | Error in library 'AvoidProperties': Adding keyword '${name}' failed: + ELSE + Syslog Should Contain | INFO \ | In library 'AvoidProperties': Adding keyword '${name}' failed: ${error} END diff --git a/atest/testdata/test_libraries/AvoidProperties.py b/atest/testdata/test_libraries/AvoidProperties.py index 4f82cf23792..2cde4ec4b36 100644 --- a/atest/testdata/test_libraries/AvoidProperties.py +++ b/atest/testdata/test_libraries/AvoidProperties.py @@ -1,3 +1,6 @@ +from functools import cached_property + + class NonDataDescriptor: def __init__(self, func): @@ -28,6 +31,7 @@ def __get__(self, instance, owner): class AvoidProperties: normal_property_called = 0 classmethod_property_called = 0 + cached_property_called = 0 non_data_descriptor_called = 0 classmethod_non_data_descriptor_called = 0 data_descriptor_called = 0 @@ -47,6 +51,11 @@ def classmethod_property(cls): cls.classmethod_property_called += 1 return cls.classmethod_property_called + @cached_property + def cached_property(self): + type(self).cached_property_called += 1 + return self.cached_property_called + @NonDataDescriptor def non_data_descriptor(self): type(self).non_data_descriptor_called += 1 diff --git a/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot b/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot index c1cf2bfb5d4..fe8f94ef578 100644 --- a/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot +++ b/atest/testdata/test_libraries/avoid_properties_when_creating_libraries.robot @@ -5,10 +5,13 @@ Test Template Attribute value should be *** Test Cases *** Property - normal_property 1 + normal_property Classmethod property - classmethod_property 2 classmethod=True + classmethod_property classmethod=True + +Cached property + cached_property Non-data descriptor non_data_descriptor 2 @@ -17,15 +20,20 @@ Classmethod non-data descriptor classmethod_non_data_descriptor 2 classmethod=True Data descriptor - data_descriptor 1 + data_descriptor Classmethod data descriptor - classmethod_data_descriptor 2 classmethod=True + classmethod_data_descriptor classmethod=True *** Keywords *** Attribute value should be - [Arguments] ${attr} ${expected} ${classmethod}=False + [Arguments] ${attr} ${expected}=1 ${classmethod}=False ${lib} = Get Library Instance AvoidProperties IF sys.version_info >= (3, 9) or not ${classmethod} Should Be Equal As Integers ${lib.${attr}} ${expected} END + TRY + Run Keyword ${attr} + EXCEPT No keyword with name '${attr}' found. + No Operation + END diff --git a/src/robot/running/testlibraries.py b/src/robot/running/testlibraries.py index 45588e961ed..37e6a9abc9b 100644 --- a/src/robot/running/testlibraries.py +++ b/src/robot/running/testlibraries.py @@ -15,7 +15,7 @@ import inspect import os -from functools import partial +from functools import cached_property, partial from robot.errors import DataError from robot.libraries import STDLIBS @@ -254,11 +254,8 @@ def _create_handlers(self, libcode): def _get_handler_names(self, libcode): def has_robot_name(name): - try: - handler = self._get_handler_method(libcode, name) - except DataError: - return False - return hasattr(handler, 'robot_name') + candidate = inspect.getattr_static(libcode, name) + return hasattr(candidate, 'robot_name') auto_keywords = getattr(libcode, 'ROBOT_AUTO_KEYWORDS', True) if auto_keywords: @@ -337,18 +334,17 @@ def _raise_creating_instance_failed(self): class _ClassLibrary(_BaseTestLibrary): def _get_handler_method(self, libinst, name): - for item in (libinst,) + inspect.getmro(libinst.__class__): - # `isroutine` is used before `getattr` to avoid calling properties. - if (name in getattr(item, '__dict__', ()) - and inspect.isroutine(item.__dict__[name])): - try: - method = getattr(libinst, name) - except Exception: - message, traceback = get_error_details() - raise DataError(f'Getting handler method failed: {message}', - traceback) - return self._validate_handler_method(method) - raise DataError('Not a method or function.') + candidate = inspect.getattr_static(libinst, name) + if isinstance(candidate, classmethod): + candidate = candidate.__func__ + if isinstance(candidate, cached_property) or not inspect.isroutine(candidate): + raise DataError('Not a method or function.') + try: + method = getattr(libinst, name) + except Exception: + message, details = get_error_details() + raise DataError(f'Getting handler method failed: {message}', details) + return self._validate_handler_method(method) class _ModuleLibrary(_BaseTestLibrary): From c4e5d2e42fcaf6282623844c43bb8e269709a500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Sat, 14 Oct 2023 10:00:07 +0300 Subject: [PATCH 0810/1592] listeres: get rid of the temporary ListenerAdapter --- src/robot/output/listeners.py | 239 ++++++++++------------------------ src/robot/output/output.py | 6 +- 2 files changed, 74 insertions(+), 171 deletions(-) diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index 7bd719ff755..1161b94cc43 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -25,12 +25,13 @@ from .modelcombiner import ModelCombiner -class Listeners: +class Listeners(LoggerApi): _method_names = ('start_suite', 'end_suite', 'start_test', 'end_test', 'start_keyword', 'end_keyword', 'log_message', 'message', 'output_file', 'report_file', 'log_file', 'debug_file', 'xunit_file', 'library_import', 'resource_import', 'variables_import', 'close') + _methods = {} def __init__(self, listeners, log_level='INFO'): self._is_logged = IsLogged(log_level) @@ -38,50 +39,91 @@ def __init__(self, listeners, log_level='INFO'): self._method_names) for name in self._method_names: method = ListenerMethods(name, listeners) - if name.endswith(('_keyword', '_file', '_import', 'log_message')): - name = '_' + name - setattr(self, name, method) + self._methods[name] = method + + def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + self._methods['start_suite'](ModelCombiner(data, result, + tests=data.tests, + suites=data.suites, + test_count=data.test_count)) + + def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + self._methods['end_suite'](ModelCombiner(data, result)) + + def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + self._methods['start_test'](ModelCombiner(data, result)) + + def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + self._methods['end_test'](ModelCombiner(data, result)) + + def start_body_item(self, data, result): + if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): + self._methods['start_keyword'](ModelCombiner(data, result)) + + def end_body_item(self, data, result): + if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): + self._methods['end_keyword'](ModelCombiner(data, result)) def set_log_level(self, level): self._is_logged.set_level(level) - def start_keyword(self, kw): - if kw.type not in (kw.IF_ELSE_ROOT, kw.TRY_EXCEPT_ROOT): - self._start_keyword(kw) - - def end_keyword(self, kw): - if kw.type not in (kw.IF_ELSE_ROOT, kw.TRY_EXCEPT_ROOT): - self._end_keyword(kw) + def log_message(self, message: 'model.Message'): + if self._is_logged(message.level): + self._methods['log_message'](message) - def log_message(self, msg): - if self._is_logged(msg.level): - self._log_message(msg) + def message(self, message: 'model.Message'): + self._methods['message'](message) def imported(self, import_type, name, attrs): - method = getattr(self, '_%s_import' % import_type.lower()) + method = self._methods.get('%s_import' % import_type.lower()) method(name, attrs) def output_file(self, file_type, path): - method = getattr(self, '_%s_file' % file_type.lower()) + method = self._methods.get('%s_file' % file_type.lower()) method(path) + def close(self): + self._methods['close']() + def __bool__(self): return any(isinstance(method, ListenerMethods) and method - for method in self.__dict__.values()) + for method in self._methods.values()) -class LibraryListeners: +class LibraryListeners(LoggerApi): _method_names = ('start_suite', 'end_suite', 'start_test', 'end_test', 'start_keyword', 'end_keyword', 'log_message', 'message', 'close') + _methods = {} def __init__(self, log_level='INFO'): self._is_logged = IsLogged(log_level) for name in self._method_names: method = LibraryListenerMethods(name) - if name == 'log_message': - name = '_' + name - setattr(self, name, method) + self._methods[name] = method + + def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + self._methods['start_suite'](ModelCombiner(data, result, + tests=data.tests, + suites=data.suites, + test_count=data.test_count)) + + def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + self._methods['end_suite'](ModelCombiner(data, result)) + + def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + self._methods['start_test'](ModelCombiner(data, result)) + + def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + self._methods['end_test'](ModelCombiner(data, result)) + + def start_body_item(self, data, result): + if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): + self._methods['start_keyword'](ModelCombiner(data, result)) + + def end_body_item(self, data, result): + if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): + self._methods['end_keyword'](ModelCombiner(data, result)) def register(self, listeners, library): listeners = ListenerProxy.import_listeners(listeners, @@ -92,12 +134,12 @@ def register(self, listeners, library): method.register(listeners, library) def _listener_methods(self): - return [method for method in self.__dict__.values() + return [method for method in self._methods.values() if isinstance(method, LibraryListenerMethods)] def unregister(self, library, close=False): - if close: - self.close(library=library) + if close and self._methods.get('close'): + self._methods['close'](library=library) for method in self._listener_methods(): method.unregister(library) @@ -112,15 +154,12 @@ def discard_suite_scope(self): def set_log_level(self, level): self._is_logged.set_level(level) - def log_message(self, msg): - if self._is_logged(msg.level): - self._log_message(msg) - - def imported(self, import_type, name, attrs): - pass + def log_message(self, message: 'model.Message'): + if self._is_logged(message.level): + self._methods['log_message'](message) - def output_file(self, file_type, path): - pass + def message(self, message: 'model.Message'): + self._methods['message'](message) class ListenerProxy(AbstractLoggerProxy): @@ -174,139 +213,3 @@ def import_listeners(cls, listeners, method_names, prefix=None, raise DataError(msg) LOGGER.error(msg) return imported - - -class ListenerAdapter(LoggerApi): - - def __init__(self, listener): - self.listener = listener - - def register(self, listeners, library): - self.listener.register(listeners, library) - - def unregister(self, library, close=False): - self.listener.unregister(library, close) - - def new_suite_scope(self): - self.listener.new_suite_scope() - - def discard_suite_scope(self): - self.listener.discard_suite_scope() - - def message(self, message: 'model.Message'): - self.listener.message(message) - - def log_message(self, message: 'model.Message'): - self.listener.log_message(message) - - def set_log_level(self, level): - self.listener.set_log_level(level) - - def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - suite = ModelCombiner(data, result, - tests=data.tests, - suites=data.suites, - test_count=data.test_count) - self.listener.start_suite(suite) - - def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self.listener.end_suite(ModelCombiner(data, result)) - - def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self.listener.start_test(ModelCombiner(data, result)) - - def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self.listener.end_test(ModelCombiner(data, result)) - - def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_for(self, data: 'running.For', result: 'result.For'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_for(self, data: 'running.For', result: 'result.For'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_while(self, data: 'running.While', result: 'result.While'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_while(self, data: 'running.While', result: 'result.While'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_if(self, data: 'running.If', result: 'result.If'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_if(self, data: 'running.If', result: 'result.If'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_if_branch(self, data: 'running.If', result: 'result.IfBranch'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_if_branch(self, data: 'running.If', result: 'result.IfBranch'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_try(self, data: 'running.Try', result: 'result.Try'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_try(self, data: 'running.Try', result: 'result.Try'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_var(self, data, result): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_var(self, data, result): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_break(self, data, result): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_break(self, data, result): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_continue(self, data, result): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_continue(self, data, result): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_return(self, data, result): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_return(self, data, result): - self.listener.end_keyword(ModelCombiner(data, result)) - - def start_error(self, data, result): - self.listener.start_keyword(ModelCombiner(data, result)) - - def end_error(self, data, result): - self.listener.end_keyword(ModelCombiner(data, result)) - - def imported(self, import_type: str, name: str, attrs): - self.listener.imported(import_type, name, attrs) - - def output_file(self, type_: str, path: str): - self.listener.output_file(type_, path) - - def close(self): - self.listener.close() diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 86718fda9e1..0bc1e3aff8e 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -15,7 +15,7 @@ from . import pyloggingconf from .debugfile import DebugFile -from .listeners import LibraryListeners, Listeners, ListenerAdapter +from .listeners import LibraryListeners, Listeners from .logger import LOGGER from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger @@ -28,8 +28,8 @@ def __init__(self, settings): AbstractLogger.__init__(self) self._xml_logger = XmlLoggerAdapter(settings.output, settings.log_level, settings.rpa) - self.listeners = ListenerAdapter(Listeners(settings.listeners, settings.log_level)) - self.library_listeners = ListenerAdapter(LibraryListeners(settings.log_level)) + self.listeners = Listeners(settings.listeners, settings.log_level) + self.library_listeners = LibraryListeners(settings.log_level) self._register_loggers(DebugFile(settings.debug_file)) self._settings = settings self._flatten_level = 0 From 94c81c3f03fba2226a6bd07725e3c06ae881f966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Sun, 15 Oct 2023 08:53:03 +0300 Subject: [PATCH 0811/1592] listeners: get rid of ModelCombiner --- src/robot/output/listenermethods.py | 17 +++++++++++++++++ src/robot/output/listeners.py | 8 ++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/robot/output/listenermethods.py b/src/robot/output/listenermethods.py index 6b71830f41d..6385b7c3dc3 100644 --- a/src/robot/output/listenermethods.py +++ b/src/robot/output/listenermethods.py @@ -34,6 +34,23 @@ def _register_methods(self, method_name, listeners): if method: self._methods.append(ListenerMethod(method, listener)) + def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method((result.name, { + 'id': data.id, + 'doc': result.doc, + 'metadata': dict(result.metadata), + 'starttime': result.starttime, + 'longname': result.full_name, + 'tests': [t.name for t in data.tests], + 'suites': [s.name for s in data.suites], + 'totaltests': data.test_count, + 'source': str(data.source or '') + })) + def __call__(self, *args): if self._methods: args = ListenerArguments.by_method_name(self._method_name, args) diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index 1161b94cc43..3e9d85914e6 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -38,14 +38,10 @@ def __init__(self, listeners, log_level='INFO'): listeners = ListenerProxy.import_listeners(listeners, self._method_names) for name in self._method_names: - method = ListenerMethods(name, listeners) - self._methods[name] = method + self._methods[name] = ListenerMethods(name, listeners) def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._methods['start_suite'](ModelCombiner(data, result, - tests=data.tests, - suites=data.suites, - test_count=data.test_count)) + self._methods['start_suite'].start_suite(data, result) def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): self._methods['end_suite'](ModelCombiner(data, result)) From 8355e52ae15403f8236fb0abc8181ae7d3b9c939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Sat, 21 Oct 2023 09:21:50 +0300 Subject: [PATCH 0812/1592] listener: get rid of model combiner --- src/robot/output/listenermethods.py | 342 +++++++++++++++++++++++++++- src/robot/output/listeners.py | 84 ++++++- 2 files changed, 404 insertions(+), 22 deletions(-) diff --git a/src/robot/output/listenermethods.py b/src/robot/output/listenermethods.py index 6385b7c3dc3..bc12ce4a800 100644 --- a/src/robot/output/listenermethods.py +++ b/src/robot/output/listenermethods.py @@ -14,7 +14,8 @@ # limitations under the License. from robot.errors import TimeoutError -from robot.utils import get_error_details +from robot.model import BodyItem +from robot.utils import get_error_details, is_string, safe_str from .listenerarguments import ListenerArguments from .logger import LOGGER @@ -39,17 +40,334 @@ def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): if method.version == 3: method((data, result)) else: - method((result.name, { - 'id': data.id, - 'doc': result.doc, - 'metadata': dict(result.metadata), - 'starttime': result.starttime, - 'longname': result.full_name, - 'tests': [t.name for t in data.tests], - 'suites': [s.name for s in data.suites], - 'totaltests': data.test_count, - 'source': str(data.source or '') - })) + method(self._suite_v2_attributes(data, result)) + + def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._suite_v2_attributes(data, result, is_end=True)) + + def _suite_v2_attributes(self, data, result, is_end=False): + attrs = { + 'id': data.id, + 'doc': result.doc, + 'metadata': dict(result.metadata), + 'starttime': result.starttime, + 'longname': result.full_name, + 'tests': [t.name for t in data.tests], + 'suites': [s.name for s in data.suites], + 'totaltests': data.test_count, + 'source': str(data.source or '') + } + if is_end: + attrs.update({ + 'endtime': result.endtime, + 'elapsedtime': result.elapsedtime, + 'status': result.status, + 'message': result.message, + 'statistics': result.stat_message + }) + return result.name, attrs + + def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._test_v2_attributes(data, result)) + + def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._test_v2_attributes(data, result, is_end=True)) + + def _test_v2_attributes(self, data: 'running.TestCase', result: 'result.TestCase', is_end=False): + attrs = { + 'id': data.id, + 'doc': result.doc, + 'tags': list(result.tags), + 'lineno': data.lineno, + 'starttime': result.starttime, + 'longname': result.full_name, + 'source': str(data.source or ''), + 'template': data.template or '', + 'originalname': data.name + } + if is_end: + attrs.update({ + 'endtime': result.endtime, + 'elapsedtime': result.elapsedtime, + 'status': result.status, + 'message': result.message, + }) + return result.name, attrs + + def start_keyword(self, data, result): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._kw_v2_attributes(data, result, is_end=False)) + + def end_keyword(self, data, result): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._kw_v2_attributes(data, result, is_end=True)) + + def start_for(self, data: 'running.For', result: 'result.For'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._for_v2_attributes(data, result, is_end=False)) + + def end_for(self, data: 'running.For', result: 'result.For'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._for_v2_attributes(data, result, is_end=True)) + + def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) + attrs['variables'] = dict(result.assign) + method((result._log_name, attrs)) + + def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) + attrs['variables'] = dict(result.assign) + method((result._log_name, attrs)) + + def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) + method((result._log_name, attrs)) + + def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) + method((result._log_name, attrs)) + + def start_while(self, data: 'running.While', result: 'result.While'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._while_v2_attributes(data, result, is_end=False)) + + def end_while(self, data: 'running.While', result: 'result.While'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._while_v2_attributes(data, result, is_end=True)) + + def start_if_branch(self, data: 'running.If', result: 'result.If'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._if_v2_attributes(data, result, is_end=False)) + + def end_if_branch(self, data: 'running.If', result: 'result.If'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._if_v2_attributes(data, result, is_end=True)) + + def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._try_v2_attributes(data, result, is_end=False)) + + def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._try_v2_attributes(data, result, is_end=True)) + + def start_return(self, data: 'running.Return', result: 'result.Return'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._return_v2_attributes(data, result, is_end=False)) + + def end_return(self, data: 'running.Return', result: 'result.Return'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + method(self._return_v2_attributes(data, result, is_end=True)) + + def start_continue(self, data: 'running.Continue', result: 'result.Continue'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) + method((result._log_name, attrs)) + + def end_continue(self, data: 'running.Continue', result: 'result.Continue'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) + method((result._log_name, attrs)) + + def start_break(self, data: 'running.Break', result: 'result.Break'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) + method((result._log_name, attrs)) + + def end_break(self, data: 'running.Break', result: 'result.Break'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) + method((result._log_name, attrs)) + + def start_error(self, data: 'running.Error', result: 'result.Error'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) + method((result._log_name, attrs)) + + def end_error(self, data: 'running.Error', result: 'result.Error'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) + method((result._log_name, attrs)) + + def start_var(self, data: 'running.Var', result: 'result.Var'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) + method((result._log_name, attrs)) + + def end_var(self, data: 'running.Var', result: 'result.Var'): + for method in self._methods: + if method.version == 3: + method((data, result)) + else: + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) + method((result._log_name, attrs)) + + def _common_v2_attributes(self, data, result, is_keyword_like, is_end): + attrs = { + 'doc': result.doc, + 'lineno': data.lineno, + 'type': result.type, + 'status': result.status, + 'starttime': result.starttime, + 'source': str(data.source or '') + } + if is_keyword_like: + attrs.update({ + 'kwname': result.name or '', + 'libname': result.owner or '', + 'args': [a if is_string(a) else safe_str(a) for a in result.args], + 'assign': list(result.assign), + 'tags': list(result.tags), + }) + else: + attrs.update({ + 'kwname': result._log_name, + 'libname': '', + 'args': [], + 'assign': [], + 'tags': [] + }) + if is_end: + attrs.update({ + 'endtime': result.endtime, + 'elapsedtime': result.elapsedtime + }) + return attrs + + def _kw_v2_attributes(self, data, result, is_end): + attrs = self._common_v2_attributes(data, result, is_keyword_like=True, is_end=is_end) + return result.full_name, attrs + + def _for_v2_attributes(self, data: 'running.For', result: 'result.For', is_end): + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) + attrs.update({ + 'variables': list(result.assign), + 'flavor': result.flavor or '', + 'values': list(result.values) + }) + if result.flavor == 'IN ENUMERATE': + attrs['start'] = result.start + elif result.flavor == 'IN ZIP': + attrs['fill'] = result.fill + attrs['mode'] = result.mode + return result._log_name, attrs + + def _while_v2_attributes(self, data: 'running.While', result: 'result.While', is_end=False): + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) + attrs.update({ + 'condition': result.condition, + 'limit': result.limit, + 'on_limit_message': result.on_limit_message + # FIXME: Add 'on_limit' + }) + return result._log_name, attrs + + def _if_v2_attributes(self, data: 'running.If', result: 'result.If', is_end=False): + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) + if result.type in (BodyItem.IF, BodyItem.ELSE_IF): + attrs['condition'] = result.condition + return result._log_name, attrs + + def _try_v2_attributes(self, data: 'running.Try', result: 'result.TryBranch', is_end=False): + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) + if result.type == BodyItem.EXCEPT: + attrs.update({ + 'patterns': list(result.patterns), + 'pattern_type': result.pattern_type, + 'variable': result.assign + }) + return result._log_name, attrs + + def _return_v2_attributes(self, data: 'running.Return', result: 'result.Return', is_end=False): + attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) + attrs['values'] = list(result.values) + return result._log_name, attrs def __call__(self, *args): if self._methods: diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index 3e9d85914e6..5addf06b671 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -31,12 +31,12 @@ class Listeners(LoggerApi): 'output_file', 'report_file', 'log_file', 'debug_file', 'xunit_file', 'library_import', 'resource_import', 'variables_import', 'close') - _methods = {} def __init__(self, listeners, log_level='INFO'): self._is_logged = IsLogged(log_level) listeners = ListenerProxy.import_listeners(listeners, self._method_names) + self._methods = {} for name in self._method_names: self._methods[name] = ListenerMethods(name, listeners) @@ -44,21 +44,85 @@ def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): self._methods['start_suite'].start_suite(data, result) def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._methods['end_suite'](ModelCombiner(data, result)) + self._methods['end_suite'].end_suite(data, result) def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._methods['start_test'](ModelCombiner(data, result)) + self._methods['start_test'].start_test(data, result) def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._methods['end_test'](ModelCombiner(data, result)) + self._methods['end_test'].end_test(data, result) - def start_body_item(self, data, result): - if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): - self._methods['start_keyword'](ModelCombiner(data, result)) + def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + self._methods['start_keyword'].start_keyword(data, result) - def end_body_item(self, data, result): - if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): - self._methods['end_keyword'](ModelCombiner(data, result)) + def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + self._methods['end_keyword'].end_keyword(data, result) + + def start_for(self, data: 'running.For', result: 'result.For'): + self._methods['start_keyword'].start_for(data, result) + + def end_for(self, data: 'running.For', result: 'result.For'): + self._methods['end_keyword'].end_for(data, result) + + def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + self._methods['start_keyword'].start_for_iteration(data, result) + + def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + self._methods['end_keyword'].end_for_iteration(data, result) + + def start_while(self, data: 'running.While', result: 'result.While'): + self._methods['start_keyword'].start_while(data, result) + + def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + self._methods['start_keyword'].start_while_iteration(data, result) + + def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + self._methods['end_keyword'].end_while_iteration(data, result) + + def end_while(self, data: 'running.While', result: 'result.While'): + self._methods['end_keyword'].end_while(data, result) + + def start_if_branch(self, data: 'running.If', result: 'result.If'): + self._methods['start_keyword'].start_if_branch(data, result) + + def end_if_branch(self, data: 'running.If', result: 'result.If'): + self._methods['end_keyword'].end_if_branch(data, result) + + def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + self._methods['start_keyword'].start_try_branch(data, result) + + def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + self._methods['end_keyword'].end_try_branch(data, result) + + def start_return(self, data: 'running.Return', result: 'result.Return'): + self._methods['start_keyword'].start_return(data, result) + + def end_return(self, data: 'running.Return', result: 'result.Return'): + self._methods['end_keyword'].end_return(data, result) + + def start_continue(self, data: 'running.Continue', result: 'result.Continue'): + self._methods['start_keyword'].start_continue(data, result) + + def end_continue(self, data: 'running.Continue', result: 'result.Continue'): + self._methods['end_keyword'].end_continue(data, result) + + def start_break(self, data: 'running.Break', result: 'result.Break'): + self._methods['start_keyword'].start_break(data, result) + + def end_break(self, data: 'running.Break', result: 'result.Break'): + self._methods['end_keyword'].end_break(data, result) + + def start_error(self, data: 'running.Error', result: 'result.Error'): + self._methods['start_keyword'].start_error(data, result) + + def end_error(self, data: 'running.Error', result: 'result.Error'): + self._methods['end_keyword'].end_error(data, result) + + def start_var(self, data: 'running.Var', result: 'result.Var'): + self._methods['start_keyword'].start_var(data, result) + + def end_var(self, data: 'running.Var', result: 'result.Var'): + self._methods['end_keyword'].end_var(data, result) def set_log_level(self, level): self._is_logged.set_level(level) From f29ca0ebc5c103e302c5a3e9f8c2e28bc34283a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Mon, 23 Oct 2023 09:34:38 +0300 Subject: [PATCH 0813/1592] listeneres: refactoring --- src/robot/output/listeners.py | 612 +++++++++++++++++++++++-------- src/robot/output/old_listener.py | 135 +++++++ src/robot/output/output.py | 3 +- 3 files changed, 590 insertions(+), 160 deletions(-) create mode 100644 src/robot/output/old_listener.py diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index 5addf06b671..a41663f7eac 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -15,261 +15,555 @@ import os.path -from robot.errors import DataError -from robot.utils import Importer, is_string, split_args_from_name_or_path, type_name +from robot.errors import DataError, TimeoutError +from robot.model import BodyItem +from robot.utils import (Importer, get_error_details, is_string, safe_str, + split_args_from_name_or_path, type_name) -from .listenermethods import ListenerMethods, LibraryListenerMethods from .loggerapi import LoggerApi -from .loggerhelper import AbstractLoggerProxy, IsLogged +from .loggerhelper import IsLogged from .logger import LOGGER -from .modelcombiner import ModelCombiner class Listeners(LoggerApi): - _method_names = ('start_suite', 'end_suite', 'start_test', 'end_test', - 'start_keyword', 'end_keyword', 'log_message', 'message', - 'output_file', 'report_file', 'log_file', 'debug_file', - 'xunit_file', 'library_import', 'resource_import', - 'variables_import', 'close') def __init__(self, listeners, log_level='INFO'): self._is_logged = IsLogged(log_level) - listeners = ListenerProxy.import_listeners(listeners, - self._method_names) - self._methods = {} - for name in self._method_names: - self._methods[name] = ListenerMethods(name, listeners) + self.listeners = import_listeners(listeners) def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._methods['start_suite'].start_suite(data, result) + for listener in self.listeners: + listener.start_suite(data, result) def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._methods['end_suite'].end_suite(data, result) + for listener in self.listeners: + listener.end_suite(data, result) def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._methods['start_test'].start_test(data, result) + for listener in self.listeners: + listener.start_test(data, result) def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._methods['end_test'].end_test(data, result) + for listener in self.listeners: + listener.end_test(data, result) def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - self._methods['start_keyword'].start_keyword(data, result) + for listener in self.listeners: + listener.start_keyword(data, result) def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - self._methods['end_keyword'].end_keyword(data, result) + for listener in self.listeners: + listener.end_keyword(data, result) def start_for(self, data: 'running.For', result: 'result.For'): - self._methods['start_keyword'].start_for(data, result) + for listener in self.listeners: + listener.start_for(data, result) def end_for(self, data: 'running.For', result: 'result.For'): - self._methods['end_keyword'].end_for(data, result) + for listener in self.listeners: + listener.end_for(data, result) def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - self._methods['start_keyword'].start_for_iteration(data, result) + for listener in self.listeners: + listener.start_for_iteration(data, result) def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - self._methods['end_keyword'].end_for_iteration(data, result) + for listener in self.listeners: + listener.end_for_iteration(data, result) def start_while(self, data: 'running.While', result: 'result.While'): - self._methods['start_keyword'].start_while(data, result) + for listener in self.listeners: + listener.start_while(data, result) + + def end_while(self, data: 'running.While', result: 'result.While'): + for listener in self.listeners: + listener.end_while(data, result) def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - self._methods['start_keyword'].start_while_iteration(data, result) + for listener in self.listeners: + listener.start_while_iteration(data, result) def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - self._methods['end_keyword'].end_while_iteration(data, result) - - def end_while(self, data: 'running.While', result: 'result.While'): - self._methods['end_keyword'].end_while(data, result) + for listener in self.listeners: + listener.end_while_iteration(data, result) def start_if_branch(self, data: 'running.If', result: 'result.If'): - self._methods['start_keyword'].start_if_branch(data, result) + for listener in self.listeners: + listener.start_if_branch(data, result) def end_if_branch(self, data: 'running.If', result: 'result.If'): - self._methods['end_keyword'].end_if_branch(data, result) + for listener in self.listeners: + listener.end_if_branch(data, result) def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - self._methods['start_keyword'].start_try_branch(data, result) + for listener in self.listeners: + listener.start_try_branch(data, result) def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - self._methods['end_keyword'].end_try_branch(data, result) + for listener in self.listeners: + listener.end_try_branch(data, result) def start_return(self, data: 'running.Return', result: 'result.Return'): - self._methods['start_keyword'].start_return(data, result) + for listener in self.listeners: + listener.start_return(data, result) def end_return(self, data: 'running.Return', result: 'result.Return'): - self._methods['end_keyword'].end_return(data, result) + for listener in self.listeners: + listener.end_return(data, result) def start_continue(self, data: 'running.Continue', result: 'result.Continue'): - self._methods['start_keyword'].start_continue(data, result) + for listener in self.listeners: + listener.start_continue(data, result) def end_continue(self, data: 'running.Continue', result: 'result.Continue'): - self._methods['end_keyword'].end_continue(data, result) + for listener in self.listeners: + listener.end_continue(data, result) def start_break(self, data: 'running.Break', result: 'result.Break'): - self._methods['start_keyword'].start_break(data, result) + for listener in self.listeners: + listener.start_break(data, result) def end_break(self, data: 'running.Break', result: 'result.Break'): - self._methods['end_keyword'].end_break(data, result) + for listener in self.listeners: + listener.end_break(data, result) def start_error(self, data: 'running.Error', result: 'result.Error'): - self._methods['start_keyword'].start_error(data, result) + for listener in self.listeners: + listener.start_error(data, result) def end_error(self, data: 'running.Error', result: 'result.Error'): - self._methods['end_keyword'].end_error(data, result) + for listener in self.listeners: + listener.end_error(data, result) def start_var(self, data: 'running.Var', result: 'result.Var'): - self._methods['start_keyword'].start_var(data, result) + for listener in self.listeners: + listener.start_var(data, result) def end_var(self, data: 'running.Var', result: 'result.Var'): - self._methods['end_keyword'].end_var(data, result) + for listener in self.listeners: + listener.end_var(data, result) def set_log_level(self, level): self._is_logged.set_level(level) def log_message(self, message: 'model.Message'): if self._is_logged(message.level): - self._methods['log_message'](message) + for listener in self.listeners: + listener.log_message(message) def message(self, message: 'model.Message'): - self._methods['message'](message) + for listener in self.listeners: + listener.message(message) def imported(self, import_type, name, attrs): - method = self._methods.get('%s_import' % import_type.lower()) - method(name, attrs) + for listener in self.listeners: + listener.imported(import_type, name, attrs) def output_file(self, file_type, path): - method = self._methods.get('%s_file' % file_type.lower()) - method(path) + for listener in self.listeners: + listener.output_file(file_type, path) def close(self): - self._methods['close']() + for listener in self.listeners: + listener.close() def __bool__(self): - return any(isinstance(method, ListenerMethods) and method - for method in self._methods.values()) + return len(self.listeners) > 0 -class LibraryListeners(LoggerApi): - _method_names = ('start_suite', 'end_suite', 'start_test', 'end_test', - 'start_keyword', 'end_keyword', 'log_message', 'message', - 'close') - _methods = {} +class ListenerFacade(LoggerApi): + _no_method = lambda *args: None - def __init__(self, log_level='INFO'): - self._is_logged = IsLogged(log_level) - for name in self._method_names: - method = LibraryListenerMethods(name) - self._methods[name] = method + def __init__(self, listener, name, version): + self.listener = listener + self.name = name + self.version = version def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._methods['start_suite'](ModelCombiner(data, result, - tests=data.tests, - suites=data.suites, - test_count=data.test_count)) + method = self._get_method(self.listener, 'start_suite') + method(data, result) def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._methods['end_suite'](ModelCombiner(data, result)) + method = self._get_method(self.listener, 'end_suite') + method(data, result) def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._methods['start_test'](ModelCombiner(data, result)) + method = self._get_method(self.listener, 'start_test') + method(data, result) def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._methods['end_test'](ModelCombiner(data, result)) - - def start_body_item(self, data, result): - if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): - self._methods['start_keyword'](ModelCombiner(data, result)) - - def end_body_item(self, data, result): - if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): - self._methods['end_keyword'](ModelCombiner(data, result)) - - def register(self, listeners, library): - listeners = ListenerProxy.import_listeners(listeners, - self._method_names, - prefix='_', - raise_on_error=True) - for method in self._listener_methods(): - method.register(listeners, library) - - def _listener_methods(self): - return [method for method in self._methods.values() - if isinstance(method, LibraryListenerMethods)] - - def unregister(self, library, close=False): - if close and self._methods.get('close'): - self._methods['close'](library=library) - for method in self._listener_methods(): - method.unregister(library) - - def new_suite_scope(self): - for method in self._listener_methods(): - method.new_suite_scope() - - def discard_suite_scope(self): - for method in self._listener_methods(): - method.discard_suite_scope() - - def set_log_level(self, level): - self._is_logged.set_level(level) + method = self._get_method(self.listener, 'end_test') + method(data, result) def log_message(self, message: 'model.Message'): - if self._is_logged(message.level): - self._methods['log_message'](message) + method = self._get_method(self.listener, 'log_message') + method(message) def message(self, message: 'model.Message'): - self._methods['message'](message) + method = self._get_method(self.listener, 'message') + method(message) + def output_file(self, type_: str, path: str): + method = self._get_method(self.listener, '%s_file' % type_.lower()) + method(path) -class ListenerProxy(AbstractLoggerProxy): - _no_method = None + def close(self): + method = self._get_method(self.listener, 'close') + method() - def __init__(self, listener, method_names, prefix=None): - listener, name = self._import_listener(listener) - AbstractLoggerProxy.__init__(self, listener, method_names, prefix) - self.name = name - self.version = self._get_version(listener) - if self.version == 3: - self.start_keyword = self.end_keyword = None - self.library_import = self.resource_import = self.variables_import = None - - def _import_listener(self, listener): - if not is_string(listener): - # Modules have `__name__`, with others better to use `type_name`. - name = getattr(listener, '__name__', None) or type_name(listener) - return listener, name - name, args = split_args_from_name_or_path(listener) - importer = Importer('listener', logger=LOGGER) - listener = importer.import_class_or_module(os.path.normpath(name), - instantiate_with_args=args) - return listener, name + def _get_method(self, listener, name, prefix=''): + for method_name in self._get_method_names(name, prefix): + if hasattr(listener, method_name): + return ListenerMethod(getattr(listener, method_name), self.name, self.version) + return self._no_method - def _get_version(self, listener): + def _get_method_names(self, name, prefix): + names = [name, self._toCamelCase(name)] if '_' in name else [name] + if prefix: + names += [prefix + name for name in names] + return names + + def _toCamelCase(self, name): + parts = name.split('_') + return ''.join([parts[0]] + [part.capitalize() for part in parts[1:]]) + + +class ListenerV2Facade(ListenerFacade): + + def imported(self, import_type: str, name: str, attrs): + method = self._get_method(self.listener, '%s_import' % import_type.lower()) + method(name, attrs) + + def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + method = self._get_method(self.listener, 'start_suite') + method(result.name, self._suite_v2_attributes(data, result)) + + def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + method = self._get_method(self.listener, 'end_suite') + method(result.name, self._suite_v2_attributes(data, result, is_end=True)) + + def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + method = self._get_method(self.listener, 'start_test') + method(result.name, self._test_v2_attributes(data, result)) + + def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + method = self._get_method(self.listener, 'end_test') + method(result.name, self._test_v2_attributes(data, result, is_end=True)) + + def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + method = self._get_method(self.listener, 'start_keyword') + method(result.full_name, + self._body_item_v2_attributes(data, result, is_keyword_like=True)) + + def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + method = self._get_method(self.listener, 'end_keyword') + method(result.full_name, + self._body_item_v2_attributes(data, result, is_keyword_like=True, is_end=True)) + + def start_for(self, data: 'running.For', result: 'result.For'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._for_v2_attributes(data, result)) + + def end_for(self, data: 'running.For', result: 'result.For'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._for_v2_attributes(data, result, is_end=True)) + + def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + attrs = self._body_item_v2_attributes(data, result) + attrs['variables'] = dict(result.assign) + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, attrs) + + def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + attrs = self._body_item_v2_attributes(data, result, is_end=True) + attrs['variables'] = dict(result.assign) + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, attrs) + + def start_while(self, data: 'running.While', result: 'result.While'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._while_v2_attributes(data, result)) + + def end_while(self, data: 'running.While', result: 'result.While'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._while_v2_attributes(data, result, is_end=True)) + + def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result)) + + def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + + def start_if_branch(self, data: 'running.If', result: 'result.IfBranch'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._if_v2_attributes(data, result)) + + def end_if_branch(self, data: 'running.If', result: 'result.IfBranch'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._if_v2_attributes(data, result, is_end=True)) + + def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._try_v2_attributes(data, result)) + + def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._try_v2_attributes(data, result, is_end=True)) + + def start_return(self, data: 'running.Return', result: 'result.Return'): + attrs = self._body_item_v2_attributes(data, result) + attrs['values'] = list(result.values) + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, attrs) + + def end_return(self, data: 'running.Return', result: 'result.Return'): + attrs = self._body_item_v2_attributes(data, result, is_end=True) + attrs['values'] = list(result.values) + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, attrs) + + def start_continue(self, data: 'running.Continue', result: 'result.Continue'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result)) + + def end_continue(self, data: 'running.Continue', result: 'result.Continue'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + + def start_break(self, data: 'running.Break', result: 'result.Break'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result)) + + def end_break(self, data: 'running.Break', result: 'result.Break'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + + def start_error(self, data: 'running.Error', result: 'result.Error'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result)) + + def end_error(self, data: 'running.Error', result: 'result.Error'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + + def start_var(self, data: 'running.Var', result: 'result.Var'): + method = self._get_method(self.listener, 'start_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result)) + + def end_var(self, data: 'running.Var', result: 'result.Var'): + method = self._get_method(self.listener, 'end_keyword') + method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + + def log_message(self, message: 'model.Message'): + method = self._get_method(self.listener, 'log_message') + method(self._message_attributes(message)) + + def message(self, message: 'model.Message'): + method = self._get_method(self.listener, 'message') + method(self._message_attributes(message)) + + def _suite_v2_attributes(self, data, result, is_end=False): + attrs = { + 'id': data.id, + 'doc': result.doc, + 'metadata': dict(result.metadata), + 'starttime': result.starttime, + 'longname': result.full_name, + 'tests': [t.name for t in data.tests], + 'suites': [s.name for s in data.suites], + 'totaltests': data.test_count, + 'source': str(data.source or '') + } + if is_end: + attrs.update({ + 'endtime': result.endtime, + 'elapsedtime': result.elapsedtime, + 'status': result.status, + 'message': result.message, + 'statistics': result.stat_message + }) + return attrs + + def _test_v2_attributes(self, data: 'running.TestCase', result: 'result.TestCase', is_end=False): + attrs = { + 'id': data.id, + 'doc': result.doc, + 'tags': list(result.tags), + 'lineno': data.lineno, + 'starttime': result.starttime, + 'longname': result.full_name, + 'source': str(data.source or ''), + 'template': data.template or '', + 'originalname': data.name + } + if is_end: + attrs.update({ + 'endtime': result.endtime, + 'elapsedtime': result.elapsedtime, + 'status': result.status, + 'message': result.message, + }) + return attrs + + def _for_v2_attributes(self, data: 'running.For', result: 'result.For', is_end=False): + attrs = self._body_item_v2_attributes(data, result, is_end=is_end) + attrs.update({ + 'variables': list(result.assign), + 'flavor': result.flavor or '', + 'values': list(result.values) + }) + if result.flavor == 'IN ENUMERATE': + attrs['start'] = result.start + elif result.flavor == 'IN ZIP': + attrs['fill'] = result.fill + attrs['mode'] = result.mode + return attrs + + def _while_v2_attributes(self, data: 'running.While', result: 'result.While', is_end=False): + attrs = self._body_item_v2_attributes(data, result, is_end=is_end) + attrs.update({ + 'condition': result.condition, + 'limit': result.limit, + 'on_limit_message': result.on_limit_message + # FIXME: Add 'on_limit' + }) + return attrs + + def _if_v2_attributes(self, data: 'running.If', result: 'result.If', is_end=False): + attrs = self._body_item_v2_attributes(data, result, is_end=is_end) + if result.type in (BodyItem.IF, BodyItem.ELSE_IF): + attrs['condition'] = result.condition + return attrs + + def _try_v2_attributes(self, data: 'running.Try', result: 'result.TryBranch', is_end=False): + attrs = self._body_item_v2_attributes(data, result, is_end=is_end) + if result.type == BodyItem.EXCEPT: + attrs.update({ + 'patterns': list(result.patterns), + 'pattern_type': result.pattern_type, + 'variable': result.assign + }) + return attrs + + def _return_v2_attributes(self, data: 'running.Return', result: 'result.Return', is_end=False): + attrs = self._body_item_v2_attributes(data, result, is_end=is_end) + attrs['values'] = list(result.values) + return result._log_name, attrs + + def _body_item_v2_attributes(self, data, result, is_end=False, is_keyword_like=False): + attrs = { + 'doc': result.doc, + 'lineno': data.lineno, + 'type': result.type, + 'status': result.status, + 'starttime': result.starttime, + 'source': str(data.source or '') + } + if is_keyword_like: + attrs.update({ + 'kwname': result.name or '', + 'libname': result.owner or '', + 'args': [a if is_string(a) else safe_str(a) for a in result.args], + 'assign': list(result.assign), + 'tags': list(result.tags), + }) + else: + attrs.update({ + 'kwname': result._log_name, + 'libname': '', + 'args': [], + 'assign': [], + 'tags': [] + }) + if is_end: + attrs.update({ + 'endtime': result.endtime, + 'elapsedtime': result.elapsedtime + }) + return attrs + + def _message_attributes(self, msg): + # Timestamp in our legacy format. + timestamp = msg.timestamp.isoformat(' ', timespec='milliseconds').replace('-', '') + attrs = {'timestamp': timestamp, + 'message': msg.message, + 'level': msg.level, + 'html': 'yes' if msg.html else 'no'} + return attrs + +def import_listener(listener): + if not is_string(listener): + # Modules have `__name__`, with others better to use `type_name`. + name = getattr(listener, '__name__', None) or type_name(listener) + return listener, name + name, args = split_args_from_name_or_path(listener) + importer = Importer('listener', logger=LOGGER) + listener = importer.import_class_or_module(os.path.normpath(name), + instantiate_with_args=args) + return listener, name + + +def get_version(listener, name): + try: + version = int(listener.ROBOT_LISTENER_API_VERSION) + if version not in (2, 3): + raise ValueError + except AttributeError: + raise DataError("Listener '%s' does not have mandatory " + "'ROBOT_LISTENER_API_VERSION' attribute." + % name) + except (ValueError, TypeError): + raise DataError("Listener '%s' uses unsupported API version '%s'." + % (name, listener.ROBOT_LISTENER_API_VERSION)) + return version + + +def import_listeners(listeners, + raise_on_error=False): + all = [] + for listener in listeners: + try: + imported, name = import_listener(listener) + version = get_version(imported, name) + if version == 2: + all.append(ListenerV2Facade(imported, name, version)) + else: + all.append(ListenerFacade(imported, name, version)) + except DataError as err: + name = listener if is_string(listener) else type_name(listener) + msg = "Taking listener '%s' into use failed: %s" % (name, err) + if raise_on_error: + raise DataError(msg) + LOGGER.error(msg) + return all + + +class ListenerMethod: + # Flag to avoid recursive listener calls. + called = False + + def __init__(self, method, name, version, library=None): + self.method = method + self.listener_name = name + self.version = version + self.library = library + + def __call__(self, *args): + if self.called: + return try: - version = int(listener.ROBOT_LISTENER_API_VERSION) - if version not in (2, 3): - raise ValueError - except AttributeError: - raise DataError("Listener '%s' does not have mandatory " - "'ROBOT_LISTENER_API_VERSION' attribute." - % self.name) - except (ValueError, TypeError): - raise DataError("Listener '%s' uses unsupported API version '%s'." - % (self.name, listener.ROBOT_LISTENER_API_VERSION)) - return version - - @classmethod - def import_listeners(cls, listeners, method_names, prefix=None, - raise_on_error=False): - imported = [] - for listener in listeners: - try: - imported.append(cls(listener, method_names, prefix)) - except DataError as err: - name = listener if is_string(listener) else type_name(listener) - msg = "Taking listener '%s' into use failed: %s" % (name, err) - if raise_on_error: - raise DataError(msg) - LOGGER.error(msg) - return imported + ListenerMethod.called = True + self.method(*args) + except TimeoutError: + # Propagate possible timeouts: + # https://github.com/robotframework/robotframework/issues/2763 + raise + except: + message, details = get_error_details() + LOGGER.error("Calling method '%s' of listener '%s' failed: %s" + % (self.method.__name__, self.listener_name, message)) + LOGGER.info("Details:\n%s" % details) + finally: + ListenerMethod.called = False diff --git a/src/robot/output/old_listener.py b/src/robot/output/old_listener.py new file mode 100644 index 00000000000..d99278e6746 --- /dev/null +++ b/src/robot/output/old_listener.py @@ -0,0 +1,135 @@ +import os.path + +from robot.errors import DataError +from robot.utils import Importer, is_string, split_args_from_name_or_path, type_name + +from .listenermethods import LibraryListenerMethods +from .loggerapi import LoggerApi +from .loggerhelper import AbstractLoggerProxy, IsLogged +from .logger import LOGGER +from .modelcombiner import ModelCombiner + + +class LibraryListeners(LoggerApi): + _method_names = ('start_suite', 'end_suite', 'start_test', 'end_test', + 'start_keyword', 'end_keyword', 'log_message', 'message', + 'close') + _methods = {} + + def __init__(self, log_level='INFO'): + self._is_logged = IsLogged(log_level) + for name in self._method_names: + method = LibraryListenerMethods(name) + self._methods[name] = method + + def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + self._methods['start_suite'](ModelCombiner(data, result, + tests=data.tests, + suites=data.suites, + test_count=data.test_count)) + + def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + self._methods['end_suite'](ModelCombiner(data, result)) + + def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + self._methods['start_test'](ModelCombiner(data, result)) + + def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + self._methods['end_test'](ModelCombiner(data, result)) + + def start_body_item(self, data, result): + if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): + self._methods['start_keyword'](ModelCombiner(data, result)) + + def end_body_item(self, data, result): + if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): + self._methods['end_keyword'](ModelCombiner(data, result)) + + def register(self, listeners, library): + listeners = ListenerProxy.import_listeners(listeners, + self._method_names, + prefix='_', + raise_on_error=True) + for method in self._listener_methods(): + method.register(listeners, library) + + def _listener_methods(self): + return [method for method in self._methods.values() + if isinstance(method, LibraryListenerMethods)] + + def unregister(self, library, close=False): + if close and self._methods.get('close'): + self._methods['close'](library=library) + for method in self._listener_methods(): + method.unregister(library) + + def new_suite_scope(self): + for method in self._listener_methods(): + method.new_suite_scope() + + def discard_suite_scope(self): + for method in self._listener_methods(): + method.discard_suite_scope() + + def set_log_level(self, level): + self._is_logged.set_level(level) + + def log_message(self, message: 'model.Message'): + if self._is_logged(message.level): + self._methods['log_message'](message) + + def message(self, message: 'model.Message'): + self._methods['message'](message) + + +class ListenerProxy(AbstractLoggerProxy): + _no_method = None + + def __init__(self, listener, method_names, prefix=None): + listener, name = self._import_listener(listener) + AbstractLoggerProxy.__init__(self, listener, method_names, prefix) + self.name = name + self.version = self._get_version(listener) + if self.version == 3: + self.start_keyword = self.end_keyword = None + self.library_import = self.resource_import = self.variables_import = None + + def _import_listener(self, listener): + if not is_string(listener): + # Modules have `__name__`, with others better to use `type_name`. + name = getattr(listener, '__name__', None) or type_name(listener) + return listener, name + name, args = split_args_from_name_or_path(listener) + importer = Importer('listener', logger=LOGGER) + listener = importer.import_class_or_module(os.path.normpath(name), + instantiate_with_args=args) + return listener, name + + def _get_version(self, listener): + try: + version = int(listener.ROBOT_LISTENER_API_VERSION) + if version not in (2, 3): + raise ValueError + except AttributeError: + raise DataError("Listener '%s' does not have mandatory " + "'ROBOT_LISTENER_API_VERSION' attribute." + % self.name) + except (ValueError, TypeError): + raise DataError("Listener '%s' uses unsupported API version '%s'." + % (self.name, listener.ROBOT_LISTENER_API_VERSION)) + return version + + @classmethod + def import_listeners(cls, listeners, method_names, prefix=None, + raise_on_error=False): + imported = [] + for listener in listeners: + try: + imported.append(cls(listener, method_names, prefix)) + except DataError as err: + name = listener if is_string(listener) else type_name(listener) + msg = "Taking listener '%s' into use failed: %s" % (name, err) + if raise_on_error: + raise DataError(msg) + LOGGER.error(msg) + return imported diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 0bc1e3aff8e..60f08ad4703 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -15,10 +15,11 @@ from . import pyloggingconf from .debugfile import DebugFile -from .listeners import LibraryListeners, Listeners +from .listeners import Listeners from .logger import LOGGER from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger +from .old_listener import LibraryListeners from .xmllogger import XmlLoggerAdapter From 2b9589918aa4b6bde607c288064602cc6be7f8a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Tue, 24 Oct 2023 07:29:37 +0300 Subject: [PATCH 0814/1592] listeners: refactoring --- src/robot/output/listeners.py | 140 +++++++++++++++------------------- 1 file changed, 62 insertions(+), 78 deletions(-) diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index a41663f7eac..8e05c3cc40c 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -172,50 +172,49 @@ def __bool__(self): class ListenerFacade(LoggerApi): - _no_method = lambda *args: None def __init__(self, listener, name, version): self.listener = listener self.name = name self.version = version + self._start_suite = self._get_method(listener, 'start_suite') + self._end_suite = self._get_method(listener, 'end_suite') + self._start_test = self._get_method(listener, 'start_test') + self._end_test = self._get_method(listener, 'end_test') + self._log_message = self._get_method(listener, 'log_message') + self._message = self._get_method(listener, 'message') + self._close = self._get_method(listener, 'close') def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - method = self._get_method(self.listener, 'start_suite') - method(data, result) + self._start_suite(data, result) def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - method = self._get_method(self.listener, 'end_suite') - method(data, result) + self._end_suite(data, result) def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - method = self._get_method(self.listener, 'start_test') - method(data, result) + self._start_test(data, result) def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - method = self._get_method(self.listener, 'end_test') - method(data, result) + self._end_test(data, result) def log_message(self, message: 'model.Message'): - method = self._get_method(self.listener, 'log_message') - method(message) + self._log_message(message) def message(self, message: 'model.Message'): - method = self._get_method(self.listener, 'message') - method(message) + self._message(message) def output_file(self, type_: str, path: str): method = self._get_method(self.listener, '%s_file' % type_.lower()) method(path) def close(self): - method = self._get_method(self.listener, 'close') - method() + self._close() def _get_method(self, listener, name, prefix=''): for method_name in self._get_method_names(name, prefix): if hasattr(listener, method_name): return ListenerMethod(getattr(listener, method_name), self.name, self.version) - return self._no_method + return ListenerMethod(None, self.name, self.version) def _get_method_names(self, name, prefix): names = [name, self._toCamelCase(name)] if '_' in name else [name] @@ -230,139 +229,121 @@ def _toCamelCase(self, name): class ListenerV2Facade(ListenerFacade): + def __init__(self, listener, name, version): + super().__init__(listener, name, version) + self._start_keyword = self._get_method(listener, 'start_keyword') + self._end_keyword = self._get_method(listener, 'end_keyword') + self._start_for = self._start_for_iteration = self._start_while = \ + self._start_while_iteration = self._start_if_branch = \ + self._start_try_branch = self._start_return = self._start_continue = \ + self._start_break = self._start_var = self._start_error = self._start_keyword + self._end_for = self._end_for_iteration = self._end_while = self._end_while_iteration =\ + self._end_if_branch = self._end_try_branch = self._end_return = self._end_continue =\ + self._end_break = self._end_var = self._end_error = self._end_keyword + def imported(self, import_type: str, name: str, attrs): method = self._get_method(self.listener, '%s_import' % import_type.lower()) method(name, attrs) def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - method = self._get_method(self.listener, 'start_suite') - method(result.name, self._suite_v2_attributes(data, result)) + self._start_suite(result.name, self._suite_v2_attributes(data, result)) def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - method = self._get_method(self.listener, 'end_suite') - method(result.name, self._suite_v2_attributes(data, result, is_end=True)) + self._end_suite(result.name, self._suite_v2_attributes(data, result, is_end=True)) def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - method = self._get_method(self.listener, 'start_test') - method(result.name, self._test_v2_attributes(data, result)) + self._start_test(result.name, self._test_v2_attributes(data, result)) def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - method = self._get_method(self.listener, 'end_test') - method(result.name, self._test_v2_attributes(data, result, is_end=True)) + self._end_test(result.name, self._test_v2_attributes(data, result, is_end=True)) def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - method = self._get_method(self.listener, 'start_keyword') - method(result.full_name, - self._body_item_v2_attributes(data, result, is_keyword_like=True)) + attrs = self._body_item_v2_attributes(data, result, is_keyword_like=True) + self._start_keyword(result.full_name, attrs) def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - method = self._get_method(self.listener, 'end_keyword') - method(result.full_name, - self._body_item_v2_attributes(data, result, is_keyword_like=True, is_end=True)) + attrs = self._body_item_v2_attributes(data, result, is_keyword_like=True, is_end=True) + self._end_keyword(result.full_name, attrs) def start_for(self, data: 'running.For', result: 'result.For'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._for_v2_attributes(data, result)) + self._start_for(result._log_name, self._for_v2_attributes(data, result)) def end_for(self, data: 'running.For', result: 'result.For'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._for_v2_attributes(data, result, is_end=True)) + self._end_for(result._log_name, self._for_v2_attributes(data, result, is_end=True)) def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): attrs = self._body_item_v2_attributes(data, result) attrs['variables'] = dict(result.assign) - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, attrs) + self._start_for_iteration(result._log_name, attrs) def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): attrs = self._body_item_v2_attributes(data, result, is_end=True) attrs['variables'] = dict(result.assign) - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, attrs) + self._end_for_iteration(result._log_name, attrs) def start_while(self, data: 'running.While', result: 'result.While'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._while_v2_attributes(data, result)) + self._start_while(result._log_name, self._while_v2_attributes(data, result)) def end_while(self, data: 'running.While', result: 'result.While'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._while_v2_attributes(data, result, is_end=True)) + self._end_while(result._log_name, self._while_v2_attributes(data, result, is_end=True)) def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_while_iteration(result._log_name, self._body_item_v2_attributes(data, result)) def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_while_iteration(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) def start_if_branch(self, data: 'running.If', result: 'result.IfBranch'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._if_v2_attributes(data, result)) + self._start_if_branch(result._log_name, self._if_v2_attributes(data, result)) def end_if_branch(self, data: 'running.If', result: 'result.IfBranch'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._if_v2_attributes(data, result, is_end=True)) + self._end_if_branch(result._log_name, self._if_v2_attributes(data, result, is_end=True)) def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._try_v2_attributes(data, result)) + self._start_try_branch(result._log_name, self._try_v2_attributes(data, result)) def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._try_v2_attributes(data, result, is_end=True)) + self._end_try_branch(result._log_name, self._try_v2_attributes(data, result, is_end=True)) def start_return(self, data: 'running.Return', result: 'result.Return'): attrs = self._body_item_v2_attributes(data, result) attrs['values'] = list(result.values) - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, attrs) + self._start_return(result._log_name, attrs) def end_return(self, data: 'running.Return', result: 'result.Return'): attrs = self._body_item_v2_attributes(data, result, is_end=True) attrs['values'] = list(result.values) - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, attrs) + self._end_return(result._log_name, attrs) def start_continue(self, data: 'running.Continue', result: 'result.Continue'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_continue(result._log_name, self._body_item_v2_attributes(data, result)) def end_continue(self, data: 'running.Continue', result: 'result.Continue'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_continue(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) def start_break(self, data: 'running.Break', result: 'result.Break'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_break(result._log_name, self._body_item_v2_attributes(data, result)) def end_break(self, data: 'running.Break', result: 'result.Break'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_break(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) def start_error(self, data: 'running.Error', result: 'result.Error'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_error(result._log_name, self._body_item_v2_attributes(data, result)) def end_error(self, data: 'running.Error', result: 'result.Error'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_error(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) def start_var(self, data: 'running.Var', result: 'result.Var'): - method = self._get_method(self.listener, 'start_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_var(result._log_name, self._body_item_v2_attributes(data, result)) def end_var(self, data: 'running.Var', result: 'result.Var'): - method = self._get_method(self.listener, 'end_keyword') - method(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_var(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) def log_message(self, message: 'model.Message'): - method = self._get_method(self.listener, 'log_message') - method(self._message_attributes(message)) + self._log_message(self._message_attributes(message)) def message(self, message: 'model.Message'): - method = self._get_method(self.listener, 'message') - method(self._message_attributes(message)) + self._message(self._message_attributes(message)) def _suite_v2_attributes(self, data, result, is_end=False): attrs = { @@ -493,6 +474,7 @@ def _message_attributes(self, msg): 'html': 'yes' if msg.html else 'no'} return attrs + def import_listener(listener): if not is_string(listener): # Modules have `__name__`, with others better to use `type_name`. @@ -551,6 +533,8 @@ def __init__(self, method, name, version, library=None): self.library = library def __call__(self, *args): + if self.method is None: + return if self.called: return try: From e4b425c9e385ce370e7892cd798d6a8fba27a6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Thu, 26 Oct 2023 20:47:59 +0300 Subject: [PATCH 0815/1592] implement library listeners based on LoggerApi --- src/robot/output/listeners.py | 74 ++++++++++++++++++++++++++++------ src/robot/output/output.py | 3 +- utest/output/test_listeners.py | 53 ++++++++++-------------- 3 files changed, 85 insertions(+), 45 deletions(-) diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index 8e05c3cc40c..bd068cd9a07 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -171,12 +171,44 @@ def __bool__(self): return len(self.listeners) > 0 +class LibraryListeners(Listeners): + + def __init__(self, log_level='INFO'): + self._is_logged = IsLogged(log_level) + self._listener_stack = [] + + @property + def listeners(self): + return self._listener_stack[-1] if self._listener_stack else [] + + def new_suite_scope(self): + self._listener_stack.append([]) + + def discard_suite_scope(self): + self._listener_stack.pop() + + def register(self, listeners, library): + listeners = import_listeners(listeners, library=library) + self._listener_stack[-1].extend(listeners) + + def close(self): + pass + + def unregister(self, library, close=False): + if close: + for listener in [li for li in self.listeners if li.library is library]: + listener.close() + listeners = [listener for listener in self._listener_stack[-1] if listener.library is not library] + self._listener_stack[-1] = listeners + + class ListenerFacade(LoggerApi): - def __init__(self, listener, name, version): + def __init__(self, listener, name, version, prefix=''): self.listener = listener self.name = name self.version = version + self.prefix = prefix self._start_suite = self._get_method(listener, 'start_suite') self._end_suite = self._get_method(listener, 'end_suite') self._start_test = self._get_method(listener, 'start_test') @@ -210,8 +242,8 @@ def output_file(self, type_: str, path: str): def close(self): self._close() - def _get_method(self, listener, name, prefix=''): - for method_name in self._get_method_names(name, prefix): + def _get_method(self, listener, name): + for method_name in self._get_method_names(name, self.prefix): if hasattr(listener, method_name): return ListenerMethod(getattr(listener, method_name), self.name, self.version) return ListenerMethod(None, self.name, self.version) @@ -229,8 +261,8 @@ def _toCamelCase(self, name): class ListenerV2Facade(ListenerFacade): - def __init__(self, listener, name, version): - super().__init__(listener, name, version) + def __init__(self, listener, name, version, prefix=''): + super().__init__(listener, name, version, prefix) self._start_keyword = self._get_method(listener, 'start_keyword') self._end_keyword = self._get_method(listener, 'end_keyword') self._start_for = self._start_for_iteration = self._start_while = \ @@ -475,6 +507,20 @@ def _message_attributes(self, msg): return attrs +class LibraryListenerFacade(ListenerFacade): + + def __init__(self, listener, name, version, library, prefix='_'): + super().__init__(listener, name, version, prefix) + self.library = library + + +class LibraryListenerV2Facade(ListenerV2Facade): + + def __init__(self, listener, name, version, library, prefix='_'): + super().__init__(listener, name, version, prefix) + self.library = library + + def import_listener(listener): if not is_string(listener): # Modules have `__name__`, with others better to use `type_name`. @@ -502,21 +548,26 @@ def get_version(listener, name): return version -def import_listeners(listeners, - raise_on_error=False): +def import_listeners(listeners, library=None): all = [] for listener in listeners: try: imported, name = import_listener(listener) version = get_version(imported, name) if version == 2: - all.append(ListenerV2Facade(imported, name, version)) + if library: + all.append(LibraryListenerV2Facade(imported, name, version, library, prefix='_')) + else: + all.append(ListenerV2Facade(imported, name, version)) else: - all.append(ListenerFacade(imported, name, version)) + if library: + all.append(LibraryListenerFacade(imported, name, version, library, prefix='_')) + else: + all.append(ListenerFacade(imported, name, version)) except DataError as err: name = listener if is_string(listener) else type_name(listener) msg = "Taking listener '%s' into use failed: %s" % (name, err) - if raise_on_error: + if library: raise DataError(msg) LOGGER.error(msg) return all @@ -526,11 +577,10 @@ class ListenerMethod: # Flag to avoid recursive listener calls. called = False - def __init__(self, method, name, version, library=None): + def __init__(self, method, name, version): self.method = method self.listener_name = name self.version = version - self.library = library def __call__(self, *args): if self.method is None: diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 60f08ad4703..8675ac3b8b7 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -15,11 +15,10 @@ from . import pyloggingconf from .debugfile import DebugFile -from .listeners import Listeners +from .listeners import Listeners, LibraryListeners from .logger import LOGGER from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger -from .old_listener import LibraryListeners from .xmllogger import XmlLoggerAdapter diff --git a/utest/output/test_listeners.py b/utest/output/test_listeners.py index 7af5cb0c263..b1f253dd8e9 100644 --- a/utest/output/test_listeners.py +++ b/utest/output/test_listeners.py @@ -22,11 +22,12 @@ def __getattr__(self, name): class SuiteMock(Mock): - def __init__(self): + def __init__(self, is_result=False): self.name = 'suitemock' - self.doc = 'somedoc' - self.status = 'PASS' self.tests = self.suites = [] + if is_result: + self.doc = 'somedoc' + self.status = 'PASS' stat_message = 'stat message' full_message = 'full message' @@ -34,23 +35,25 @@ def __init__(self): class TestMock(Mock): - def __init__(self): + def __init__(self, is_result=False): self.name = 'testmock' - self.doc = 'cod' - self.tags = ['foo', 'bar'] - self.message = 'Expected failure' - self.status = 'FAIL' self.data = DotDict({'name':self.name}) + if is_result: + self.doc = 'cod' + self.tags = ['foo', 'bar'] + self.message = 'Expected failure' + self.status = 'FAIL' class KwMock(Mock, BodyItem): non_existing = ('branch_status',) - def __init__(self): + def __init__(self, is_result=False): self.full_name = self.name = 'kwmock' - self.args = ['a1', 'a2'] - self.status = 'PASS' - self.type = BodyItem.KEYWORD + if is_result: + self.args = ['a1', 'a2'] + self.status = 'PASS' + self.type = BodyItem.KEYWORD class ListenOutputs: @@ -113,27 +116,27 @@ def setUp(self): self.capturer = OutputCapturer() def test_start_suite(self): - self.listeners.start_suite(SuiteMock()) + self.listeners.start_suite(SuiteMock(), SuiteMock(is_result=True)) self._assert_output("SUITE START: suitemock 'somedoc'") def test_start_test(self): - self.listeners.start_test(TestMock()) + self.listeners.start_test(TestMock(), TestMock(is_result=True)) self._assert_output("TEST START: testmock 'cod' foo, bar") def test_start_keyword(self): - self.listeners.start_keyword(KwMock()) + self.listeners.start_keyword(KwMock(), KwMock(is_result=True)) self._assert_output("KW START: kwmock ['a1', 'a2']") def test_end_keyword(self): - self.listeners.end_keyword(KwMock()) + self.listeners.end_keyword(KwMock(), KwMock(is_result=True)) self._assert_output("KW END: PASS") def test_end_test(self): - self.listeners.end_test(TestMock()) + self.listeners.end_test(TestMock(), TestMock(is_result=True)) self._assert_output('TEST END: FAIL Expected failure') def test_end_suite(self): - self.listeners.end_suite(SuiteMock()) + self.listeners.end_suite(SuiteMock(), SuiteMock(is_result=True)) self._assert_output('SUITE END: PASS ' + self.stat_message) def test_output_file(self): @@ -178,7 +181,7 @@ class ModelStub: if name.startswith(('start_', 'end_')): model = ModelStub() if name.endswith('keyword') else None method = getattr(listeners, name) - method(model) + method(model, model) def test_message_methods(self): class Message: @@ -187,18 +190,6 @@ class Message: listeners.log_message(Message) listeners.message(Message) - def test_some_methods_implemented(self): - class MyListener: - ROBOT_LISTENER_API_VERSION = 2 - def end_suite(self, suite): - pass - libs = LibraryListeners() - libs.new_suite_scope() - libs.register([MyListener()], None) - for listeners in [Listeners([MyListener()]), libs]: - listeners.start_suite(None) - assert_raises(AttributeError, listeners.end_suite, None) - if __name__ == '__main__': unittest.main() From 71ed46025061eb08f9ce516bf5b59eb241e34f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Thu, 26 Oct 2023 20:48:35 +0300 Subject: [PATCH 0816/1592] listeners: remove dead code --- src/robot/output/listenerarguments.py | 191 ----------- src/robot/output/listenermethods.py | 448 -------------------------- src/robot/output/loggerhelper.py | 33 -- src/robot/output/old_listener.py | 135 -------- 4 files changed, 807 deletions(-) delete mode 100644 src/robot/output/listenerarguments.py delete mode 100644 src/robot/output/listenermethods.py delete mode 100644 src/robot/output/old_listener.py diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py deleted file mode 100644 index 954dc8824d4..00000000000 --- a/src/robot/output/listenerarguments.py +++ /dev/null @@ -1,191 +0,0 @@ -# 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.model import BodyItem -from robot.utils import is_list_like, is_dict_like, is_string, safe_str - - -class ListenerArguments: - - def __init__(self, arguments): - self._arguments = arguments - self._version2 = None - self._version3 = None - - def get_arguments(self, version): - if version == 2: - if self._version2 is None: - self._version2 = self._get_version2_arguments(*self._arguments) - return self._version2 - else: - if self._version3 is None: - self._version3 = self._get_version3_arguments(*self._arguments) - return self._version3 - - def _get_version2_arguments(self, *arguments): - return arguments - - def _get_version3_arguments(self, *arguments): - return arguments - - @classmethod - def by_method_name(cls, name, arguments): - Arguments = {'start_suite': StartSuiteArguments, - 'end_suite': EndSuiteArguments, - 'start_test': StartTestArguments, - 'end_test': EndTestArguments, - 'start_keyword': StartKeywordArguments, - 'end_keyword': EndKeywordArguments, - 'log_message': MessageArguments, - 'message': MessageArguments}.get(name, ListenerArguments) - return Arguments(arguments) - - -class MessageArguments(ListenerArguments): - - def _get_version2_arguments(self, msg): - # Timestamp in our legacy format. - timestamp = msg.timestamp.isoformat(' ', timespec='milliseconds').replace('-', '') - attributes = {'timestamp': timestamp, - 'message': msg.message, - 'level': msg.level, - 'html': 'yes' if msg.html else 'no'} - return attributes, - - def _get_version3_arguments(self, msg): - return msg, - - -class _ListenerArgumentsFromItem(ListenerArguments): - _attribute_names = None - - def _get_version2_arguments(self, item): - attributes = dict((name, self._get_attribute_value(item, name)) - for name in self._attribute_names) - attributes.update(self._get_extra_attributes(item)) - return self._get_name(item) or '', attributes - - def _get_name(self, item): - return item.name - - def _get_attribute_value(self, item, name): - value = getattr(item, name) - if value is None: - return '' - return self._take_copy_of_mutable_value(value) - - def _take_copy_of_mutable_value(self, value): - if is_dict_like(value): - return dict(value) - if is_list_like(value): - return list(value) - return value - - def _get_extra_attributes(self, item): - return {} - - def _get_version3_arguments(self, item): - return item.data, item.result - - -class StartSuiteArguments(_ListenerArgumentsFromItem): - _attribute_names = ('id', 'doc', 'metadata', 'starttime') - - def _get_extra_attributes(self, suite): - return {'longname': suite.full_name, - 'tests': [t.name for t in suite.tests], - 'suites': [s.name for s in suite.suites], - 'totaltests': suite.test_count, - 'source': str(suite.source or '')} - - -class EndSuiteArguments(StartSuiteArguments): - _attribute_names = ('id', 'doc', 'metadata', 'starttime', - 'endtime', 'elapsedtime', 'status', 'message') - - def _get_extra_attributes(self, suite): - attrs = super()._get_extra_attributes(suite) - attrs['statistics'] = suite.stat_message - return attrs - - -class StartTestArguments(_ListenerArgumentsFromItem): - _attribute_names = ('id', 'doc', 'tags', 'lineno', 'starttime') - - def _get_extra_attributes(self, test): - return {'longname': test.full_name, - 'source': str(test.source or ''), - 'template': test.template or '', - 'originalname': test.data.name} - - -class EndTestArguments(StartTestArguments): - _attribute_names = ('id', 'doc', 'tags', 'lineno', 'starttime', - 'endtime', 'elapsedtime', 'status', 'message') - - -class StartKeywordArguments(_ListenerArgumentsFromItem): - _attribute_names = ('doc', 'tags', 'lineno', 'type', 'status', 'starttime') - _type_attributes = { - BodyItem.FOR: ('variables', 'flavor', 'values'), - BodyItem.IF: ('condition',), - BodyItem.ELSE_IF: ('condition',), - BodyItem.EXCEPT: ('patterns', 'pattern_type', 'variable'), - BodyItem.WHILE: ('condition', 'limit', 'on_limit_message'), # FIXME: Add 'on_limit' - BodyItem.RETURN: ('values',), - } - _for_flavor_attributes = { - 'IN ENUMERATE': ('start',), - 'IN ZIP': ('mode', 'fill') - } - - def _get_name(self, kw): - return kw.full_name if kw.type in kw.KEYWORD_TYPES else kw._log_name - - def _get_extra_attributes(self, kw): - # backwards compatibility reasons we pass them as `variable(s)`. - if kw.type in kw.KEYWORD_TYPES: - assign = list(kw.assign) - name = kw.name or '' - owner = kw.owner or '' - args = [a if is_string(a) else safe_str(a) for a in kw.args] - else: - assign = [] - name = kw._log_name - owner = '' - args = [] - attrs = {'kwname': name, - 'libname': owner, - 'args': args, - 'assign': assign, - 'source': str(kw.source or '')} - if kw.type in self._type_attributes: - for name in self._type_attributes[kw.type]: - # FOR and TRY model objects use `assign` instead of `variable(s)` - # starting from RF 7.0, but we want to use old names with listeners. - model_name = name if name not in ('variables', 'variable') else 'assign' - if hasattr(kw, model_name): - attrs[name] = self._get_attribute_value(kw, model_name) - elif kw.type == BodyItem.ITERATION and kw.parent.type == BodyItem.FOR: - attrs['variables'] = dict(kw.assign) - if kw.type == BodyItem.FOR: - for name in self._for_flavor_attributes.get(kw.flavor, ()): - attrs[name] = self._get_attribute_value(kw, name) - return attrs - - -class EndKeywordArguments(StartKeywordArguments): - _attribute_names = ('doc', 'assign', 'tags', 'lineno', 'type', 'status', - 'starttime', 'endtime', 'elapsedtime') diff --git a/src/robot/output/listenermethods.py b/src/robot/output/listenermethods.py deleted file mode 100644 index bc12ce4a800..00000000000 --- a/src/robot/output/listenermethods.py +++ /dev/null @@ -1,448 +0,0 @@ -# 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.errors import TimeoutError -from robot.model import BodyItem -from robot.utils import get_error_details, is_string, safe_str - -from .listenerarguments import ListenerArguments -from .logger import LOGGER - - -class ListenerMethods: - - def __init__(self, method_name, listeners): - self._methods = [] - self._method_name = method_name - if listeners: - self._register_methods(method_name, listeners) - - def _register_methods(self, method_name, listeners): - for listener in listeners: - method = getattr(listener, method_name) - if method: - self._methods.append(ListenerMethod(method, listener)) - - def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._suite_v2_attributes(data, result)) - - def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._suite_v2_attributes(data, result, is_end=True)) - - def _suite_v2_attributes(self, data, result, is_end=False): - attrs = { - 'id': data.id, - 'doc': result.doc, - 'metadata': dict(result.metadata), - 'starttime': result.starttime, - 'longname': result.full_name, - 'tests': [t.name for t in data.tests], - 'suites': [s.name for s in data.suites], - 'totaltests': data.test_count, - 'source': str(data.source or '') - } - if is_end: - attrs.update({ - 'endtime': result.endtime, - 'elapsedtime': result.elapsedtime, - 'status': result.status, - 'message': result.message, - 'statistics': result.stat_message - }) - return result.name, attrs - - def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._test_v2_attributes(data, result)) - - def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._test_v2_attributes(data, result, is_end=True)) - - def _test_v2_attributes(self, data: 'running.TestCase', result: 'result.TestCase', is_end=False): - attrs = { - 'id': data.id, - 'doc': result.doc, - 'tags': list(result.tags), - 'lineno': data.lineno, - 'starttime': result.starttime, - 'longname': result.full_name, - 'source': str(data.source or ''), - 'template': data.template or '', - 'originalname': data.name - } - if is_end: - attrs.update({ - 'endtime': result.endtime, - 'elapsedtime': result.elapsedtime, - 'status': result.status, - 'message': result.message, - }) - return result.name, attrs - - def start_keyword(self, data, result): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._kw_v2_attributes(data, result, is_end=False)) - - def end_keyword(self, data, result): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._kw_v2_attributes(data, result, is_end=True)) - - def start_for(self, data: 'running.For', result: 'result.For'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._for_v2_attributes(data, result, is_end=False)) - - def end_for(self, data: 'running.For', result: 'result.For'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._for_v2_attributes(data, result, is_end=True)) - - def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) - attrs['variables'] = dict(result.assign) - method((result._log_name, attrs)) - - def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) - attrs['variables'] = dict(result.assign) - method((result._log_name, attrs)) - - def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) - method((result._log_name, attrs)) - - def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) - method((result._log_name, attrs)) - - def start_while(self, data: 'running.While', result: 'result.While'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._while_v2_attributes(data, result, is_end=False)) - - def end_while(self, data: 'running.While', result: 'result.While'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._while_v2_attributes(data, result, is_end=True)) - - def start_if_branch(self, data: 'running.If', result: 'result.If'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._if_v2_attributes(data, result, is_end=False)) - - def end_if_branch(self, data: 'running.If', result: 'result.If'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._if_v2_attributes(data, result, is_end=True)) - - def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._try_v2_attributes(data, result, is_end=False)) - - def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._try_v2_attributes(data, result, is_end=True)) - - def start_return(self, data: 'running.Return', result: 'result.Return'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._return_v2_attributes(data, result, is_end=False)) - - def end_return(self, data: 'running.Return', result: 'result.Return'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - method(self._return_v2_attributes(data, result, is_end=True)) - - def start_continue(self, data: 'running.Continue', result: 'result.Continue'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) - method((result._log_name, attrs)) - - def end_continue(self, data: 'running.Continue', result: 'result.Continue'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) - method((result._log_name, attrs)) - - def start_break(self, data: 'running.Break', result: 'result.Break'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) - method((result._log_name, attrs)) - - def end_break(self, data: 'running.Break', result: 'result.Break'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) - method((result._log_name, attrs)) - - def start_error(self, data: 'running.Error', result: 'result.Error'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) - method((result._log_name, attrs)) - - def end_error(self, data: 'running.Error', result: 'result.Error'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) - method((result._log_name, attrs)) - - def start_var(self, data: 'running.Var', result: 'result.Var'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=False) - method((result._log_name, attrs)) - - def end_var(self, data: 'running.Var', result: 'result.Var'): - for method in self._methods: - if method.version == 3: - method((data, result)) - else: - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=True) - method((result._log_name, attrs)) - - def _common_v2_attributes(self, data, result, is_keyword_like, is_end): - attrs = { - 'doc': result.doc, - 'lineno': data.lineno, - 'type': result.type, - 'status': result.status, - 'starttime': result.starttime, - 'source': str(data.source or '') - } - if is_keyword_like: - attrs.update({ - 'kwname': result.name or '', - 'libname': result.owner or '', - 'args': [a if is_string(a) else safe_str(a) for a in result.args], - 'assign': list(result.assign), - 'tags': list(result.tags), - }) - else: - attrs.update({ - 'kwname': result._log_name, - 'libname': '', - 'args': [], - 'assign': [], - 'tags': [] - }) - if is_end: - attrs.update({ - 'endtime': result.endtime, - 'elapsedtime': result.elapsedtime - }) - return attrs - - def _kw_v2_attributes(self, data, result, is_end): - attrs = self._common_v2_attributes(data, result, is_keyword_like=True, is_end=is_end) - return result.full_name, attrs - - def _for_v2_attributes(self, data: 'running.For', result: 'result.For', is_end): - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) - attrs.update({ - 'variables': list(result.assign), - 'flavor': result.flavor or '', - 'values': list(result.values) - }) - if result.flavor == 'IN ENUMERATE': - attrs['start'] = result.start - elif result.flavor == 'IN ZIP': - attrs['fill'] = result.fill - attrs['mode'] = result.mode - return result._log_name, attrs - - def _while_v2_attributes(self, data: 'running.While', result: 'result.While', is_end=False): - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) - attrs.update({ - 'condition': result.condition, - 'limit': result.limit, - 'on_limit_message': result.on_limit_message - # FIXME: Add 'on_limit' - }) - return result._log_name, attrs - - def _if_v2_attributes(self, data: 'running.If', result: 'result.If', is_end=False): - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) - if result.type in (BodyItem.IF, BodyItem.ELSE_IF): - attrs['condition'] = result.condition - return result._log_name, attrs - - def _try_v2_attributes(self, data: 'running.Try', result: 'result.TryBranch', is_end=False): - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) - if result.type == BodyItem.EXCEPT: - attrs.update({ - 'patterns': list(result.patterns), - 'pattern_type': result.pattern_type, - 'variable': result.assign - }) - return result._log_name, attrs - - def _return_v2_attributes(self, data: 'running.Return', result: 'result.Return', is_end=False): - attrs = self._common_v2_attributes(data, result, is_keyword_like=False, is_end=is_end) - attrs['values'] = list(result.values) - return result._log_name, attrs - - def __call__(self, *args): - if self._methods: - args = ListenerArguments.by_method_name(self._method_name, args) - for method in self._methods: - method(args.get_arguments(method.version)) - - def __bool__(self): - return bool(self._methods) - - -class LibraryListenerMethods: - - def __init__(self, method_name): - self._method_stack = [] - self._method_name = method_name - - def new_suite_scope(self): - self._method_stack.append([]) - - def discard_suite_scope(self): - self._method_stack.pop() - - def register(self, listeners, library): - methods = self._method_stack[-1] - for listener in listeners: - method = getattr(listener, self._method_name) - if method: - info = ListenerMethod(method, listener, library) - methods.append(info) - - def unregister(self, library): - methods = [m for m in self._method_stack[-1] if m.library is not library] - self._method_stack[-1] = methods - - def __call__(self, *args, **conf): - methods = self._get_methods(**conf) - if methods: - args = ListenerArguments.by_method_name(self._method_name, args) - for method in methods: - method(args.get_arguments(method.version)) - - def _get_methods(self, library=None): - if not (self._method_stack and self._method_stack[-1]): - return [] - methods = self._method_stack[-1] - if library: - return [m for m in methods if m.library is library] - return methods - - -class ListenerMethod: - # Flag to avoid recursive listener calls. - called = False - - def __init__(self, method, listener, library=None): - self.method = method - self.listener_name = listener.name - self.version = listener.version - self.library = library - - def __call__(self, args): - if self.called: - return - try: - ListenerMethod.called = True - self.method(*args) - except TimeoutError: - # Propagate possible timeouts: - # https://github.com/robotframework/robotframework/issues/2763 - raise - except: - message, details = get_error_details() - LOGGER.error("Calling method '%s' of listener '%s' failed: %s" - % (self.method.__name__, self.listener_name, message)) - LOGGER.info("Details:\n%s" % details) - finally: - ListenerMethod.called = False diff --git a/src/robot/output/loggerhelper.py b/src/robot/output/loggerhelper.py index 7851e7e22a3..98d82032fd6 100644 --- a/src/robot/output/loggerhelper.py +++ b/src/robot/output/loggerhelper.py @@ -146,36 +146,3 @@ def _level_to_int(self, level): return LEVELS[level.upper()] except KeyError: raise DataError("Invalid log level '%s'." % level) - - -class AbstractLoggerProxy: - _methods = None - _no_method = lambda *args: None - - def __init__(self, logger, method_names=None, prefix=None): - self.logger = logger - for name in method_names or self._methods: - # Allow extending classes to implement some of the messages themselves. - if hasattr(self, name): - if hasattr(logger, name): - continue - target = logger - else: - target = self - setattr(target, name, self._get_method(logger, name, prefix)) - - def _get_method(self, logger, name, prefix): - for method_name in self._get_method_names(name, prefix): - if hasattr(logger, method_name): - return getattr(logger, method_name) - return self._no_method - - def _get_method_names(self, name, prefix): - names = [name, self._toCamelCase(name)] if '_' in name else [name] - if prefix: - names += [prefix + name for name in names] - return names - - def _toCamelCase(self, name): - parts = name.split('_') - return ''.join([parts[0]] + [part.capitalize() for part in parts[1:]]) diff --git a/src/robot/output/old_listener.py b/src/robot/output/old_listener.py deleted file mode 100644 index d99278e6746..00000000000 --- a/src/robot/output/old_listener.py +++ /dev/null @@ -1,135 +0,0 @@ -import os.path - -from robot.errors import DataError -from robot.utils import Importer, is_string, split_args_from_name_or_path, type_name - -from .listenermethods import LibraryListenerMethods -from .loggerapi import LoggerApi -from .loggerhelper import AbstractLoggerProxy, IsLogged -from .logger import LOGGER -from .modelcombiner import ModelCombiner - - -class LibraryListeners(LoggerApi): - _method_names = ('start_suite', 'end_suite', 'start_test', 'end_test', - 'start_keyword', 'end_keyword', 'log_message', 'message', - 'close') - _methods = {} - - def __init__(self, log_level='INFO'): - self._is_logged = IsLogged(log_level) - for name in self._method_names: - method = LibraryListenerMethods(name) - self._methods[name] = method - - def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._methods['start_suite'](ModelCombiner(data, result, - tests=data.tests, - suites=data.suites, - test_count=data.test_count)) - - def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._methods['end_suite'](ModelCombiner(data, result)) - - def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._methods['start_test'](ModelCombiner(data, result)) - - def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._methods['end_test'](ModelCombiner(data, result)) - - def start_body_item(self, data, result): - if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): - self._methods['start_keyword'](ModelCombiner(data, result)) - - def end_body_item(self, data, result): - if data.type not in (data.IF_ELSE_ROOT, data.TRY_EXCEPT_ROOT): - self._methods['end_keyword'](ModelCombiner(data, result)) - - def register(self, listeners, library): - listeners = ListenerProxy.import_listeners(listeners, - self._method_names, - prefix='_', - raise_on_error=True) - for method in self._listener_methods(): - method.register(listeners, library) - - def _listener_methods(self): - return [method for method in self._methods.values() - if isinstance(method, LibraryListenerMethods)] - - def unregister(self, library, close=False): - if close and self._methods.get('close'): - self._methods['close'](library=library) - for method in self._listener_methods(): - method.unregister(library) - - def new_suite_scope(self): - for method in self._listener_methods(): - method.new_suite_scope() - - def discard_suite_scope(self): - for method in self._listener_methods(): - method.discard_suite_scope() - - def set_log_level(self, level): - self._is_logged.set_level(level) - - def log_message(self, message: 'model.Message'): - if self._is_logged(message.level): - self._methods['log_message'](message) - - def message(self, message: 'model.Message'): - self._methods['message'](message) - - -class ListenerProxy(AbstractLoggerProxy): - _no_method = None - - def __init__(self, listener, method_names, prefix=None): - listener, name = self._import_listener(listener) - AbstractLoggerProxy.__init__(self, listener, method_names, prefix) - self.name = name - self.version = self._get_version(listener) - if self.version == 3: - self.start_keyword = self.end_keyword = None - self.library_import = self.resource_import = self.variables_import = None - - def _import_listener(self, listener): - if not is_string(listener): - # Modules have `__name__`, with others better to use `type_name`. - name = getattr(listener, '__name__', None) or type_name(listener) - return listener, name - name, args = split_args_from_name_or_path(listener) - importer = Importer('listener', logger=LOGGER) - listener = importer.import_class_or_module(os.path.normpath(name), - instantiate_with_args=args) - return listener, name - - def _get_version(self, listener): - try: - version = int(listener.ROBOT_LISTENER_API_VERSION) - if version not in (2, 3): - raise ValueError - except AttributeError: - raise DataError("Listener '%s' does not have mandatory " - "'ROBOT_LISTENER_API_VERSION' attribute." - % self.name) - except (ValueError, TypeError): - raise DataError("Listener '%s' uses unsupported API version '%s'." - % (self.name, listener.ROBOT_LISTENER_API_VERSION)) - return version - - @classmethod - def import_listeners(cls, listeners, method_names, prefix=None, - raise_on_error=False): - imported = [] - for listener in listeners: - try: - imported.append(cls(listener, method_names, prefix)) - except DataError as err: - name = listener if is_string(listener) else type_name(listener) - msg = "Taking listener '%s' into use failed: %s" % (name, err) - if raise_on_error: - raise DataError(msg) - LOGGER.error(msg) - return imported From e765eaa9420d726c7d48a1cd03e823b7b523dc11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Thu, 26 Oct 2023 21:59:18 +0300 Subject: [PATCH 0817/1592] listeners: cleanup --- src/robot/output/listeners.py | 50 ++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index bd068cd9a07..f65842ae764 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -29,7 +29,11 @@ class Listeners(LoggerApi): def __init__(self, listeners, log_level='INFO'): self._is_logged = IsLogged(log_level) - self.listeners = import_listeners(listeners) + self._listeners = import_listeners(listeners) if listeners else [] + + @property + def listeners(self): + return self._listeners def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): for listener in self.listeners: @@ -174,7 +178,7 @@ def __bool__(self): class LibraryListeners(Listeners): def __init__(self, log_level='INFO'): - self._is_logged = IsLogged(log_level) + super().__init__([], log_level) self._listener_stack = [] @property @@ -204,10 +208,9 @@ def unregister(self, library, close=False): class ListenerFacade(LoggerApi): - def __init__(self, listener, name, version, prefix=''): + def __init__(self, listener, name, prefix=''): self.listener = listener self.name = name - self.version = version self.prefix = prefix self._start_suite = self._get_method(listener, 'start_suite') self._end_suite = self._get_method(listener, 'end_suite') @@ -245,8 +248,8 @@ def close(self): def _get_method(self, listener, name): for method_name in self._get_method_names(name, self.prefix): if hasattr(listener, method_name): - return ListenerMethod(getattr(listener, method_name), self.name, self.version) - return ListenerMethod(None, self.name, self.version) + return ListenerMethod(getattr(listener, method_name), self.name) + return ListenerMethod(None, self.name) def _get_method_names(self, name, prefix): names = [name, self._toCamelCase(name)] if '_' in name else [name] @@ -261,8 +264,8 @@ def _toCamelCase(self, name): class ListenerV2Facade(ListenerFacade): - def __init__(self, listener, name, version, prefix=''): - super().__init__(listener, name, version, prefix) + def __init__(self, listener, name, prefix=''): + super().__init__(listener, name, prefix) self._start_keyword = self._get_method(listener, 'start_keyword') self._end_keyword = self._get_method(listener, 'end_keyword') self._start_for = self._start_for_iteration = self._start_while = \ @@ -509,15 +512,15 @@ def _message_attributes(self, msg): class LibraryListenerFacade(ListenerFacade): - def __init__(self, listener, name, version, library, prefix='_'): - super().__init__(listener, name, version, prefix) + def __init__(self, listener, name, library): + super().__init__(listener, name, prefix="_") self.library = library class LibraryListenerV2Facade(ListenerV2Facade): - def __init__(self, listener, name, version, library, prefix='_'): - super().__init__(listener, name, version, prefix) + def __init__(self, listener, name, library): + super().__init__(listener, name, prefix='_') self.library = library @@ -549,38 +552,37 @@ def get_version(listener, name): def import_listeners(listeners, library=None): - all = [] - for listener in listeners: + imported = [] + for listener_source in listeners: try: - imported, name = import_listener(listener) - version = get_version(imported, name) + listener, name = import_listener(listener_source) + version = get_version(listener, name) if version == 2: if library: - all.append(LibraryListenerV2Facade(imported, name, version, library, prefix='_')) + imported.append(LibraryListenerV2Facade(listener, name, library)) else: - all.append(ListenerV2Facade(imported, name, version)) + imported.append(ListenerV2Facade(listener, name)) else: if library: - all.append(LibraryListenerFacade(imported, name, version, library, prefix='_')) + imported.append(LibraryListenerFacade(listener, name, library)) else: - all.append(ListenerFacade(imported, name, version)) + imported.append(ListenerFacade(listener, name)) except DataError as err: - name = listener if is_string(listener) else type_name(listener) + name = listener_source if is_string(listener_source) else type_name(listener_source) msg = "Taking listener '%s' into use failed: %s" % (name, err) if library: raise DataError(msg) LOGGER.error(msg) - return all + return imported class ListenerMethod: # Flag to avoid recursive listener calls. called = False - def __init__(self, method, name, version): + def __init__(self, method, name): self.method = method self.listener_name = name - self.version = version def __call__(self, *args): if self.method is None: From ffda3ae9522475942b2268771d340bd857fa460c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Thu, 26 Oct 2023 22:00:57 +0300 Subject: [PATCH 0818/1592] remove unused modelcombiner --- src/robot/output/modelcombiner.py | 36 ------------------------------- 1 file changed, 36 deletions(-) delete mode 100644 src/robot/output/modelcombiner.py diff --git a/src/robot/output/modelcombiner.py b/src/robot/output/modelcombiner.py deleted file mode 100644 index ab9cb8ad993..00000000000 --- a/src/robot/output/modelcombiner.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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. - -import warnings - - -class ModelCombiner: - __slots__ = ['data', 'result', 'priority'] - - def __init__(self, data, result, **priority): - self.data = data - self.result = result - self.priority = priority - - def __getattr__(self, name): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - if name in self.priority: - return self.priority[name] - if hasattr(self.result, name): - return getattr(self.result, name) - if hasattr(self.data, name): - return getattr(self.data, name) - raise AttributeError(name) From 699cfdbfd315312ebee31228973e22ba8c4753b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20Ha=CC=88rko=CC=88nen?= Date: Wed, 1 Nov 2023 20:27:24 +0200 Subject: [PATCH 0819/1592] listeners: refactoring --- src/robot/output/listeners.py | 230 ++++++++++++++++++---------------- 1 file changed, 119 insertions(+), 111 deletions(-) diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index f65842ae764..5aa2520b752 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -27,10 +27,12 @@ class Listeners(LoggerApi): - def __init__(self, listeners, log_level='INFO'): + def __init__(self, listeners=(), log_level='INFO'): self._is_logged = IsLogged(log_level) self._listeners = import_listeners(listeners) if listeners else [] + # LibraryListeners has a dynamic implementation which requires + # `listeners` to be a property. @property def listeners(self): return self._listeners @@ -172,13 +174,13 @@ def close(self): listener.close() def __bool__(self): - return len(self.listeners) > 0 + return bool(self.listeners) class LibraryListeners(Listeners): def __init__(self, log_level='INFO'): - super().__init__([], log_level) + super().__init__(log_level=log_level) self._listener_stack = [] @property @@ -208,10 +210,10 @@ def unregister(self, library, close=False): class ListenerFacade(LoggerApi): - def __init__(self, listener, name, prefix=''): + def __init__(self, listener, name, allow_leading_underscore=False): self.listener = listener self.name = name - self.prefix = prefix + self.allow_leading_underscore = allow_leading_underscore self._start_suite = self._get_method(listener, 'start_suite') self._end_suite = self._get_method(listener, 'end_suite') self._start_test = self._get_method(listener, 'start_test') @@ -246,15 +248,15 @@ def close(self): self._close() def _get_method(self, listener, name): - for method_name in self._get_method_names(name, self.prefix): + for method_name in self._get_method_names(name): if hasattr(listener, method_name): return ListenerMethod(getattr(listener, method_name), self.name) return ListenerMethod(None, self.name) - def _get_method_names(self, name, prefix): + def _get_method_names(self, name): names = [name, self._toCamelCase(name)] if '_' in name else [name] - if prefix: - names += [prefix + name for name in names] + if self.allow_leading_underscore: + names += ['_' + name for name in names] return names def _toCamelCase(self, name): @@ -264,8 +266,8 @@ def _toCamelCase(self, name): class ListenerV2Facade(ListenerFacade): - def __init__(self, listener, name, prefix=''): - super().__init__(listener, name, prefix) + def __init__(self, listener, name, allow_leading_underscore=False): + super().__init__(listener, name, allow_leading_underscore) self._start_keyword = self._get_method(listener, 'start_keyword') self._end_keyword = self._get_method(listener, 'end_keyword') self._start_for = self._start_for_iteration = self._start_while = \ @@ -281,98 +283,138 @@ def imported(self, import_type: str, name: str, attrs): method(name, attrs) def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._start_suite(result.name, self._suite_v2_attributes(data, result)) + self._start_suite(result.name, self._suite_attributes(data, result)) def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._end_suite(result.name, self._suite_v2_attributes(data, result, is_end=True)) + self._end_suite(result.name, self._suite_attributes(data, result, is_end=True)) def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._start_test(result.name, self._test_v2_attributes(data, result)) + self._start_test(result.name, self._test_attributes(data, result)) def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._end_test(result.name, self._test_v2_attributes(data, result, is_end=True)) + self._end_test(result.name, self._test_attributes(data, result, is_end=True)) def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - attrs = self._body_item_v2_attributes(data, result, is_keyword_like=True) - self._start_keyword(result.full_name, attrs) + self._start_keyword(result.full_name, self._keyword_attributes(data, result)) def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - attrs = self._body_item_v2_attributes(data, result, is_keyword_like=True, is_end=True) - self._end_keyword(result.full_name, attrs) + self._end_keyword(result.full_name, + self._keyword_attributes(data, result, is_end=True)) def start_for(self, data: 'running.For', result: 'result.For'): - self._start_for(result._log_name, self._for_v2_attributes(data, result)) + extra = self._for_extra_attrs(result) + self._start_for(result._log_name, + self._control_attributes(data, result, **extra)) def end_for(self, data: 'running.For', result: 'result.For'): - self._end_for(result._log_name, self._for_v2_attributes(data, result, is_end=True)) + extra = self._for_extra_attrs(result) + self._end_for(result._log_name, + self._control_attributes(data, result, is_end=True, **extra)) + + def _for_extra_attrs(self, result): + extra = { + 'variables': list(result.assign), + 'flavor': result.flavor or '', + 'values': list(result.values) + } + if result.flavor == 'IN ENUMERATE': + extra['start'] = result.start + elif result.flavor == 'IN ZIP': + extra['fill'] = result.fill + extra['mode'] = result.mode + return extra def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - attrs = self._body_item_v2_attributes(data, result) - attrs['variables'] = dict(result.assign) + attrs = self._control_attributes(data, result, variables=dict(result.assign)) self._start_for_iteration(result._log_name, attrs) def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - attrs = self._body_item_v2_attributes(data, result, is_end=True) - attrs['variables'] = dict(result.assign) + attrs = self._control_attributes(data, result, is_end=True, variables=dict(result.assign)) self._end_for_iteration(result._log_name, attrs) def start_while(self, data: 'running.While', result: 'result.While'): - self._start_while(result._log_name, self._while_v2_attributes(data, result)) + # FIXME: Add 'on_limit' + attrs = self._control_attributes(data, result, condition=result.condition, + limit=result.limit, on_limit_message=result.on_limit_message) + self._start_while(result._log_name, attrs) def end_while(self, data: 'running.While', result: 'result.While'): - self._end_while(result._log_name, self._while_v2_attributes(data, result, is_end=True)) + attrs = self._control_attributes(data, result, condition=result.condition, + limit=result.limit, on_limit_message=result.on_limit_message, + is_end=True) + self._end_while(result._log_name, attrs) def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - self._start_while_iteration(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_while_iteration(result._log_name, self._control_attributes(data, result)) def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - self._end_while_iteration(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_while_iteration(result._log_name, + self._control_attributes(data, result, is_end=True)) def start_if_branch(self, data: 'running.If', result: 'result.IfBranch'): - self._start_if_branch(result._log_name, self._if_v2_attributes(data, result)) + extra = {} + if result.type in (BodyItem.IF, BodyItem.ELSE_IF): + extra['condition'] = result.condition + self._start_if_branch(result._log_name, + self._control_attributes(data, result, **extra)) def end_if_branch(self, data: 'running.If', result: 'result.IfBranch'): - self._end_if_branch(result._log_name, self._if_v2_attributes(data, result, is_end=True)) + extra = {} + if result.type in (BodyItem.IF, BodyItem.ELSE_IF): + extra['condition'] = result.condition + self._end_if_branch(result._log_name, + self._control_attributes(data, result, is_end=True, **extra)) def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - self._start_try_branch(result._log_name, self._try_v2_attributes(data, result)) + extra = self._try_extra_attrs(result) + self._start_try_branch(result._log_name, + self._control_attributes(data, result, **extra)) def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): - self._end_try_branch(result._log_name, self._try_v2_attributes(data, result, is_end=True)) + extra = self._try_extra_attrs(result) + self._end_try_branch(result._log_name, + self._control_attributes(data, result, is_end=True, **extra)) + + def _try_extra_attrs(self, result): + if result.type == BodyItem.EXCEPT: + return { + 'patterns': list(result.patterns), + 'pattern_type': result.pattern_type, + 'variable': result.assign + } + return {} def start_return(self, data: 'running.Return', result: 'result.Return'): - attrs = self._body_item_v2_attributes(data, result) - attrs['values'] = list(result.values) - self._start_return(result._log_name, attrs) + self._start_return(result._log_name, + self._control_attributes(data, result, values=list(result.values))) def end_return(self, data: 'running.Return', result: 'result.Return'): - attrs = self._body_item_v2_attributes(data, result, is_end=True) - attrs['values'] = list(result.values) - self._end_return(result._log_name, attrs) + self._end_return(result._log_name, + self._control_attributes(data, result, is_end=True, values=list(result.values))) def start_continue(self, data: 'running.Continue', result: 'result.Continue'): - self._start_continue(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_continue(result._log_name, self._control_attributes(data, result)) def end_continue(self, data: 'running.Continue', result: 'result.Continue'): - self._end_continue(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_continue(result._log_name, self._control_attributes(data, result, is_end=True)) def start_break(self, data: 'running.Break', result: 'result.Break'): - self._start_break(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_break(result._log_name, self._control_attributes(data, result)) def end_break(self, data: 'running.Break', result: 'result.Break'): - self._end_break(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_break(result._log_name, self._control_attributes(data, result, is_end=True)) def start_error(self, data: 'running.Error', result: 'result.Error'): - self._start_error(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_error(result._log_name, self._control_attributes(data, result)) def end_error(self, data: 'running.Error', result: 'result.Error'): - self._end_error(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_error(result._log_name, self._control_attributes(data, result, is_end=True)) def start_var(self, data: 'running.Var', result: 'result.Var'): - self._start_var(result._log_name, self._body_item_v2_attributes(data, result)) + self._start_var(result._log_name, self._control_attributes(data, result)) def end_var(self, data: 'running.Var', result: 'result.Var'): - self._end_var(result._log_name, self._body_item_v2_attributes(data, result, is_end=True)) + self._end_var(result._log_name, self._control_attributes(data, result, is_end=True)) def log_message(self, message: 'model.Message'): self._log_message(self._message_attributes(message)) @@ -380,7 +422,7 @@ def log_message(self, message: 'model.Message'): def message(self, message: 'model.Message'): self._message(self._message_attributes(message)) - def _suite_v2_attributes(self, data, result, is_end=False): + def _suite_attributes(self, data, result, is_end=False): attrs = { 'id': data.id, 'doc': result.doc, @@ -402,7 +444,7 @@ def _suite_v2_attributes(self, data, result, is_end=False): }) return attrs - def _test_v2_attributes(self, data: 'running.TestCase', result: 'result.TestCase', is_end=False): + def _test_attributes(self, data: 'running.TestCase', result: 'result.TestCase', is_end=False): attrs = { 'id': data.id, 'doc': result.doc, @@ -423,76 +465,42 @@ def _test_v2_attributes(self, data: 'running.TestCase', result: 'result.TestCase }) return attrs - def _for_v2_attributes(self, data: 'running.For', result: 'result.For', is_end=False): - attrs = self._body_item_v2_attributes(data, result, is_end=is_end) - attrs.update({ - 'variables': list(result.assign), - 'flavor': result.flavor or '', - 'values': list(result.values) - }) - if result.flavor == 'IN ENUMERATE': - attrs['start'] = result.start - elif result.flavor == 'IN ZIP': - attrs['fill'] = result.fill - attrs['mode'] = result.mode - return attrs - - def _while_v2_attributes(self, data: 'running.While', result: 'result.While', is_end=False): - attrs = self._body_item_v2_attributes(data, result, is_end=is_end) - attrs.update({ - 'condition': result.condition, - 'limit': result.limit, - 'on_limit_message': result.on_limit_message - # FIXME: Add 'on_limit' - }) - return attrs - - def _if_v2_attributes(self, data: 'running.If', result: 'result.If', is_end=False): - attrs = self._body_item_v2_attributes(data, result, is_end=is_end) - if result.type in (BodyItem.IF, BodyItem.ELSE_IF): - attrs['condition'] = result.condition - return attrs - - def _try_v2_attributes(self, data: 'running.Try', result: 'result.TryBranch', is_end=False): - attrs = self._body_item_v2_attributes(data, result, is_end=is_end) - if result.type == BodyItem.EXCEPT: + def _keyword_attributes(self, data, result, is_end=False): + attrs = { + 'doc': result.doc, + 'lineno': data.lineno, + 'type': result.type, + 'status': result.status, + 'starttime': result.starttime, + 'source': str(data.source or ''), + 'kwname': result.name or '', + 'libname': result.owner or '', + 'args': [a if is_string(a) else safe_str(a) for a in result.args], + 'assign': list(result.assign), + 'tags': list(result.tags) + } + if is_end: attrs.update({ - 'patterns': list(result.patterns), - 'pattern_type': result.pattern_type, - 'variable': result.assign + 'endtime': result.endtime, + 'elapsedtime': result.elapsedtime }) return attrs - def _return_v2_attributes(self, data: 'running.Return', result: 'result.Return', is_end=False): - attrs = self._body_item_v2_attributes(data, result, is_end=is_end) - attrs['values'] = list(result.values) - return result._log_name, attrs - - def _body_item_v2_attributes(self, data, result, is_end=False, is_keyword_like=False): + def _control_attributes(self, data, result, is_end=False, **extra): attrs = { - 'doc': result.doc, + 'doc': '', 'lineno': data.lineno, 'type': result.type, 'status': result.status, 'starttime': result.starttime, - 'source': str(data.source or '') + 'source': str(data.source or ''), + 'kwname': result._log_name, + 'libname': '', + 'args': [], + 'assign': [], + 'tags': [] } - if is_keyword_like: - attrs.update({ - 'kwname': result.name or '', - 'libname': result.owner or '', - 'args': [a if is_string(a) else safe_str(a) for a in result.args], - 'assign': list(result.assign), - 'tags': list(result.tags), - }) - else: - attrs.update({ - 'kwname': result._log_name, - 'libname': '', - 'args': [], - 'assign': [], - 'tags': [] - }) + attrs.update(**extra) if is_end: attrs.update({ 'endtime': result.endtime, @@ -513,14 +521,14 @@ def _message_attributes(self, msg): class LibraryListenerFacade(ListenerFacade): def __init__(self, listener, name, library): - super().__init__(listener, name, prefix="_") + super().__init__(listener, name, allow_leading_underscore=True) self.library = library class LibraryListenerV2Facade(ListenerV2Facade): def __init__(self, listener, name, library): - super().__init__(listener, name, prefix='_') + super().__init__(listener, name, allow_leading_underscore=True) self.library = library From a62ffb952004b8ecaab55ddb52161fb37cd8c56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 15:23:47 +0200 Subject: [PATCH 0820/1592] Fix `robot:flatten` with log levels. Move flattening to XmlLogger instead of having XmlLogger and a separate FlatXmlLogger. The latter contaneined a lot of dummy code that gets removed and the code gets simpler also otherwise. Fixes #4921. --- atest/robot/running/flatten.robot | 8 ++ atest/testdata/running/flatten.robot | 16 +++- src/robot/output/output.py | 11 +-- src/robot/output/xmllogger.py | 122 ++++----------------------- 4 files changed, 39 insertions(+), 118 deletions(-) diff --git a/atest/robot/running/flatten.robot b/atest/robot/running/flatten.robot index d3b037c5d42..cb372e9c4d1 100644 --- a/atest/robot/running/flatten.robot +++ b/atest/robot/running/flatten.robot @@ -31,6 +31,14 @@ Recursion Listener methods start and end keyword are called Stderr Should Be Empty +Log levels + Run Tests ${EMPTY} running/flatten.robot + ${tc}= User keyword content should be flattened 4 + Check Log Message ${tc.body[0].messages[0]} INFO 1 + Check Log Message ${tc.body[0].messages[1]} Log level changed from INFO to DEBUG. + Check Log Message ${tc.body[0].messages[2]} INFO 2 + Check Log Message ${tc.body[0].messages[3]} DEBUG 2 level=DEBUG + *** Keywords *** User keyword content should be flattened [Arguments] ${expected_message_count}=0 diff --git a/atest/testdata/running/flatten.robot b/atest/testdata/running/flatten.robot index 66bb6e3cbad..c70b00b1e3d 100644 --- a/atest/testdata/running/flatten.robot +++ b/atest/testdata/running/flatten.robot @@ -14,6 +14,9 @@ Loops and stuff Recursion Recursion ${3} +Log levels + Log levels + *** Keywords *** UK [Tags] robot:flatten @@ -56,10 +59,21 @@ Loops and stuff Log inside except END - Recursion +Recursion [Arguments] ${num} [Tags] robot:flatten Log Level: ${num} IF ${num} < 10 Recursion ${num+1} END + +Log levels + [Tags] robot:flatten + Log INFO 1 + Log DEBUG 1 DEBUG + Set Log Level DEBUG + Log INFO 2 + Log DEBUG 2 DEBUG + Set Log Level NONE + Log INFO 3 + Log DEBUG 3 DEBUG diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 8675ac3b8b7..526aad4d944 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -32,7 +32,6 @@ def __init__(self, settings): self.library_listeners = LibraryListeners(settings.log_level) self._register_loggers(DebugFile(settings.debug_file)) self._settings = settings - self._flatten_level = 0 def _register_loggers(self, debug_file): LOGGER.register_xml_logger(self._xml_logger) @@ -63,16 +62,8 @@ def end_test(self, data, result): def start_keyword(self, data, result): LOGGER.start_keyword(data, result) - if result.type in result.KEYWORD_TYPES and result.tags.robot('flatten'): - self._flatten_level += 1 - if self._flatten_level == 1: - self._xml_logger.flatten(True) def end_keyword(self, data, result): - if result.type in result.KEYWORD_TYPES and result.tags.robot('flatten'): - self._flatten_level -= 1 - if not self._flatten_level: - self._xml_logger.flatten(False) LOGGER.end_keyword(data, result) def start_for(self, data, result): @@ -157,7 +148,7 @@ def message(self, msg): LOGGER.log_message(msg) def trace(self, msg, write_if_flat=True): - if write_if_flat or self._flatten_level == 0: + if write_if_flat or not self._xml_logger.flatten_level: self.write(msg, 'TRACE') def set_log_level(self, level): diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index af5a6f315de..56971157821 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -26,21 +26,11 @@ class XmlLoggerAdapter(LoggerApi): def __init__(self, path, log_level='TRACE', rpa=False, generator='Robot'): - self._xml_logger = XmlLogger(path, log_level, rpa, generator) - self._flat_xml_logger = None - self.logger = self._xml_logger + self.logger = XmlLogger(path, log_level, rpa, generator) @property - def flat_xml_logger(self): - if self._flat_xml_logger is None: - self._flat_xml_logger = FlatXmlLogger(self.logger) - return self._flat_xml_logger - - def flatten(self, flatten): - if flatten: - self.logger = self.flat_xml_logger - else: - self.logger = self._xml_logger + def flatten_level(self): + return self.logger.flatten_level def close(self): self.logger.close() @@ -156,7 +146,9 @@ class XmlLogger(ResultVisitor): def __init__(self, path, log_level='TRACE', rpa=False, generator='Robot'): self._log_message_is_logged = IsLogged(log_level) self._error_message_is_logged = IsLogged('WARN') - self._writer = self._get_writer(path, rpa, generator) + # `_writer` is set to NullMarkupWriter when flattening, `_xml_writer` is not. + self._writer = self._xml_writer = self._get_writer(path, rpa, generator) + self.flatten_level = 0 self._errors = [] def _get_writer(self, path, rpa, generator): @@ -193,7 +185,8 @@ def _write_message(self, msg): 'level': msg.level} if msg.html: attrs['html'] = 'true' - self._writer.element('msg', msg.message, attrs) + # Use `_xml_writer`, not `_writer` to write messages also when flattening. + self._xml_writer.element('msg', msg.message, attrs) def start_keyword(self, kw): attrs = {'name': kw.name, 'owner': kw.owner} @@ -205,10 +198,16 @@ def start_keyword(self, kw): self._write_list('var', kw.assign) self._write_list('arg', [safe_str(a) for a in kw.args]) self._write_list('tag', kw.tags) - # Must be after tags to allow adding message when using --flattenkeywords. self._writer.element('doc', kw.doc) + if kw.tags.robot('flatten'): + self.flatten_level += 1 + self._writer = NullMarkupWriter() def end_keyword(self, kw): + if kw.tags.robot('flatten'): + self.flatten_level -= 1 + if self.flatten_level == 0: + self._writer = self._xml_writer if kw.timeout: self._writer.element('timeout', attrs={'value': str(kw.timeout)}) self._write_status(kw) @@ -407,94 +406,3 @@ def _write_status(self, item): 'start': item.start_time.isoformat() if item.start_time else None, 'elapsed': str(item.elapsed_time.total_seconds())} self._writer.element('status', item.message, attrs) - - -class FlatXmlLogger(XmlLogger): - - def __init__(self, real_xml_logger): - super().__init__(None) - self._writer = real_xml_logger._writer - - def start_keyword(self, kw): - pass - - def end_keyword(self, kw): - pass - - def start_for(self, for_): - pass - - def end_for(self, for_): - pass - - def start_for_iteration(self, iteration): - pass - - def end_for_iteration(self, iteration): - pass - - def start_if(self, if_): - pass - - def end_if(self, if_): - pass - - def start_if_branch(self, branch): - pass - - def end_if_branch(self, branch): - pass - - def start_try(self, root): - pass - - def end_try(self, root): - pass - - def start_try_branch(self, branch): - pass - - def end_try_branch(self, branch): - pass - - def start_while(self, while_): - pass - - def end_while(self, while_): - pass - - def start_while_iteration(self, iteration): - pass - - def end_while_iteration(self, iteration): - pass - - def start_var(self, var): - pass - - def end_var(self, var): - pass - - def start_break(self, break_): - pass - - def end_break(self, break_): - pass - - def start_continue(self, continue_): - pass - - def end_continue(self, continue_): - pass - - def start_return(self, return_): - pass - - def end_return(self, return_): - pass - - def start_error(self, error): - pass - - def end_error(self, error): - pass From 871068c86cfb38c694ea6174147d51d651a61f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 16:05:14 +0200 Subject: [PATCH 0821/1592] Change `Set Log Level` message level to DEBUG. Fixes #4922. --- atest/robot/running/flatten.robot | 2 +- .../builtin/set_log_level.robot | 17 ++++++++++------- .../used_in_custom_libs_and_listeners.robot | 4 ++-- .../test_libraries/as_listener/log_levels.robot | 2 +- src/robot/libraries/BuiltIn.py | 14 +++++--------- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/atest/robot/running/flatten.robot b/atest/robot/running/flatten.robot index cb372e9c4d1..d9fe596666d 100644 --- a/atest/robot/running/flatten.robot +++ b/atest/robot/running/flatten.robot @@ -35,7 +35,7 @@ Log levels Run Tests ${EMPTY} running/flatten.robot ${tc}= User keyword content should be flattened 4 Check Log Message ${tc.body[0].messages[0]} INFO 1 - Check Log Message ${tc.body[0].messages[1]} Log level changed from INFO to DEBUG. + Check Log Message ${tc.body[0].messages[1]} Log level changed from INFO to DEBUG. DEBUG Check Log Message ${tc.body[0].messages[2]} INFO 2 Check Log Message ${tc.body[0].messages[3]} DEBUG 2 level=DEBUG diff --git a/atest/robot/standard_libraries/builtin/set_log_level.robot b/atest/robot/standard_libraries/builtin/set_log_level.robot index 709679cc2c3..459846c75fe 100644 --- a/atest/robot/standard_libraries/builtin/set_log_level.robot +++ b/atest/robot/standard_libraries/builtin/set_log_level.robot @@ -5,20 +5,23 @@ Resource atest_resource.robot *** Test Cases *** Set Log Level ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Log level changed from INFO to TRACE. + Check Log Message ${tc.kws[0].msgs[0]} Log level changed from INFO to TRACE. DEBUG Check Log Message ${tc.kws[1].msgs[1]} This is logged TRACE Check Log Message ${tc.kws[2].msgs[1]} This is logged DEBUG Check Log Message ${tc.kws[3].msgs[1]} This is logged INFO - Should Be Empty ${tc.kws[6].msgs} + Check Log Message ${tc.kws[4].msgs[1]} Log level changed from TRACE to DEBUG. DEBUG + Should Be Empty ${tc.kws[6].msgs} Check Log Message ${tc.kws[7].msgs[0]} This is logged DEBUG Check Log Message ${tc.kws[8].msgs[0]} This is logged INFO - Should Be Empty ${tc.kws[10].msgs} - Should Be Empty ${tc.kws[11].msgs} + Should Be Empty ${tc.kws[9].msgs} + Should Be Empty ${tc.kws[10].msgs} + Should Be Empty ${tc.kws[11].msgs} Check Log Message ${tc.kws[12].msgs[0]} This is logged INFO - Should Be Empty ${tc.kws[15].msgs} + Should Be Empty ${tc.kws[15].msgs} Check Log Message ${tc.kws[16].msgs[0]} This is logged ERROR - Should Be Empty ${tc.kws[18].msgs} - Should Be Empty ${tc.kws[19].msgs} + Should Be Empty ${tc.kws[17].msgs} + Should Be Empty ${tc.kws[18].msgs} + Should Be Empty ${tc.kws[19].msgs} Invalid Log Level Failure Is Catchable Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/used_in_custom_libs_and_listeners.robot b/atest/robot/standard_libraries/builtin/used_in_custom_libs_and_listeners.robot index 8c2293677cf..2da3f4a1019 100644 --- a/atest/robot/standard_libraries/builtin/used_in_custom_libs_and_listeners.robot +++ b/atest/robot/standard_libraries/builtin/used_in_custom_libs_and_listeners.robot @@ -8,7 +8,7 @@ Resource atest_resource.robot *** Test Cases *** Keywords Using BuiltIn ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Log level changed from INFO to DEBUG. + Check Log Message ${tc.kws[0].msgs[0]} Log level changed from INFO to DEBUG. DEBUG Check Log Message ${tc.kws[0].msgs[1]} Hello, debug world! DEBUG Listener Using BuiltIn @@ -22,7 +22,7 @@ Use 'Run Keyword' with non-Unicode values Use BuiltIn keywords with timeouts ${tc} = Check Test Case ${TESTNAME} Check Log Message ${tc.kws[0].msgs[0]} Test timeout 1 day active. * seconds left. level=DEBUG pattern=True - Check Log Message ${tc.kws[0].msgs[1]} Log level changed from INFO to DEBUG. + Check Log Message ${tc.kws[0].msgs[1]} Log level changed from INFO to DEBUG. DEBUG Check Log Message ${tc.kws[0].msgs[2]} Hello, debug world! DEBUG Check Log Message ${tc.kws[3].kws[0].msgs[0]} Test timeout 1 day active. * seconds left. level=DEBUG pattern=True Check Log Message ${tc.kws[3].kws[0].msgs[1]} 42 diff --git a/atest/testdata/test_libraries/as_listener/log_levels.robot b/atest/testdata/test_libraries/as_listener/log_levels.robot index 3aef035f82d..e7e2cd58a62 100644 --- a/atest/testdata/test_libraries/as_listener/log_levels.robot +++ b/atest/testdata/test_libraries/as_listener/log_levels.robot @@ -17,7 +17,7 @@ Log messages are collected on level set using 'Set Log Level' ${old} = Set Log Level DEBUG Keyword Logged messages should be - ... INFO: Log level changed from ${old} to DEBUG. + ... DEBUG: Log level changed from ${old} to DEBUG. ... INFO: \${old} = ${old} ... INFO: Message ... DEBUG: Debug message diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index e0a59d62b25..931e43e60d6 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -3158,18 +3158,14 @@ def set_log_level(self, level): """Sets the log threshold to the specified level and returns the old level. Messages below the level will not logged. The default logging level is - INFO, but it can be overridden with the command line option - ``--loglevel``. + INFO, but it can be overridden with the command line option ``--loglevel``. - The available levels: TRACE, DEBUG, INFO (default), WARN, ERROR and NONE (no - logging). + The available levels: TRACE, DEBUG, INFO (default), WARN, ERROR and NONE + (no logging). """ - try: - old = self._context.output.set_log_level(level) - except DataError as err: - raise RuntimeError(str(err)) + 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()}.') + self.log(f'Log level changed from {old} to {level.upper()}.', level='DEBUG') return old def reload_library(self, name_or_instance): From 91bef18531aac9960029e44c2a80077f8d1ac3aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 16:19:42 +0200 Subject: [PATCH 0822/1592] atest/run.py: Enhance rerun-support Store latest results in interpreter specific file to avoid conflicts when using other interpreters. --- atest/run.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/atest/run.py b/atest/run.py index c6dfc98b5f6..4625798a21c 100755 --- a/atest/run.py +++ b/atest/run.py @@ -48,7 +48,7 @@ CURDIR = Path(__file__).parent -LATEST = CURDIR / 'results/latest.xml' +LATEST = str(CURDIR / 'results/{interpreter.output_name}-latest.xml') ARGUMENTS = ''' --doc Robot Framework acceptance tests --metadata interpreter:{interpreter} @@ -65,15 +65,11 @@ def atests(interpreter, arguments, schema_validation=False): - try: - interpreter = Interpreter(interpreter) - except ValueError as err: - sys.exit(str(err)) output_dir, temp_dir = _get_directories(interpreter) arguments = list(_get_arguments(interpreter, output_dir)) + list(arguments) rc = _run(arguments, temp_dir, interpreter, schema_validation) if rc < 251: - _rebot(rc, output_dir) + _rebot(rc, output_dir, interpreter) return rc @@ -117,7 +113,7 @@ def _run(args, tempdir, interpreter, schema_validation): return subprocess.call(command, env=environ) -def _rebot(rc, output_dir): +def _rebot(rc, output_dir, interpreter): output = output_dir / 'output.xml' if rc == 0: print('All tests passed, not generating log or report.') @@ -125,7 +121,7 @@ def _rebot(rc, output_dir): command = [sys.executable, str(CURDIR.parent / 'src/robot/rebot.py'), '--output-dir', str(output_dir), str(output)] subprocess.call(command) - shutil.copy(output, LATEST) + shutil.copy(output, LATEST.format(interpreter=interpreter)) if __name__ == '__main__': @@ -135,8 +131,12 @@ def _rebot(rc, output_dir): parser.add_argument('-R', '--rerun-failed', action='store_true') parser.add_argument('-h', '--help', action='store_true') options, robot_args = parser.parse_known_args() + try: + interpreter = Interpreter(options.interpreter) + except ValueError as err: + sys.exit(str(err)) if options.rerun_failed: - robot_args = ['--rerun-failed', LATEST] + robot_args + robot_args[:0] = ['--rerun-failed', LATEST.format(interpreter=interpreter)] last = Path(robot_args[-1]) if robot_args else None source_given = last and (last.is_dir() or last.is_file() and last.suffix == '.robot') if not source_given: @@ -145,5 +145,5 @@ def _rebot(rc, output_dir): print(__doc__) rc = 251 else: - rc = atests(options.interpreter, robot_args, options.schema_validation) + rc = atests(interpreter, robot_args, options.schema_validation) sys.exit(rc) From e1f61027f283f454eef5a2a50d3007f142e657e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 17:46:01 +0200 Subject: [PATCH 0823/1592] Fix typing --- src/robot/output/loggerapi.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot/output/loggerapi.py b/src/robot/output/loggerapi.py index 9c4bb69ec45..dce410039b1 100644 --- a/src/robot/output/loggerapi.py +++ b/src/robot/output/loggerapi.py @@ -69,10 +69,10 @@ def start_if(self, data: 'running.If', result: 'result.If'): def end_if(self, data: 'running.If', result: 'result.If'): self.end_body_item(data, result) - def start_if_branch(self, data: 'running.If', result: 'result.IfBranch'): + def start_if_branch(self, data: 'running.IfBranch', result: 'result.IfBranch'): self.start_body_item(data, result) - def end_if_branch(self, data: 'running.If', result: 'result.IfBranch'): + def end_if_branch(self, data: 'running.IfBranch', result: 'result.IfBranch'): self.end_body_item(data, result) def start_try(self, data: 'running.Try', result: 'result.Try'): @@ -81,10 +81,10 @@ def start_try(self, data: 'running.Try', result: 'result.Try'): def end_try(self, data: 'running.Try', result: 'result.Try'): self.end_body_item(data, result) - def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + def start_try_branch(self, data: 'running.TryBranch', result: 'result.TryBranch'): self.start_body_item(data, result) - def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + def end_try_branch(self, data: 'running.TryBranch', result: 'result.TryBranch'): self.end_body_item(data, result) def start_var(self, data: 'running.Var', result: 'result.Var'): From 624d6bfab5e5013231fb7a4f92a82ab0baab1ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 17:56:23 +0200 Subject: [PATCH 0824/1592] Refactor - Remove type hints that can be inferred from the base class. - Remove `LibraryListenerFacade` and `LibraryListenerV2Facade` and make normal listener facades generic so that they work also with library listeners. - Try to keep line lenght < 89. --- src/robot/output/listeners.py | 366 +++++++++++++++------------------- 1 file changed, 165 insertions(+), 201 deletions(-) diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index 5aa2520b752..98ac4de6dfd 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -17,7 +17,7 @@ from robot.errors import DataError, TimeoutError from robot.model import BodyItem -from robot.utils import (Importer, get_error_details, is_string, safe_str, +from robot.utils import (get_error_details, Importer, safe_str, split_args_from_name_or_path, type_name) from .loggerapi import LoggerApi @@ -26,138 +26,138 @@ class Listeners(LoggerApi): + _listeners: 'list[ListenerFacade]' def __init__(self, listeners=(), log_level='INFO'): self._is_logged = IsLogged(log_level) self._listeners = import_listeners(listeners) if listeners else [] - # LibraryListeners has a dynamic implementation which requires - # `listeners` to be a property. + # Must be property to allow LibraryListeners to override it. @property def listeners(self): return self._listeners - def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + def start_suite(self, data, result): for listener in self.listeners: listener.start_suite(data, result) - def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + def end_suite(self, data, result): for listener in self.listeners: listener.end_suite(data, result) - def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + def start_test(self, data, result): for listener in self.listeners: listener.start_test(data, result) - def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + def end_test(self, data, result): for listener in self.listeners: listener.end_test(data, result) - def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + def start_keyword(self, data, result): for listener in self.listeners: listener.start_keyword(data, result) - def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): + def end_keyword(self, data, result): for listener in self.listeners: listener.end_keyword(data, result) - def start_for(self, data: 'running.For', result: 'result.For'): + def start_for(self, data, result): for listener in self.listeners: listener.start_for(data, result) - def end_for(self, data: 'running.For', result: 'result.For'): + def end_for(self, data, result): for listener in self.listeners: listener.end_for(data, result) - def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + def start_for_iteration(self, data, result): for listener in self.listeners: listener.start_for_iteration(data, result) - def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): + def end_for_iteration(self, data, result): for listener in self.listeners: listener.end_for_iteration(data, result) - def start_while(self, data: 'running.While', result: 'result.While'): + def start_while(self, data, result): for listener in self.listeners: listener.start_while(data, result) - def end_while(self, data: 'running.While', result: 'result.While'): + def end_while(self, data, result): for listener in self.listeners: listener.end_while(data, result) - def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + def start_while_iteration(self, data, result): for listener in self.listeners: listener.start_while_iteration(data, result) - def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): + def end_while_iteration(self, data, result): for listener in self.listeners: listener.end_while_iteration(data, result) - def start_if_branch(self, data: 'running.If', result: 'result.If'): + def start_if_branch(self, data, result): for listener in self.listeners: listener.start_if_branch(data, result) - def end_if_branch(self, data: 'running.If', result: 'result.If'): + def end_if_branch(self, data, result): for listener in self.listeners: listener.end_if_branch(data, result) - def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + def start_try_branch(self, data, result): for listener in self.listeners: listener.start_try_branch(data, result) - def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + def end_try_branch(self, data, result): for listener in self.listeners: listener.end_try_branch(data, result) - def start_return(self, data: 'running.Return', result: 'result.Return'): + def start_return(self, data, result): for listener in self.listeners: listener.start_return(data, result) - def end_return(self, data: 'running.Return', result: 'result.Return'): + def end_return(self, data, result): for listener in self.listeners: listener.end_return(data, result) - def start_continue(self, data: 'running.Continue', result: 'result.Continue'): + def start_continue(self, data, result): for listener in self.listeners: listener.start_continue(data, result) - def end_continue(self, data: 'running.Continue', result: 'result.Continue'): + def end_continue(self, data, result): for listener in self.listeners: listener.end_continue(data, result) - def start_break(self, data: 'running.Break', result: 'result.Break'): + def start_break(self, data, result): for listener in self.listeners: listener.start_break(data, result) - def end_break(self, data: 'running.Break', result: 'result.Break'): + def end_break(self, data, result): for listener in self.listeners: listener.end_break(data, result) - def start_error(self, data: 'running.Error', result: 'result.Error'): + def start_error(self, data, result): for listener in self.listeners: listener.start_error(data, result) - def end_error(self, data: 'running.Error', result: 'result.Error'): + def end_error(self, data, result): for listener in self.listeners: listener.end_error(data, result) - def start_var(self, data: 'running.Var', result: 'result.Var'): + def start_var(self, data, result): for listener in self.listeners: listener.start_var(data, result) - def end_var(self, data: 'running.Var', result: 'result.Var'): + def end_var(self, data, result): for listener in self.listeners: listener.end_var(data, result) def set_log_level(self, level): self._is_logged.set_level(level) - def log_message(self, message: 'model.Message'): + def log_message(self, message): if self._is_logged(message.level): for listener in self.listeners: listener.log_message(message) - def message(self, message: 'model.Message'): + def message(self, message): for listener in self.listeners: listener.message(message) @@ -178,42 +178,44 @@ def __bool__(self): class LibraryListeners(Listeners): + _listeners: 'list[list[ListenerFacade]]' def __init__(self, log_level='INFO'): super().__init__(log_level=log_level) - self._listener_stack = [] @property def listeners(self): - return self._listener_stack[-1] if self._listener_stack else [] + return self._listeners[-1] if self._listeners else [] def new_suite_scope(self): - self._listener_stack.append([]) + self._listeners.append([]) def discard_suite_scope(self): - self._listener_stack.pop() + self._listeners.pop() def register(self, listeners, library): listeners = import_listeners(listeners, library=library) - self._listener_stack[-1].extend(listeners) + self._listeners[-1].extend(listeners) def close(self): pass def unregister(self, library, close=False): - if close: - for listener in [li for li in self.listeners if li.library is library]: + remaining = [] + for listener in self._listeners[-1]: + if listener.library is not library: + remaining.append(listener) + elif close: listener.close() - listeners = [listener for listener in self._listener_stack[-1] if listener.library is not library] - self._listener_stack[-1] = listeners + self._listeners[-1] = remaining class ListenerFacade(LoggerApi): - def __init__(self, listener, name, allow_leading_underscore=False): + def __init__(self, listener, name, library=None): self.listener = listener self.name = name - self.allow_leading_underscore = allow_leading_underscore + self.library = library self._start_suite = self._get_method(listener, 'start_suite') self._end_suite = self._get_method(listener, 'end_suite') self._start_test = self._get_method(listener, 'start_test') @@ -222,26 +224,26 @@ def __init__(self, listener, name, allow_leading_underscore=False): self._message = self._get_method(listener, 'message') self._close = self._get_method(listener, 'close') - def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + def start_suite(self, data, result): self._start_suite(data, result) - def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): + def end_suite(self, data, result): self._end_suite(data, result) - def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): + def start_test(self, data, result): self._start_test(data, result) - def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): + def end_test(self, data, result): self._end_test(data, result) - def log_message(self, message: 'model.Message'): + def log_message(self, message): self._log_message(message) - def message(self, message: 'model.Message'): + def message(self, message): self._message(message) def output_file(self, type_: str, path: str): - method = self._get_method(self.listener, '%s_file' % type_.lower()) + method = self._get_method(self.listener, f'{type_.lower()}_file') method(path) def close(self): @@ -254,62 +256,53 @@ def _get_method(self, listener, name): return ListenerMethod(None, self.name) def _get_method_names(self, name): - names = [name, self._toCamelCase(name)] if '_' in name else [name] - if self.allow_leading_underscore: + names = [name, self._to_camelCase(name)] if '_' in name else [name] + if self.library is not None: names += ['_' + name for name in names] return names - def _toCamelCase(self, name): - parts = name.split('_') - return ''.join([parts[0]] + [part.capitalize() for part in parts[1:]]) + def _to_camelCase(self, name): + first, *rest = name.split('_') + return ''.join([first] + [part.capitalize() for part in rest]) class ListenerV2Facade(ListenerFacade): - def __init__(self, listener, name, allow_leading_underscore=False): - super().__init__(listener, name, allow_leading_underscore) - self._start_keyword = self._get_method(listener, 'start_keyword') - self._end_keyword = self._get_method(listener, 'end_keyword') - self._start_for = self._start_for_iteration = self._start_while = \ - self._start_while_iteration = self._start_if_branch = \ - self._start_try_branch = self._start_return = self._start_continue = \ - self._start_break = self._start_var = self._start_error = self._start_keyword - self._end_for = self._end_for_iteration = self._end_while = self._end_while_iteration =\ - self._end_if_branch = self._end_try_branch = self._end_return = self._end_continue =\ - self._end_break = self._end_var = self._end_error = self._end_keyword + def __init__(self, listener, name, library=None): + super().__init__(listener, name, library) + self._start_kw = self._get_method(listener, 'start_keyword') + self._end_kw = self._get_method(listener, 'end_keyword') def imported(self, import_type: str, name: str, attrs): - method = self._get_method(self.listener, '%s_import' % import_type.lower()) + method = self._get_method(self.listener, f'{import_type.lower()}_import') method(name, attrs) - def start_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._start_suite(result.name, self._suite_attributes(data, result)) + def start_suite(self, data, result): + self._start_suite(result.name, self._suite_attrs(data, result)) - def end_suite(self, data: 'running.TestSuite', result: 'result.TestSuite'): - self._end_suite(result.name, self._suite_attributes(data, result, is_end=True)) + def end_suite(self, data, result): + self._end_suite(result.name, self._suite_attrs(data, result, end=True)) - def start_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._start_test(result.name, self._test_attributes(data, result)) + def start_test(self, data, result): + self._start_test(result.name, self._test_attrs(data, result)) - def end_test(self, data: 'running.TestCase', result: 'result.TestCase'): - self._end_test(result.name, self._test_attributes(data, result, is_end=True)) + def end_test(self, data, result): + self._end_test(result.name, self._test_attrs(data, result, end=True)) - def start_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - self._start_keyword(result.full_name, self._keyword_attributes(data, result)) + def start_keyword(self, data, result): + self._start_kw(result.full_name, self._keyword_attrs(data, result)) - def end_keyword(self, data: 'running.Keyword', result: 'result.Keyword'): - self._end_keyword(result.full_name, - self._keyword_attributes(data, result, is_end=True)) + def end_keyword(self, data, result): + self._end_kw(result.full_name, + self._keyword_attrs(data, result, end=True)) - def start_for(self, data: 'running.For', result: 'result.For'): + def start_for(self, data, result): extra = self._for_extra_attrs(result) - self._start_for(result._log_name, - self._control_attributes(data, result, **extra)) + self._start_kw(result._log_name, self._attrs(data, result, **extra)) - def end_for(self, data: 'running.For', result: 'result.For'): + def end_for(self, data, result): extra = self._for_extra_attrs(result) - self._end_for(result._log_name, - self._control_attributes(data, result, is_end=True, **extra)) + self._end_kw(result._log_name, self._attrs(data, result, **extra, end=True)) def _for_extra_attrs(self, result): extra = { @@ -324,56 +317,47 @@ def _for_extra_attrs(self, result): extra['mode'] = result.mode return extra - def start_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - attrs = self._control_attributes(data, result, variables=dict(result.assign)) - self._start_for_iteration(result._log_name, attrs) - - def end_for_iteration(self, data: 'running.For', result: 'result.ForIteration'): - attrs = self._control_attributes(data, result, is_end=True, variables=dict(result.assign)) - self._end_for_iteration(result._log_name, attrs) - - def start_while(self, data: 'running.While', result: 'result.While'): - # FIXME: Add 'on_limit' - attrs = self._control_attributes(data, result, condition=result.condition, - limit=result.limit, on_limit_message=result.on_limit_message) - self._start_while(result._log_name, attrs) - - def end_while(self, data: 'running.While', result: 'result.While'): - attrs = self._control_attributes(data, result, condition=result.condition, - limit=result.limit, on_limit_message=result.on_limit_message, - is_end=True) - self._end_while(result._log_name, attrs) - - def start_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - self._start_while_iteration(result._log_name, self._control_attributes(data, result)) - - def end_while_iteration(self, data: 'running.While', result: 'result.WhileIteration'): - self._end_while_iteration(result._log_name, - self._control_attributes(data, result, is_end=True)) - - def start_if_branch(self, data: 'running.If', result: 'result.IfBranch'): - extra = {} - if result.type in (BodyItem.IF, BodyItem.ELSE_IF): - extra['condition'] = result.condition - self._start_if_branch(result._log_name, - self._control_attributes(data, result, **extra)) - - def end_if_branch(self, data: 'running.If', result: 'result.IfBranch'): - extra = {} - if result.type in (BodyItem.IF, BodyItem.ELSE_IF): - extra['condition'] = result.condition - self._end_if_branch(result._log_name, - self._control_attributes(data, result, is_end=True, **extra)) - - def start_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + def start_for_iteration(self, data, result): + attrs = self._attrs(data, result, variables=dict(result.assign)) + self._start_kw(result._log_name, attrs) + + def end_for_iteration(self, data, result): + attrs = self._attrs(data, result, variables=dict(result.assign), end=True) + self._end_kw(result._log_name, attrs) + + def start_while(self, data, result): + attrs = self._attrs(data, result, condition=result.condition, + limit=result.limit, + on_limit_message=result.on_limit_message) + self._start_kw(result._log_name, attrs) + + def end_while(self, data, result): + attrs = self._attrs(data, result, condition=result.condition, + limit=result.limit, + on_limit_message=result.on_limit_message, end=True) + self._end_kw(result._log_name, attrs) + + def start_while_iteration(self, data, result): + self._start_kw(result._log_name, self._attrs(data, result)) + + def end_while_iteration(self, data, result): + self._end_kw(result._log_name, self._attrs(data, result, end=True)) + + def start_if_branch(self, data, result): + extra = {'condition': result.condition} if result.type != result.ELSE else {} + self._start_kw(result._log_name, self._attrs(data, result, **extra)) + + def end_if_branch(self, data, result): + extra = {'condition': result.condition} if result.type != result.ELSE else {} + self._end_kw(result._log_name, self._attrs(data, result, **extra, end=True)) + + def start_try_branch(self, data, result): extra = self._try_extra_attrs(result) - self._start_try_branch(result._log_name, - self._control_attributes(data, result, **extra)) + self._start_kw(result._log_name, self._attrs(data, result, **extra)) - def end_try_branch(self, data: 'running.Try', result: 'result.TryBranch'): + def end_try_branch(self, data, result): extra = self._try_extra_attrs(result) - self._end_try_branch(result._log_name, - self._control_attributes(data, result, is_end=True, **extra)) + self._end_kw(result._log_name, self._attrs(data, result, **extra, end=True)) def _try_extra_attrs(self, result): if result.type == BodyItem.EXCEPT: @@ -384,45 +368,45 @@ def _try_extra_attrs(self, result): } return {} - def start_return(self, data: 'running.Return', result: 'result.Return'): - self._start_return(result._log_name, - self._control_attributes(data, result, values=list(result.values))) + def start_return(self, data, result): + attrs = self._attrs(data, result, values=list(result.values)) + self._start_kw(result._log_name, attrs) - def end_return(self, data: 'running.Return', result: 'result.Return'): - self._end_return(result._log_name, - self._control_attributes(data, result, is_end=True, values=list(result.values))) + def end_return(self, data, result): + attrs = self._attrs(data, result, values=list(result.values), end=True) + self._end_kw(result._log_name, attrs) - def start_continue(self, data: 'running.Continue', result: 'result.Continue'): - self._start_continue(result._log_name, self._control_attributes(data, result)) + def start_continue(self, data, result): + self._start_kw(result._log_name, self._attrs(data, result)) - def end_continue(self, data: 'running.Continue', result: 'result.Continue'): - self._end_continue(result._log_name, self._control_attributes(data, result, is_end=True)) + def end_continue(self, data, result): + self._end_kw(result._log_name, self._attrs(data, result, end=True)) - def start_break(self, data: 'running.Break', result: 'result.Break'): - self._start_break(result._log_name, self._control_attributes(data, result)) + def start_break(self, data, result): + self._start_kw(result._log_name, self._attrs(data, result)) - def end_break(self, data: 'running.Break', result: 'result.Break'): - self._end_break(result._log_name, self._control_attributes(data, result, is_end=True)) + def end_break(self, data, result): + self._end_kw(result._log_name, self._attrs(data, result, end=True)) - def start_error(self, data: 'running.Error', result: 'result.Error'): - self._start_error(result._log_name, self._control_attributes(data, result)) + def start_error(self, data, result): + self._start_kw(result._log_name, self._attrs(data, result)) - def end_error(self, data: 'running.Error', result: 'result.Error'): - self._end_error(result._log_name, self._control_attributes(data, result, is_end=True)) + def end_error(self, data, result): + self._end_kw(result._log_name, self._attrs(data, result, end=True)) - def start_var(self, data: 'running.Var', result: 'result.Var'): - self._start_var(result._log_name, self._control_attributes(data, result)) + def start_var(self, data, result): + self._start_kw(result._log_name, self._attrs(data, result)) - def end_var(self, data: 'running.Var', result: 'result.Var'): - self._end_var(result._log_name, self._control_attributes(data, result, is_end=True)) + def end_var(self, data, result): + self._end_kw(result._log_name, self._attrs(data, result, end=True)) - def log_message(self, message: 'model.Message'): + def log_message(self, message): self._log_message(self._message_attributes(message)) - def message(self, message: 'model.Message'): + def message(self, message): self._message(self._message_attributes(message)) - def _suite_attributes(self, data, result, is_end=False): + def _suite_attrs(self, data, result, end=False): attrs = { 'id': data.id, 'doc': result.doc, @@ -434,7 +418,7 @@ def _suite_attributes(self, data, result, is_end=False): 'totaltests': data.test_count, 'source': str(data.source or '') } - if is_end: + if end: attrs.update({ 'endtime': result.endtime, 'elapsedtime': result.elapsedtime, @@ -444,7 +428,7 @@ def _suite_attributes(self, data, result, is_end=False): }) return attrs - def _test_attributes(self, data: 'running.TestCase', result: 'result.TestCase', is_end=False): + def _test_attrs(self, data, result, end=False): attrs = { 'id': data.id, 'doc': result.doc, @@ -456,7 +440,7 @@ def _test_attributes(self, data: 'running.TestCase', result: 'result.TestCase', 'template': data.template or '', 'originalname': data.name } - if is_end: + if end: attrs.update({ 'endtime': result.endtime, 'elapsedtime': result.elapsedtime, @@ -465,7 +449,7 @@ def _test_attributes(self, data: 'running.TestCase', result: 'result.TestCase', }) return attrs - def _keyword_attributes(self, data, result, is_end=False): + def _keyword_attrs(self, data, result, end=False): attrs = { 'doc': result.doc, 'lineno': data.lineno, @@ -475,18 +459,18 @@ def _keyword_attributes(self, data, result, is_end=False): 'source': str(data.source or ''), 'kwname': result.name or '', 'libname': result.owner or '', - 'args': [a if is_string(a) else safe_str(a) for a in result.args], + 'args': [a if isinstance(a, str) else safe_str(a) for a in result.args], 'assign': list(result.assign), 'tags': list(result.tags) } - if is_end: + if end: attrs.update({ 'endtime': result.endtime, 'elapsedtime': result.elapsedtime }) return attrs - def _control_attributes(self, data, result, is_end=False, **extra): + def _attrs(self, data, result, end=False, **extra): attrs = { 'doc': '', 'lineno': data.lineno, @@ -501,7 +485,7 @@ def _control_attributes(self, data, result, is_end=False, **extra): 'tags': [] } attrs.update(**extra) - if is_end: + if end: attrs.update({ 'endtime': result.endtime, 'elapsedtime': result.elapsedtime @@ -518,22 +502,8 @@ def _message_attributes(self, msg): return attrs -class LibraryListenerFacade(ListenerFacade): - - def __init__(self, listener, name, library): - super().__init__(listener, name, allow_leading_underscore=True) - self.library = library - - -class LibraryListenerV2Facade(ListenerV2Facade): - - def __init__(self, listener, name, library): - super().__init__(listener, name, allow_leading_underscore=True) - self.library = library - - def import_listener(listener): - if not is_string(listener): + if not isinstance(listener, str): # Modules have `__name__`, with others better to use `type_name`. name = getattr(listener, '__name__', None) or type_name(listener) return listener, name @@ -550,12 +520,11 @@ def get_version(listener, name): if version not in (2, 3): raise ValueError except AttributeError: - raise DataError("Listener '%s' does not have mandatory " - "'ROBOT_LISTENER_API_VERSION' attribute." - % name) + raise DataError(f"Listener '{name}' does not have mandatory " + f"'ROBOT_LISTENER_API_VERSION' attribute.") except (ValueError, TypeError): - raise DataError("Listener '%s' uses unsupported API version '%s'." - % (name, listener.ROBOT_LISTENER_API_VERSION)) + raise DataError(f"Listener '{name}' uses unsupported API version " + f"'{listener.ROBOT_LISTENER_API_VERSION}'.") return version @@ -566,18 +535,13 @@ def import_listeners(listeners, library=None): listener, name = import_listener(listener_source) version = get_version(listener, name) if version == 2: - if library: - imported.append(LibraryListenerV2Facade(listener, name, library)) - else: - imported.append(ListenerV2Facade(listener, name)) + imported.append(ListenerV2Facade(listener, name, library)) else: - if library: - imported.append(LibraryListenerFacade(listener, name, library)) - else: - imported.append(ListenerFacade(listener, name)) + imported.append(ListenerFacade(listener, name, library)) except DataError as err: - name = listener_source if is_string(listener_source) else type_name(listener_source) - msg = "Taking listener '%s' into use failed: %s" % (name, err) + name = listener_source \ + if isinstance(listener_source, str) else type_name(listener_source) + msg = f"Taking listener '{name}' into use failed: {err}" if library: raise DataError(msg) LOGGER.error(msg) @@ -604,10 +568,10 @@ def __call__(self, *args): # Propagate possible timeouts: # https://github.com/robotframework/robotframework/issues/2763 raise - except: + except Exception: message, details = get_error_details() - LOGGER.error("Calling method '%s' of listener '%s' failed: %s" - % (self.method.__name__, self.listener_name, message)) - LOGGER.info("Details:\n%s" % details) + LOGGER.error(f"Calling method '{self.method.__name__}' of listener " + f"'{self.listener_name}' failed: {message}") + LOGGER.info(f"Details:\n{details}") finally: ListenerMethod.called = False From 42ffe996d9dd3e42887572ee2235d08a39ceaf9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 18:47:42 +0200 Subject: [PATCH 0825/1592] Test VAR a bit more thoroughly with listeners. Data changes required changes also in unrelated tests. --- atest/robot/cli/model_modifiers/ModelModifier.py | 2 +- atest/robot/cli/model_modifiers/pre_rebot.robot | 8 ++++---- atest/robot/cli/model_modifiers/pre_run.robot | 2 +- .../remove_keywords/all_passed_tag_and_name.robot | 10 +++++----- .../output/listener_interface/using_run_keyword.robot | 11 ++++++----- atest/testdata/misc/for_loops.robot | 4 ++-- atest/testdata/misc/if_else.robot | 5 +++-- atest/testresources/listeners/VerifyAttributes.py | 3 ++- atest/testresources/listeners/listeners.py | 8 +++++--- 9 files changed, 29 insertions(+), 24 deletions(-) diff --git a/atest/robot/cli/model_modifiers/ModelModifier.py b/atest/robot/cli/model_modifiers/ModelModifier.py index a7dc85b4559..f1947bfc21b 100644 --- a/atest/robot/cli/model_modifiers/ModelModifier.py +++ b/atest/robot/cli/model_modifiers/ModelModifier.py @@ -46,7 +46,7 @@ def start_for_iteration(self, iteration): iteration.assign['${x}'] = 'new' def start_if_branch(self, branch): - if branch.condition == "'IF' == 'WRONG'": + if branch.condition == "'${x}' == 'wrong'": branch.condition = 'True' # With Robot if not hasattr(branch, 'status'): diff --git a/atest/robot/cli/model_modifiers/pre_rebot.robot b/atest/robot/cli/model_modifiers/pre_rebot.robot index dbe583e96e2..3490733e13f 100644 --- a/atest/robot/cli/model_modifiers/pre_rebot.robot +++ b/atest/robot/cli/model_modifiers/pre_rebot.robot @@ -75,10 +75,10 @@ Modify FOR Modify IF [Setup] Should Be Equal ${PREV TEST NAME} Modify FOR ${tc} = Check Test Case If structure - Should Be Equal ${tc.body[0].body[0].condition} modified - Should Be Equal ${tc.body[0].body[0].status} PASS - Should Be Equal ${tc.body[0].body[0].body[0].args[0]} got here! - Should Be Equal ${tc.body[0].body[1].status} PASS + Should Be Equal ${tc.body[1].body[0].condition} modified + Should Be Equal ${tc.body[1].body[0].status} PASS + Should Be Equal ${tc.body[1].body[0].body[0].args[0]} got here! + Should Be Equal ${tc.body[1].body[1].status} PASS *** Keywords *** Modify FOR and IF diff --git a/atest/robot/cli/model_modifiers/pre_run.robot b/atest/robot/cli/model_modifiers/pre_run.robot index 7032c79109c..48c6e5c9a56 100644 --- a/atest/robot/cli/model_modifiers/pre_run.robot +++ b/atest/robot/cli/model_modifiers/pre_run.robot @@ -68,4 +68,4 @@ Modify FOR and IF Check Log Message ${tc.body[0].body[1].body[0].msgs[0]} is Check Log Message ${tc.body[0].body[2].body[0].msgs[0]} modified! ${tc} = Check Test Case If structure - Check Log Message ${tc.body[0].body[0].body[0].msgs[0]} going here! + Check Log Message ${tc.body[1].body[0].body[0].msgs[0]} going here! diff --git a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot index 84076d4c224..e49c28eada7 100644 --- a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot +++ b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot @@ -37,11 +37,11 @@ Errors Are Removed In All Mode IF/ELSE in All mode [Setup] Previous test should have passed Errors Are Removed In All Mode ${tc} = Check Test Case IF structure - Length Should Be ${tc.body} 1 - Length Should Be ${tc.body[0].body} 3 - IF Branch Should Be Empty ${tc.body[0].body[0]} IF 'IF' == 'WRONG' - IF Branch Should Be Empty ${tc.body[0].body[1]} ELSE IF 'ELSE IF' == 'ELSE IF' - IF Branch Should Be Empty ${tc.body[0].body[2]} ELSE + Length Should Be ${tc.body} 2 + Length Should Be ${tc.body[1].body} 3 + IF Branch Should Be Empty ${tc.body[1].body[0]} IF '\${x}' == 'wrong' + IF Branch Should Be Empty ${tc.body[1].body[1]} ELSE IF '\${x}' == 'value' + IF Branch Should Be Empty ${tc.body[1].body[2]} ELSE FOR in All mode [Setup] Previous test should have passed IF/ELSE in All mode diff --git a/atest/robot/output/listener_interface/using_run_keyword.robot b/atest/robot/output/listener_interface/using_run_keyword.robot index 1682371cee5..59e78a46744 100644 --- a/atest/robot/output/listener_interface/using_run_keyword.robot +++ b/atest/robot/output/listener_interface/using_run_keyword.robot @@ -98,11 +98,12 @@ In start_keyword and end_keyword with WHILE In start_keyword and end_keyword with IF/ELSE ${tc} = Check Test Case IF structure - Should Be Equal ${tc.body[1].type} IF/ELSE ROOT - Length Should Be ${tc.body[1].body} 3 # Listener is not called with root - Validate IF branch ${tc.body[1].body[0]} IF NOT RUN # but is called with unexecuted branches. - Validate IF branch ${tc.body[1].body[1]} ELSE IF PASS - Validate IF branch ${tc.body[1].body[2]} ELSE NOT RUN + Should Be Equal ${tc.body[1].type} VAR + Should Be Equal ${tc.body[2].type} IF/ELSE ROOT + Length Should Be ${tc.body[2].body} 3 # Listener is not called with root + Validate IF branch ${tc.body[2].body[0]} IF NOT RUN # but is called with unexecuted branches. + Validate IF branch ${tc.body[2].body[1]} ELSE IF PASS + Validate IF branch ${tc.body[2].body[2]} ELSE NOT RUN In start_keyword and end_keyword with TRY/EXCEPT ${tc} = Check Test Case Everything diff --git a/atest/testdata/misc/for_loops.robot b/atest/testdata/misc/for_loops.robot index 12dbc86b1ca..865b37646f3 100644 --- a/atest/testdata/misc/for_loops.robot +++ b/atest/testdata/misc/for_loops.robot @@ -1,6 +1,5 @@ *** Variables *** @{ANIMALS} cat dog horse -@{FINNISH} kissa koira hevonen *** Test Cases *** FOR @@ -22,6 +21,7 @@ FOR IN ENUMERATE END FOR IN ZIP - FOR ${en} ${fi} IN ZIP ${ANIMALS} ${FINNISH} mode=LONGEST fill=- + VAR @{finnish} kissa koira hevonen + FOR ${en} ${fi} IN ZIP ${ANIMALS} ${finnish} mode=LONGEST fill=- Log ${en} is ${fi} in Finnish END diff --git a/atest/testdata/misc/if_else.robot b/atest/testdata/misc/if_else.robot index d60545eed0f..ac4bf4463ca 100644 --- a/atest/testdata/misc/if_else.robot +++ b/atest/testdata/misc/if_else.robot @@ -1,8 +1,9 @@ *** Test Cases *** IF structure - IF 'IF' == 'WRONG' + VAR ${x} value + IF '${x}' == 'wrong' Fail not going here - ELSE IF 'ELSE IF' == 'ELSE IF' + ELSE IF '${x}' == 'value' Log else if branch ELSE Fail not going here diff --git a/atest/testresources/listeners/VerifyAttributes.py b/atest/testresources/listeners/VerifyAttributes.py index 2409af77096..a45fb9cd786 100644 --- a/atest/testresources/listeners/VerifyAttributes.py +++ b/atest/testresources/listeners/VerifyAttributes.py @@ -7,7 +7,7 @@ TEST = 'id longname tags template originalname source lineno ' KW = 'kwname libname args assign tags type lineno source status ' KW_TYPES = {'FOR': 'variables flavor values', - 'WHILE': 'condition limit on_limit_message', + 'WHILE': 'condition limit on_limit on_limit_message', 'IF': 'condition', 'ELSE IF': 'condition', 'EXCEPT': 'patterns pattern_type variable', @@ -29,6 +29,7 @@ 'values': (list, dict), 'condition': str, 'limit': (str, type(None)), + 'on_limit': (str, type(None)), 'on_limit_message': (str, type(None)), 'patterns': (str, list), 'pattern_type': (str, type(None)), diff --git a/atest/testresources/listeners/listeners.py b/atest/testresources/listeners/listeners.py index fe7f5009e3e..cc21db89086 100644 --- a/atest/testresources/listeners/listeners.py +++ b/atest/testresources/listeners/listeners.py @@ -73,19 +73,21 @@ def start_keyword(self, name, attrs): % (attrs['type'], expected)) def _get_expected_type(self, kwname, libname, args, source, lineno, **ignore): + if kwname.startswith(('${x} ', '@{finnish} ')): + return 'VAR' if ' IN ' in kwname: return 'FOR' if ' = ' in kwname: return 'ITERATION' if not args: - if "'IF' == 'WRONG'" in kwname or '${i} == 9' in kwname: + if "'${x}' == 'wrong'" in kwname or '${i} == 9' in kwname: return 'IF' - if "'ELSE IF' == 'ELSE IF'" in kwname: + if "'${x}' == 'value'" in kwname: return 'ELSE IF' if kwname == '': source = os.path.basename(source) if source == 'for_loops.robot': - return 'BREAK' if lineno == 14 else 'CONTINUE' + return 'BREAK' if lineno == 13 else 'CONTINUE' return 'ELSE' expected = args[0] if libname == 'BuiltIn' else kwname return {'Suite Setup': 'SETUP', 'Suite Teardown': 'TEARDOWN', From 47afd7453dd8b02b27a7d2158e7490991b9f033b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 18:49:25 +0200 Subject: [PATCH 0826/1592] Add WHILE on_limit to listener v2 attributes. Fixes #4924. --- .../src/ExtendingRobotFramework/ListenerInterface.rst | 2 +- src/robot/output/listeners.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index abd55093549..4db6288b67c 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -270,7 +270,7 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | * `condition`: The looping condition. | | | | * `limit`: The maximum iteration limit. | | | | * `on_limit`: What to do if the limit is exceeded. | - | | | Valid values are `pass` and `fail`. New in RF 6.1. | + | | | Valid values are `pass` and `fail`. New in RF 7.0. | | | | * `on_limit_message`: The custom error raised when the | | | | limit of the WHILE loop is reached. New in RF 6.1. | | | | | diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index 98ac4de6dfd..be1292f589a 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -327,13 +327,13 @@ def end_for_iteration(self, data, result): def start_while(self, data, result): attrs = self._attrs(data, result, condition=result.condition, - limit=result.limit, + limit=result.limit, on_limit=result.on_limit, on_limit_message=result.on_limit_message) self._start_kw(result._log_name, attrs) def end_while(self, data, result): attrs = self._attrs(data, result, condition=result.condition, - limit=result.limit, + limit=result.limit, on_limit=result.on_limit, on_limit_message=result.on_limit_message, end=True) self._end_kw(result._log_name, attrs) From bb0ced7e8e30b02721f56a3be24261cf0cdf57fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 22:49:41 +0200 Subject: [PATCH 0827/1592] Add more information about VAR to listener v2 API. Earlier forgotten part of #3761. --- .../listeners/VerifyAttributes.py | 4 +++- .../ListenerInterface.rst | 18 +++++++++++++----- src/robot/output/listeners.py | 13 +++++++++++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/atest/testresources/listeners/VerifyAttributes.py b/atest/testresources/listeners/VerifyAttributes.py index a45fb9cd786..9fc3637e2cb 100644 --- a/atest/testresources/listeners/VerifyAttributes.py +++ b/atest/testresources/listeners/VerifyAttributes.py @@ -11,6 +11,7 @@ 'IF': 'condition', 'ELSE IF': 'condition', 'EXCEPT': 'patterns pattern_type variable', + 'VAR': 'name value scope', 'RETURN': 'values'} FOR_FLAVOR_EXTRA = {'IN ENUMERATE': ' start', 'IN ZIP': ' mode fill'} @@ -33,7 +34,8 @@ 'on_limit_message': (str, type(None)), 'patterns': (str, list), 'pattern_type': (str, type(None)), - 'variable': (str, type(None))} + 'variable': (str, type(None)), + 'value': (str, list)} def verify_attrs(method_name, attrs, names): diff --git a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst index 4db6288b67c..e5702368f01 100644 --- a/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst +++ b/doc/userguide/src/ExtendingRobotFramework/ListenerInterface.rst @@ -255,10 +255,11 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | * `values`: List of values being looped over | | | | as a list or strings. | | | | * `start`: Start configuration. Only used with `IN ENUMERATE` | - | | | loops. | + | | | loops. New in RF 6.1. | | | | * `mode`: Mode configuration. Only used with `IN ZIP` loops. | + | | | New in RF 6.1. | | | | * `fill`: Fill value configuration. Only used with `IN ZIP` | - | | | loops. | + | | | loops. New in RF 6.1. | | | | | | | | Additional attributes for `ITERATION` types with `FOR` loops: | | | | | @@ -277,6 +278,7 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | Additional attributes for `IF` and `ELSE IF` types: | | | | | | | | * `condition`: The conditional expression being evaluated. | + | | | With `ELSE IF` new in RF 6.1. | | | | | | | | Additional attributes for `EXCEPT` types: | | | | | @@ -289,9 +291,15 @@ it. If that is needed, `listener version 3`_ can be used instead. | | | | | | | * `values`: Return values from a keyword as a list or strings. | | | | | - | | | Additional attributes for control structures are new in RF 6.0.| - | | | `ELSE IF` `condition` as well as `FOR` loop `start`, `mode` | - | | | and `fill` are new in RF 6.1. | + | | | Additional attributes for `VAR` types: | + | | | | + | | | * `name`: Variable name. | + | | | * `value`: Variable value. A string with scalar variables and | + | | | a list otherwise. | + | | | * `scope`: Variable scope (e.g. `GLOBAL`) as a string. | + | | | | + | | | Additional attributes for control structures are in general | + | | | new in RF 6.0. `VAR` is new in RF 7.0. | +------------------+------------------+----------------------------------------------------------------+ | end_keyword | name, attributes | Called when a keyword ends. | | | | | diff --git a/src/robot/output/listeners.py b/src/robot/output/listeners.py index be1292f589a..a892d78167d 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -395,10 +395,19 @@ def end_error(self, data, result): self._end_kw(result._log_name, self._attrs(data, result, end=True)) def start_var(self, data, result): - self._start_kw(result._log_name, self._attrs(data, result)) + extra = self._var_extra_attrs(result) + self._start_kw(result._log_name, self._attrs(data, result, **extra)) def end_var(self, data, result): - self._end_kw(result._log_name, self._attrs(data, result, end=True)) + extra = self._var_extra_attrs(result) + self._end_kw(result._log_name, self._attrs(data, result, **extra, end=True)) + + def _var_extra_attrs(self, result): + if result.name.startswith('$'): + value = (result.separator or ' ').join(result.value) + else: + value = list(result.value) + return {'name': result.name, 'value': value, 'scope': result.scope or 'LOCAL'} def log_message(self, message): self._log_message(self._message_attributes(message)) From 89e20ce2a0fcf38764814cc030c605b51a9f0031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 2 Nov 2023 23:50:52 +0200 Subject: [PATCH 0828/1592] Refactor --- .../all_passed_tag_and_name.robot | 2 +- .../remove_keywords/for_loop_keywords.robot | 8 +- .../remove_keywords_resource.robot | 2 +- .../wait_until_keyword_succeeds.robot | 4 +- .../remove_keywords/while_loop_keywords.robot | 8 +- src/robot/conf/settings.py | 2 +- src/robot/result/keywordremover.py | 85 ++++++++++--------- src/robot/result/model.py | 4 +- 8 files changed, 59 insertions(+), 56 deletions(-) diff --git a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot index e49c28eada7..9763104aafe 100644 --- a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot +++ b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot @@ -173,7 +173,7 @@ Keyword Should Contain Removal Message IF $message ${message} = Set Variable ${message}
END - Should Be Equal ${keyword.message} *HTML* ${message}Data removed using --RemoveKeywords option. + Should Be Equal ${keyword.message} *HTML* ${message}${DATA REMOVED} Logged Warnings Are Preserved In Execution Errors Check Log Message ${ERRORS[1]} Warning in suite setup WARN diff --git a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot index 7e97b8a486d..2632b99163a 100644 --- a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot @@ -4,10 +4,10 @@ Suite Teardown Remove File ${INPUTFILE} Resource remove_keywords_resource.robot *** Variables *** -${1 REMOVED} 1 passing step removed using --RemoveKeywords option. -${2 REMOVED} 2 passing steps removed using --RemoveKeywords option. -${3 REMOVED} 3 passing steps removed using --RemoveKeywords option. -${4 REMOVED} 4 passing steps removed using --RemoveKeywords option. +${1 REMOVED} 1 passing item removed using the --remove-keywords option. +${2 REMOVED} 2 passing items removed using the --remove-keywords option. +${3 REMOVED} 3 passing items removed using the --remove-keywords option. +${4 REMOVED} 4 passing items removed using the --remove-keywords option. *** Test Cases *** Passed Steps Are Removed Except The Last One diff --git a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot index a6c63a81cb4..397a87dd213 100644 --- a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot +++ b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot @@ -3,7 +3,7 @@ Resource rebot_resource.robot *** Variables *** ${INPUTFILE} %{TEMPDIR}${/}rebot-test-rmkw.xml -${DATA REMOVED} Data removed using --RemoveKeywords option. +${DATA REMOVED} Content removed using the --remove-keywords option. *** Keywords *** Keyword Should Be Empty diff --git a/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot b/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot index c81a504604a..5c5d9a409c2 100644 --- a/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot +++ b/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot @@ -7,12 +7,12 @@ Last failing Step is not removed ${tc}= Check Number Of Keywords Fail Until The End 1 ${expected} = Catenate ... [*]HTML[*] Keyword 'Fail' failed after retrying for 50 milliseconds. - ... The last error was: Not gonna happen
? failing step* removed using --RemoveKeywords option. + ... The last error was: Not gonna happen
? failing item* removed using the --remove-keywords option. Should Match ${tc.body[0].message} ${expected} Last passing Step is not removed ${tc}= Check Number Of Keywords Passes before timeout 2 - Should Be Equal ${tc.body[0].message} *HTML* 1 failing step removed using --RemoveKeywords option. + Should Be Equal ${tc.body[0].message} *HTML* 1 failing item removed using the --remove-keywords option. Steps containing warnings are not removed ${tc}= Check Number Of Keywords Warnings 3 diff --git a/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot b/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot index 4a7d0b5a812..4f07612fcef 100644 --- a/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot @@ -1,11 +1,11 @@ *** Settings *** -Suite Setup Remove For Loop Keywords With Rebot +Suite Setup Remove WHILE Keywords With Rebot Suite Teardown Remove File ${INPUTFILE} Resource remove_keywords_resource.robot *** Variables *** -${2 REMOVED} 2 passing steps removed using --RemoveKeywords option. -${4 REMOVED} 4 passing steps removed using --RemoveKeywords option. +${2 REMOVED} 2 passing items removed using the --remove-keywords option. +${4 REMOVED} 4 passing items removed using the --remove-keywords option. *** Test Cases *** Passed Steps Are Removed Except The Last One @@ -30,6 +30,6 @@ Steps From Nested Loops Are Removed Should Be Equal ${tc.kws[0].kws[0].kws[2].message} *HTML* ${2 REMOVED} *** Keywords *** -Remove For Loop Keywords With Rebot +Remove WHILE Keywords With Rebot Create Output With Robot ${INPUTFILE} ${EMPTY} running/while/while.robot Run Rebot --removekeywords while ${INPUTFILE} diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index b6defd1cb17..d1257aacdf5 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -339,7 +339,7 @@ def _split_pythonpath(self, path): def _validate_remove_keywords(self, values): for value in values: try: - KeywordRemover(value) + KeywordRemover.from_config(value) except DataError as err: self._raise_invalid('RemoveKeywords', err) diff --git a/src/robot/result/keywordremover.py b/src/robot/result/keywordremover.py index f174baac334..91cae6e1f65 100644 --- a/src/robot/result/keywordremover.py +++ b/src/robot/result/keywordremover.py @@ -13,37 +13,39 @@ # See the License for the specific language governing permissions and # limitations under the License. +from abc import ABC + from robot.errors import DataError from robot.model import SuiteVisitor, TagPattern from robot.utils import html_escape, Matcher, plural_or_not -def KeywordRemover(how): - upper = how.upper() - if upper.startswith('NAME:'): - return ByNameKeywordRemover(pattern=how[5:]) - if upper.startswith('TAG:'): - return ByTagKeywordRemover(pattern=how[4:]) - try: - return {'ALL': AllKeywordsRemover, - 'PASSED': PassedKeywordRemover, - 'FOR': ForLoopItemsRemover, - 'WHILE': WhileLoopItemsRemover, - 'WUKS': WaitUntilKeywordSucceedsRemover}[upper]() - except KeyError: - raise DataError(f"Expected 'ALL', 'PASSED', 'NAME:', 'TAG:', " - f"'FOR' or 'WUKS', got '{how}'.") - - -class _KeywordRemover(SuiteVisitor): - _message = 'Data removed using --RemoveKeywords option.' +class KeywordRemover(SuiteVisitor, ABC): + message = 'Content removed using the --remove-keywords option.' def __init__(self): - self._removal_message = RemovalMessage(self._message) + self.removal_message = RemovalMessage(self.message) + + @classmethod + def from_config(cls, conf): + upper = conf.upper() + if upper.startswith('NAME:'): + return ByNameKeywordRemover(pattern=conf[5:]) + if upper.startswith('TAG:'): + return ByTagKeywordRemover(pattern=conf[4:]) + try: + return {'ALL': AllKeywordsRemover, + 'PASSED': PassedKeywordRemover, + 'FOR': ForLoopItemsRemover, + 'WHILE': WhileLoopItemsRemover, + 'WUKS': WaitUntilKeywordSucceedsRemover}[upper]() + except KeyError: + raise DataError(f"Expected 'ALL', 'PASSED', 'NAME:', " + f"'TAG:', 'FOR' or 'WUKS', got '{conf}'.") def _clear_content(self, item): item.body.clear() - self._removal_message.set(item) + self.removal_message.set_to(item) def _failed_or_warning_or_error(self, item): return not item.passed or self._warning_or_error(item) @@ -54,7 +56,7 @@ def _warning_or_error(self, item): return finder.found -class AllKeywordsRemover(_KeywordRemover): +class AllKeywordsRemover(KeywordRemover): def visit_keyword(self, keyword): self._clear_content(keyword) @@ -66,7 +68,7 @@ def visit_if_branch(self, branch): self._clear_content(branch) -class PassedKeywordRemover(_KeywordRemover): +class PassedKeywordRemover(KeywordRemover): def start_suite(self, suite): if not suite.statistics.failed: @@ -76,14 +78,14 @@ def start_suite(self, suite): def visit_test(self, test): if not self._failed_or_warning_or_error(test): - for keyword in test.body: - self._clear_content(keyword) + for item in test.body: + self._clear_content(item) def visit_keyword(self, keyword): pass -class ByNameKeywordRemover(_KeywordRemover): +class ByNameKeywordRemover(KeywordRemover): def __init__(self, pattern): super().__init__() @@ -94,7 +96,7 @@ def start_keyword(self, kw): self._clear_content(kw) -class ByTagKeywordRemover(_KeywordRemover): +class ByTagKeywordRemover(KeywordRemover): def __init__(self, pattern): super().__init__() @@ -105,13 +107,13 @@ def start_keyword(self, kw): self._clear_content(kw) -class _LoopItemsRemover(_KeywordRemover): - _message = '%d passing step%s removed using --RemoveKeywords option.' +class LoopItemsRemover(KeywordRemover, ABC): + message = '{count} passing item{s} removed using the --remove-keywords option.' def _remove_from_loop(self, loop): before = len(loop.body) self._remove_keywords(loop.body) - self._removal_message.set_if_removed(loop, before) + self.removal_message.set_to_if_removed(loop, before) def _remove_keywords(self, body): iterations = body.filter(messages=False) @@ -120,26 +122,26 @@ def _remove_keywords(self, body): body.remove(it) -class ForLoopItemsRemover(_LoopItemsRemover): +class ForLoopItemsRemover(LoopItemsRemover): def start_for(self, for_): self._remove_from_loop(for_) -class WhileLoopItemsRemover(_LoopItemsRemover): +class WhileLoopItemsRemover(LoopItemsRemover): def start_while(self, while_): self._remove_from_loop(while_) -class WaitUntilKeywordSucceedsRemover(_KeywordRemover): - _message = '%d failing step%s removed using --RemoveKeywords option.' +class WaitUntilKeywordSucceedsRemover(KeywordRemover): + message = '{count} failing item{s} removed using the --remove-keywords option.' def start_keyword(self, kw): if kw.owner == 'BuiltIn' and kw.name == 'Wait Until Keyword Succeeds': before = len(kw.body) self._remove_keywords(kw.body) - self._removal_message.set_if_removed(kw, before) + self.removal_message.set_to_if_removed(kw, before) def _remove_keywords(self, body): keywords = body.filter(messages=False) @@ -172,18 +174,19 @@ def visit_message(self, msg): class RemovalMessage: def __init__(self, message): - self._message = message + self.message = message - def set_if_removed(self, kw, len_before): - removed = len_before - len(kw.body) + def set_to_if_removed(self, item, len_before): + removed = len_before - len(item.body) if removed: - self.set(kw, self._message % (removed, plural_or_not(removed))) + message = self.message.format(count=removed, s=plural_or_not(removed)) + self.set_to(item, message) - def set(self, item, message=None): + def set_to(self, item, message=None): if not item.message: start = '' elif item.message.startswith('*HTML*'): start = item.message[6:].strip() + '
' else: start = html_escape(item.message) + '
' - item.message = f'*HTML* {start}{message or self._message}' + item.message = f'*HTML* {start}{message or self.message}' diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 174220de579..11fb1a2e0df 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -995,13 +995,13 @@ def suites(self, suites: 'Sequence[TestSuite|DataDict]') -> TestSuites['TestSuit def remove_keywords(self, how: str): """Remove keywords based on the given condition. - :param how: What approach to use when removing keywords. Either + :param how: Which approach to use when removing keywords. Either ``ALL``, ``PASSED``, ``FOR``, ``WUKS``, or ``NAME:``. For more information about the possible values see the documentation of the ``--removekeywords`` command line option. """ - self.visit(KeywordRemover(how)) + self.visit(KeywordRemover.from_config(how)) def filter_messages(self, log_level: str = 'TRACE'): """Remove log messages below the specified ``log_level``.""" From 88a29f706e38d8d6c8a0c51c08d8484e76128b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 3 Nov 2023 00:13:30 +0200 Subject: [PATCH 0829/1592] Remove WHILE and TRY content with `--removekeywords all` Fixes #4926. --- .../all_passed_tag_and_name.robot | 34 ++++++++++++++++--- .../remove_keywords_resource.robot | 6 ++++ src/robot/result/keywordremover.py | 23 ++++++++----- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot index 9763104aafe..2f84eba3541 100644 --- a/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot +++ b/atest/robot/cli/rebot/remove_keywords/all_passed_tag_and_name.robot @@ -29,22 +29,19 @@ Warnings Are Removed In All Mode Logged Warnings Are Preserved In Execution Errors Errors Are Removed In All Mode - [Setup] Previous test should have passed Warnings Are Removed In All Mode ${tc} = Check Test Case Error in test case Keyword Should Be Empty ${tc.body[0]} Error in test case Logged Errors Are Preserved In Execution Errors IF/ELSE in All mode - [Setup] Previous test should have passed Errors Are Removed In All Mode ${tc} = Check Test Case IF structure - Length Should Be ${tc.body} 2 + Length Should Be ${tc.body} 2 Length Should Be ${tc.body[1].body} 3 IF Branch Should Be Empty ${tc.body[1].body[0]} IF '\${x}' == 'wrong' IF Branch Should Be Empty ${tc.body[1].body[1]} ELSE IF '\${x}' == 'value' IF Branch Should Be Empty ${tc.body[1].body[2]} ELSE FOR in All mode - [Setup] Previous test should have passed IF/ELSE in All mode ${tc} = Check Test Case FOR Length Should Be ${tc.body} 1 FOR Loop Should Be Empty ${tc.body[0]} IN @@ -52,6 +49,33 @@ FOR in All mode Length Should Be ${tc.body} 1 FOR Loop Should Be Empty ${tc.body[0]} IN RANGE +TRY/EXCEPT in All mode + ${tc} = Check Test Case Everything + Length Should Be ${tc.body} 1 + Length Should Be ${tc.body[0].body} 5 + TRY Branch Should Be Empty ${tc.body[0].body[0]} TRY Ooops!
+ TRY Branch Should Be Empty ${tc.body[0].body[1]} EXCEPT + TRY Branch Should Be Empty ${tc.body[0].body[2]} EXCEPT + TRY Branch Should Be Empty ${tc.body[0].body[3]} ELSE + TRY Branch Should Be Empty ${tc.body[0].body[4]} FINALLY + +WHILE and VAR in All mode + ${tc} = Check Test Case WHILE loop executed multiple times + Length Should Be ${tc.body} 2 + Should Be Equal ${tc.body[1].type} WHILE + Should Be Empty ${tc.body[1].body} + Should Be Equal ${tc.body[1].message} *HTML* ${DATA REMOVED} + +VAR in All mode + ${tc} = Check Test Case IF structure + Should Be Equal ${tc.body[0].type} VAR + Should Be Empty ${tc.body[0].body} + Should Be Equal ${tc.body[0].message} ${EMPTY} + ${tc} = Check Test Case WHILE loop executed multiple times + Should Be Equal ${tc.body[0].type} VAR + Should Be Empty ${tc.body[0].body} + Should Be Equal ${tc.body[0].message} ${EMPTY} + Passed Mode [Setup] Run Rebot and set My Suite --removekeywords passed 0 Keyword Should Not Be Empty ${MY SUITE.setup} My Keyword Suite Setup @@ -155,6 +179,8 @@ Run Some Tests ... misc/warnings_and_errors.robot ... misc/if_else.robot ... misc/for_loops.robot + ... misc/try_except.robot + ... misc/while.robot Create Output With Robot ${INPUTFILE} ${EMPTY} ${suites} Run Rebot And Set My Suite diff --git a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot index 397a87dd213..d258be189bd 100644 --- a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot +++ b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot @@ -26,6 +26,12 @@ FOR Loop Should Be Empty Should Be Equal ${loop.flavor} ${flavor} Should Be Empty ${loop.body} +TRY Branch Should Be Empty + [Arguments] ${branch} ${type} ${message}= + Should Be Equal ${branch.message} *HTML* ${message}${DATA REMOVED} + Should Be Equal ${branch.type} ${type} + Should Be Empty ${branch.body} + Keyword Should Not Be Empty [Arguments] ${kw} ${name} @{args} Check Keyword Name And Args ${kw} ${name} @{args} diff --git a/src/robot/result/keywordremover.py b/src/robot/result/keywordremover.py index 91cae6e1f65..4081acbf464 100644 --- a/src/robot/result/keywordremover.py +++ b/src/robot/result/keywordremover.py @@ -44,8 +44,9 @@ def from_config(cls, conf): f"'TAG:', 'FOR' or 'WUKS', got '{conf}'.") def _clear_content(self, item): - item.body.clear() - self.removal_message.set_to(item) + if item.body: + item.body.clear() + self.removal_message.set_to(item) def _failed_or_warning_or_error(self, item): return not item.passed or self._warning_or_error(item) @@ -58,14 +59,20 @@ def _warning_or_error(self, item): class AllKeywordsRemover(KeywordRemover): - def visit_keyword(self, keyword): - self._clear_content(keyword) + def start_body_item(self, item): + self._clear_content(item) + + def start_if(self, item): + pass - def visit_for(self, for_): - self._clear_content(for_) + def start_if_branch(self, item): + self._clear_content(item) + + def start_try(self, item): + pass - def visit_if_branch(self, branch): - self._clear_content(branch) + def start_try_branch(self, item): + self._clear_content(item) class PassedKeywordRemover(KeywordRemover): From 7edadbbfe9b94e86e1698b8ddf2184652f555fff Mon Sep 17 00:00:00 2001 From: Jeff Li Date: Sat, 4 Nov 2023 00:09:33 +0800 Subject: [PATCH 0830/1592] Update CreatingTestSuites.rst (#4928) Correct a typo 'a test teardown' where 'a suite teardown' is intended. --- doc/userguide/src/CreatingTestData/CreatingTestSuites.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst index ba41dd030d5..2282e3e7a56 100644 --- a/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst +++ b/doc/userguide/src/CreatingTestData/CreatingTestSuites.rst @@ -216,7 +216,7 @@ Suite setup and teardown Not only `test cases`__ but also test suites can have a setup and a teardown. A suite setup is executed before running any of the suite's -test cases or child test suites, and a test teardown is executed after +test cases or child test suites, and a suite teardown is executed after them. All test suites can have a setup and a teardown; with suites created from a directory they must be specified in a `suite initialization file`_. From 8b87a95e63379d4f406f009b2087af6d3a48d8ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 3 Nov 2023 14:12:03 +0200 Subject: [PATCH 0831/1592] Add WARN to log level selector in log.html Fixes #4927. --- src/robot/htmldata/rebot/log.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/robot/htmldata/rebot/log.html b/src/robot/htmldata/rebot/log.html index 392d613885e..ba5515261da 100644 --- a/src/robot/htmldata/rebot/log.html +++ b/src/robot/htmldata/rebot/log.html @@ -386,6 +386,7 @@

{{= testOrTask('{Test}')}} Execution Errors

Log level: